From 4ab52e151472eaefb6387b71a7afeb3b8f7908f8 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 28 Sep 2026 14:29:31 -0500 Subject: [PATCH 1/2] feat(ebuy): add the GSA eBuy requests resource Adds `list_ebuy_requests()`, `get_ebuy_request()`, `get_ebuy_attachment_url()` and `get_ebuy_access()` over the `/api/ebuy/` resource (Tango API 5.1.0), with `EbuyRequest` / `EbuyAttachment` schemas, an `EbuyAccess` result type and the `EBUY_REQUESTS_MINIMAL` / `EBUY_REQUESTS_COMPREHENSIVE` default shapes. All fourteen of the API's list filters are explicit named parameters. `get_ebuy_attachment_url()` reads the download redirect without following it and returns the short-lived signed URL; a link entry raises `TangoValidationError` carrying the link. The response-status handling in `_request()` moves into `_raise_for_status()` so the attachment method can share it. `ebuy/requests` is now mapped in both conformance gates, and its unmapped-resource shape-coverage baseline entry is removed. `ebuy_request` is documented as an alert query type. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + README.md | 9 + contracts/shape_coverage_baseline.json | 3 +- docs/API_REFERENCE.md | 115 ++++++++ docs/WEBHOOKS.md | 1 + scripts/check_filter_shape_conformance.py | 1 + scripts/check_shape_coverage.py | 1 + tango/__init__.py | 6 + tango/client.py | 303 +++++++++++++++++--- tango/models.py | 111 ++++++++ tango/shapes/explicit_schemas.py | 93 +++++++ tango/shapes/generated_overlay.py | 26 +- tests/test_ebuy.py | 320 ++++++++++++++++++++++ 13 files changed, 929 insertions(+), 66 deletions(-) create mode 100644 tests/test_ebuy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8033529..e56fcfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **GSA eBuy requests** (Tango API 5.1.0). Four methods over the `/api/ebuy/` resource: `list_ebuy_requests()`, `get_ebuy_request()`, `get_ebuy_attachment_url()` and `get_ebuy_access()`, plus `EbuyRequest` / `EbuyAttachment` schemas, an `EbuyAccess` result type, and the `EBUY_REQUESTS_MINIMAL` / `EBUY_REQUESTS_COMPREHENSIVE` defaults. These are the RFQs, RFPs and RFIs posted to GSA eBuy. All fourteen of the API's filters are explicit named parameters, including `search` (which also matches attachment text), `reference_number`, `sin`, `agency` and `contract_number`. + + Access is scoped to your account: you see only requests posted under the GSA schedule contracts linked to it, and the endpoints require the Pro tier or above. With no linked contract, `list_ebuy_requests()` returns an empty page rather than an error, so `get_ebuy_access()` reports whether access is enabled, why not (`tier_required` or `no_contract_grant`), and which contracts are linked. `status` is frozen at the last state a request was seen in, because a request that closes stops appearing rather than getting a final row; read `last_seen` for staleness. `get_ebuy_attachment_url()` returns the short-lived signed URL the API redirects to without downloading the document, and raises `TangoValidationError` carrying the link when the entry is an external link rather than a stored document. +- **`alerts.ebuy_request.match` is an alertable event type.** `create_webhook_alert(query_type="ebuy_request", ...)` works. + ## [1.8.0] - 2026-09-28 ### Added diff --git a/README.md b/README.md index 7ccd97d..8a14fad 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,15 @@ protests = client.list_protests(source_system="gao", outcome="Sustained", limit= protest = client.get_protest("CASE_UUID") ``` +### GSA eBuy + +```python +access = client.get_ebuy_access() # scoped to the schedule contracts linked to your account +requests = client.list_ebuy_requests(status="Open", sin="54151S", limit=25) +request = client.get_ebuy_request("RFQ1835158") +url = client.get_ebuy_attachment_url("RFQ1835158", doc_seq_num=1) # short-lived signed URL +``` + ### GSA eLibrary Contracts ```python diff --git a/contracts/shape_coverage_baseline.json b/contracts/shape_coverage_baseline.json index 78b14b9..15207af 100644 --- a/contracts/shape_coverage_baseline.json +++ b/contracts/shape_coverage_baseline.json @@ -1,11 +1,10 @@ { "description": "Known reverse shape-coverage gaps (Tango exposes, SDK schema lacks), accepted as a tracked backlog. check_shape_coverage.py fails only on gaps NOT listed here. Burn down and regenerate with --update-baseline.", - "count": 15, + "count": 14, "known_gaps": [ "unmapped_resource|agencies_contracts_awarding|(root)|None", "unmapped_resource|agencies_contracts_funding|(root)|None", "unmapped_resource|contracts_subawards|(root)|None", - "unmapped_resource|ebuy/requests|(root)|None", "unmapped_resource|entities_contracts|(root)|None", "unmapped_resource|entities_idvs|(root)|None", "unmapped_resource|entities_lcats|(root)|None", diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index c947cd9..32d5094 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -24,6 +24,7 @@ Complete reference for all Tango Python SDK methods and functionality. - [Protests](#protests) - [Contract Appeals](#contract-appeals) - [Federal Register](#federal-register) +- [GSA eBuy](#gsa-ebuy) - [Budget](#budget) - [Business Types](#business-types) - [NAICS](#naics) @@ -1602,6 +1603,118 @@ forecast = client.get_sled_forecast("FORECAST_UUID") --- +## GSA eBuy + +GSA eBuy requests for quotes, proposals and information (RFQs, RFPs, RFIs), keyed by `rfq_id`. + +**Access is scoped to your account.** You see only requests posted under the GSA schedule contracts linked to your account, and the endpoints require the Pro tier or above (below it they return 403). With no linked contract, `list_ebuy_requests()` returns an empty page rather than an error, and `get_ebuy_request()` raises `TangoNotFoundError` for a request outside your scope, the same as for an id that does not exist. Use `get_ebuy_access()` to tell "no access" from "no matches". + +### list_ebuy_requests() + +List requests with filtering and shaping. + +```python +requests = client.list_ebuy_requests( + page=1, + limit=25, + shape=ShapeConfig.EBUY_REQUESTS_MINIMAL, + # Filter parameters (all optional) + search=None, + rfq_id=None, + reference_number=None, + request_type=None, + status=None, + sin=None, + schedule=None, + buyer_agency=None, + agency=None, + contract_number=None, + issue_date_after=None, + issue_date_before=None, + close_date_after=None, + close_date_before=None, + ordering=None, +) +``` + +**Filter Parameters:** +- `search` - Full-text search over the title, description, reference number, request id and attachment text. Results rank by relevance unless `ordering` is given +- `rfq_id` - Exact request id, e.g. `"RFQ1835158"` +- `reference_number` - The buyer's own solicitation number; dashes are ignored +- `request_type` - `"RFQ"`, `"RFP"` or `"RFI"` +- `status` - `"Open"` or `"Cancelled"`, as last seen (see the note below) +- `sin` - Special Item Number, e.g. `"54151S"` +- `schedule` - GSA schedule +- `buyer_agency` - The buyer agency as eBuy names it (free text) +- `agency` - A Tango agency name, abbreviation, code or organization key, e.g. `"GSA"`. Matches the whole organization subtree, so a department includes its sub-agencies +- `contract_number` - Narrow to requests posted under one of your own linked contracts. A contract not linked to your account returns an empty page, not an error +- `issue_date_after` / `issue_date_before` - Issue date range (`YYYY-MM-DD`, inclusive) +- `close_date_after` / `close_date_before` - Close date range (`YYYY-MM-DD`, inclusive) +- `ordering` - `issue_date` (the default, as `-issue_date`), `close_date`, `last_seen` or `modified`; prefix `-` for descending + +String filters accept several values joined with `|` (OR). + +**Returns:** [PaginatedResponse](#paginatedresponse) with request dictionaries + +**Example:** +```python +requests = client.list_ebuy_requests(status="Open", sin="54151S", ordering="close_date") + +for req in requests.results: + print(f"{req['rfq_id']} closes {req['close_date']}: {req['title']}") + print(f" last seen {req['last_seen']}") +``` + +**Notes:** +- **`status` is frozen at the last state the request was seen in.** Only currently-active requests are carried, so a request that closes stops appearing rather than getting a final row. `Open` means "open the last time it was seen", not "open now"; read `last_seen` for staleness. +- The contract number a request was posted under is never returned in any payload. +- `buyer_agency_code` and some other buyer and contact fields are sparse on older requests. + +### get_ebuy_request() + +Get a single request by `rfq_id`. + +```python +request = client.get_ebuy_request( + "RFQ1835158", + shape=ShapeConfig.EBUY_REQUESTS_COMPREHENSIVE, +) + +for attachment in request["attachments"]: + print(attachment["doc_seq_num"], attachment["doc_name"], attachment["is_link"]) +``` + +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. + +### get_ebuy_attachment_url() + +Get a short-lived download URL for one stored attachment. + +```python +url = client.get_ebuy_attachment_url("RFQ1835158", doc_seq_num=1) +``` + +**Returns:** The signed URL the API redirects to, as a string. The SDK reads the redirect without following it, so no document is downloaded. The URL expires after about five minutes: fetch it promptly, and call this again rather than storing it. + +**Raises:** +- `TangoValidationError` - The entry is an external link (`is_link`), not a stored document. The link is in the message and in `error.response_data["url"]` +- `TangoNotFoundError` - The request is unknown or outside your scope, the attachment does not exist, or its document has not been captured yet + +### get_ebuy_access() + +Check whether your account can read eBuy requests. + +```python +access = client.get_ebuy_access() +if not access.enabled: + print(access.reason) # "tier_required" or "no_contract_grant" +print(access.contracts) # your own linked contracts, sorted +``` + +**Returns:** `EbuyAccess` with `enabled` (bool), `reason` (`"tier_required"`, `"no_contract_grant"` or `None`; `tier_required` wins when both apply) and `contracts` (list of str). + +--- + ## Budget Federal account × fiscal year budget rollups, covering the full budget lifecycle (requested → enacted → apportioned → obligated → outlayed), pre-computed ratios and trends, the contract / assistance / unlinked breakdown, and request-vs-actual spend. @@ -2401,6 +2514,8 @@ entity = client.get_entity("UEI_KEY", shape=ShapeConfig.ENTITIES_COMPREHENSIVE) | `CONTRACT_APPEALS_COMPREHENSIVE` | `get_contract_appeal` | The list fields plus docket_raw, docket_source, decision_date_repaired, decision_type_raw, listing_year, first_listed_at, listed, text_status, text_char_count (omits `decision_text`, which needs an Enterprise plan) | | `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 | | `BUDGET_ACCOUNTS_MINIMAL` | `list_budget_accounts`, `get_budget_account` | id, federal_account_symbol, fiscal_year, agency_code/name, bureau_name, account_title, bea_category, on_off_budget, subfunction_code, lifecycle (requested/enacted/apportioned/obligated/outlayed/unobligated), contract & assistance rollups, key ratios, next-year growth | | `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/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index 6876a24..b4dba3e 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -90,6 +90,7 @@ tango webhooks list-event-types # alerts.sled_opportunity.match State/local/education solicitation matched a saved alert # alerts.contract_appeal.match CBCA/ASBCA appeal decision matched a saved alert # alerts.federal_register.match Federal Register document matched a saved alert +# alerts.ebuy_request.match GSA eBuy request matched a saved alert ``` This list is served by the API, so it is always current — the SDK does not hardcode diff --git a/scripts/check_filter_shape_conformance.py b/scripts/check_filter_shape_conformance.py index 986be2e..ca7b27f 100644 --- a/scripts/check_filter_shape_conformance.py +++ b/scripts/check_filter_shape_conformance.py @@ -79,6 +79,7 @@ "protests": "list_protests", "contract_appeals": "list_contract_appeals", "federal_register": "list_federal_register_documents", + "ebuy/requests": "list_ebuy_requests", "offices": "list_offices", "psc": "list_psc", "mas_sins": "list_mas_sins", diff --git a/scripts/check_shape_coverage.py b/scripts/check_shape_coverage.py index 055eaac..6c0e891 100644 --- a/scripts/check_shape_coverage.py +++ b/scripts/check_shape_coverage.py @@ -70,6 +70,7 @@ "protests": "Protest", "contract_appeals": "ContractAppeal", "federal_register": "FederalRegisterDocument", + "ebuy/requests": "EbuyRequest", "offices": "Office", "assistance_listings": "AssistanceListing", "business_types": "BusinessType", diff --git a/tango/__init__.py b/tango/__init__.py index e8efc1d..eb0bc30 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -14,6 +14,9 @@ DibbsAward, DibbsRfp, DibbsRfq, + EbuyAccess, + EbuyAttachment, + EbuyRequest, Exclusion, FederalRegisterDocument, GsaElibraryContract, @@ -69,6 +72,9 @@ "BudgetAccount", "ContractAppeal", "FederalRegisterDocument", + "EbuyAccess", + "EbuyAttachment", + "EbuyRequest", "DibbsAward", "DibbsRfp", "DibbsRfq", diff --git a/tango/client.py b/tango/client.py index 0c8a76f..274e77b 100644 --- a/tango/client.py +++ b/tango/client.py @@ -5,7 +5,7 @@ from datetime import date, datetime from decimal import Decimal from typing import Any, Literal, cast -from urllib.parse import urljoin +from urllib.parse import quote, urljoin import httpx @@ -28,6 +28,8 @@ DibbsAward, DibbsRfp, DibbsRfq, + EbuyAccess, + EbuyRequest, Entity, Exclusion, FederalRegisterDocument, @@ -175,55 +177,57 @@ def _request( self._last_response_headers = response.headers self._last_rate_limit_info = self._parse_rate_limit_headers(response.headers) - if response.status_code == 401: - raise TangoAuthError( - "Invalid API key or authentication required", response.status_code - ) - elif response.status_code == 404: - raise TangoNotFoundError("Resource not found", response.status_code) - elif response.status_code == 400: - error_data = response.json() if response.content else {} - error_msg = "Invalid request parameters" - if error_data: - # Try to extract a more specific error message - if isinstance(error_data, dict): - detail = ( - error_data.get("detail") - or error_data.get("message") - or error_data.get("error") - ) - if detail: - error_msg = f"Invalid request parameters: {detail}" - issues = error_data.get("issues") - if isinstance(issues, list): - rejected = [ - f"{issue['path']} ({issue['reason']})" - if issue.get("reason") - else str(issue["path"]) - for issue in issues - if isinstance(issue, dict) and issue.get("path") - ] - if rejected: - error_msg = f"{error_msg}: {', '.join(rejected)}" - raise TangoValidationError( - error_msg, - response.status_code, - error_data, - ) - elif response.status_code == 429: - error_data = response.json() if response.content else {} - detail = error_data.get("detail", "Rate limit exceeded") - raise TangoRateLimitError(detail, response.status_code, error_data) - elif not response.is_success: - raise TangoAPIError( - f"API request failed with status {response.status_code}", response.status_code - ) + self._raise_for_status(response) return response.json() if response.content else {} except httpx.HTTPError as e: raise TangoAPIError(f"Request failed: {str(e)}") from e + def _raise_for_status(self, response: httpx.Response) -> None: + """Raise the SDK exception matching a non-success response; return quietly on success.""" + if response.status_code == 401: + raise TangoAuthError("Invalid API key or authentication required", response.status_code) + elif response.status_code == 404: + raise TangoNotFoundError("Resource not found", response.status_code) + elif response.status_code == 400: + error_data = response.json() if response.content else {} + error_msg = "Invalid request parameters" + if error_data: + # Try to extract a more specific error message + if isinstance(error_data, dict): + detail = ( + error_data.get("detail") + or error_data.get("message") + or error_data.get("error") + ) + if detail: + error_msg = f"Invalid request parameters: {detail}" + issues = error_data.get("issues") + if isinstance(issues, list): + rejected = [ + f"{issue['path']} ({issue['reason']})" + if issue.get("reason") + else str(issue["path"]) + for issue in issues + if isinstance(issue, dict) and issue.get("path") + ] + if rejected: + error_msg = f"{error_msg}: {', '.join(rejected)}" + raise TangoValidationError( + error_msg, + response.status_code, + error_data, + ) + elif response.status_code == 429: + error_data = response.json() if response.content else {} + detail = error_data.get("detail", "Rate limit exceeded") + raise TangoRateLimitError(detail, response.status_code, error_data) + elif not response.is_success: + raise TangoAPIError( + f"API request failed with status {response.status_code}", response.status_code + ) + def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]: """Make a GET request""" return self._request("GET", endpoint, params=params) @@ -2963,6 +2967,215 @@ def get_federal_register_document( data, shape, FederalRegisterDocument, flat, flat_lists ) + # ============================================================================ + # GSA eBuy requests + # ============================================================================ + + def list_ebuy_requests( + self, + page: int = 1, + limit: int = 25, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + search: str | None = None, + rfq_id: str | None = None, + reference_number: str | None = None, + request_type: str | None = None, + status: str | None = None, + sin: str | None = None, + schedule: str | None = None, + buyer_agency: str | None = None, + agency: str | None = None, + contract_number: str | None = None, + issue_date_after: str | None = None, + issue_date_before: str | None = None, + close_date_after: str | None = None, + close_date_before: str | None = None, + ordering: str | None = None, + ) -> PaginatedResponse: + """ + List GSA eBuy requests (RFQs, RFPs and RFIs). + + Requires the Pro tier or above; below it the API returns 403. + Results are scoped to your account: you see only requests posted under the GSA schedule contracts linked to it. + With no linked contract the page is empty rather than an error, so use ``get_ebuy_access()`` to tell "no access" from "no matches". + + ``status`` is frozen at the last state the request was seen in. + Only currently-active requests are carried, so a request that closes stops appearing rather than getting a final row, and ``Open`` means "open the last time it was seen". + Read ``last_seen`` for staleness. + + Args: + page: Page number + limit: Results per page (max 100) + shape: Response shape string (defaults to minimal shape) + flat: If True, flatten nested objects in shaped response + flat_lists: If True, flatten arrays using indexed keys + search: Full-text search over the title, description, reference number, request id and attachment text. Results rank by relevance unless ``ordering`` is given + rfq_id: Exact request id, e.g. ``RFQ1835158``. OR several with ``|`` + reference_number: The buyer's own solicitation number; dashes are ignored + request_type: ``RFQ``, ``RFP`` or ``RFI``. OR several with ``|`` + status: ``Open`` or ``Cancelled``, as last seen. OR several with ``|`` + sin: Special Item Number, e.g. ``54151S``. OR several with ``|`` + schedule: GSA schedule. OR several with ``|`` + buyer_agency: The buyer agency as eBuy names it (free text). OR several with ``|`` + agency: A Tango agency name, abbreviation, code or organization key, e.g. ``GSA``. Matches the whole organization subtree, so a department includes its sub-agencies. OR several with ``|`` + contract_number: Narrow to requests posted under one of your own linked contracts. A contract not linked to your account returns an empty page, not an error + issue_date_after: Issued on or after (YYYY-MM-DD) + issue_date_before: Issued on or before (YYYY-MM-DD) + close_date_after: Closes on or after (YYYY-MM-DD) + close_date_before: Closes on or before (YYYY-MM-DD) + ordering: Sort field — ``issue_date`` (the default, as ``-issue_date``), ``close_date``, ``last_seen`` or ``modified``. Prefix ``-`` for descending + """ + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + + if shape is None: + shape = ShapeConfig.EBUY_REQUESTS_MINIMAL + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if flat_lists: + params["flat_lists"] = "true" + + for key, val in ( + ("search", search), + ("rfq_id", rfq_id), + ("reference_number", reference_number), + ("request_type", request_type), + ("status", status), + ("sin", sin), + ("schedule", schedule), + ("buyer_agency", buyer_agency), + ("agency", agency), + ("contract_number", contract_number), + ("issue_date_after", issue_date_after), + ("issue_date_before", issue_date_before), + ("close_date_after", close_date_after), + ("close_date_before", close_date_before), + ("ordering", ordering), + ): + if val is not None: + params[key] = val + + data = self._get("/api/ebuy/requests/", params) + + results = [ + self._parse_response_with_shape(item, shape, EbuyRequest, flat, flat_lists) + for item in data["results"] + ] + + return PaginatedResponse( + count=data["count"], + next=data.get("next"), + previous=data.get("previous"), + results=results, + meta=data.get("meta"), + ) + + def get_ebuy_request( + self, + rfq_id: str, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + ) -> Any: + """ + Get a single GSA eBuy request by its request id. + + A request outside your account's scope raises ``TangoNotFoundError``, the same as an id that does not exist. + + Args: + rfq_id: Request id, e.g. ``RFQ1835158`` + shape: Response shape string (defaults to the comprehensive shape: every field plus the ``organization`` and ``attachments`` expands) + flat: If True, flatten nested objects in shaped response + flat_lists: If True, flatten arrays using indexed keys + """ + params: dict[str, Any] = {} + if shape is None: + shape = ShapeConfig.EBUY_REQUESTS_COMPREHENSIVE + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if flat_lists: + params["flat_lists"] = "true" + + data = self._get(f"/api/ebuy/requests/{quote(rfq_id, safe='')}/", params) + return self._parse_response_with_shape(data, shape, EbuyRequest, flat, flat_lists) + + def get_ebuy_attachment_url(self, rfq_id: str, doc_seq_num: int) -> str: + """ + Get a short-lived download URL for one attachment on a GSA eBuy request. + + The API answers with a redirect to a signed URL that expires after about five minutes; this returns that URL without downloading the document. + Fetch it promptly, and call this again rather than storing it. + + Args: + rfq_id: Request id, e.g. ``RFQ1835158`` + doc_seq_num: The attachment's ``doc_seq_num`` from ``get_ebuy_request()`` + + Raises: + TangoValidationError: The entry is an external link (``is_link``), not a stored document. The link is in the message and in ``response_data["url"]``. + TangoNotFoundError: The request is unknown or outside your scope, the attachment does not exist, or its document has not been captured yet. + """ + endpoint = ( + f"/api/ebuy/requests/{quote(rfq_id, safe='')}/attachments/{int(doc_seq_num)}/download/" + ) + url = urljoin(f"{self.base_url}/", endpoint.lstrip("/")) + + try: + response = self.client.request(method="GET", url=url, follow_redirects=False) + except httpx.HTTPError as e: + raise TangoAPIError(f"Request failed: {str(e)}") from e + self._last_response_headers = response.headers + self._last_rate_limit_info = self._parse_rate_limit_headers(response.headers) + + location = response.headers.get("Location") + if response.is_redirect and location: + return str(location) + + if response.status_code in (400, 404): + try: + error_data = response.json() if response.content else {} + except ValueError: + error_data = {} + if not isinstance(error_data, dict): + error_data = {} + if response.status_code == 400 and error_data.get("url"): + raise TangoValidationError( + f"Attachment {doc_seq_num} on {rfq_id} is an external link, not a stored document: {error_data['url']}", + response.status_code, + error_data, + ) + if response.status_code == 404: + raise TangoNotFoundError( + error_data.get("detail") or "Resource not found", + response.status_code, + error_data, + ) + + self._raise_for_status(response) + raise TangoAPIError( + f"Expected a redirect to the attachment, got status {response.status_code}", + response.status_code, + ) + + def get_ebuy_access(self) -> EbuyAccess: + """ + Check whether your account can read GSA eBuy requests. + + ``list_ebuy_requests()`` returns an empty page, not an error, when no contract is linked; this tells the two apart. + ``reason`` is ``"tier_required"`` below the Pro tier (it wins when both apply), ``"no_contract_grant"`` when no contract is linked, and ``None`` when ``enabled`` is true. + ``contracts`` lists your own linked contracts, sorted. + """ + data = self._get("/api/ebuy/access/") + return EbuyAccess( + enabled=bool(data.get("enabled")), + reason=data.get("reason"), + contracts=list(data.get("contracts") or []), + ) + # ============================================================================ # DLA DIBBS (RFQs, RFPs, awards) # ============================================================================ @@ -5022,7 +5235,7 @@ def create_webhook_alert( query_type: One of ``opportunity``, ``contract``, ``idv``, ``ota``, ``otidv``, ``entity``, ``grant``, ``forecast``, ``exclusion``, ``dibbs_rfq``, ``dibbs_rfp``, ``sled_opportunity``, - ``contract_appeal`` or ``federal_register``. :meth:`list_webhook_event_types` is the + ``contract_appeal``, ``federal_register`` or ``ebuy_request``. :meth:`list_webhook_event_types` is the current authority — this list can only go stale. filters: Dict of query parameters that the alert matches against (e.g. ``{"naics": "541330", "set_aside": "SBA"}``). diff --git a/tango/models.py b/tango/models.py index 9ae11ea..7b4ebd4 100644 --- a/tango/models.py +++ b/tango/models.py @@ -1272,6 +1272,97 @@ class FederalRegisterDocument: full_text: str | None = None +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()`` refuses it. + """ + + doc_seq_num: int | None = None + doc_name: str | None = None + doc_type: int | None = None + doc_path: str | None = None + is_link: bool | None = None + doc_session_date: datetime | None = None + + +class EbuyRequest: + """Schema definition for EbuyRequest (not used for instances). + + GSA eBuy requests (RFQs, RFPs, RFIs) at ``/api/ebuy/requests/``, keyed by ``rfq_id``. + Shape-on-demand, so every field is optional. + + The resource is scoped to your account: you see only requests posted under the GSA schedule contracts linked to it. + + ``status`` is frozen at the last state the request was seen in. + Only currently-active requests are carried, so a request that closes stops appearing rather than getting a final row, and ``Open`` means "open the last time it was seen". + Read ``last_seen`` for staleness. + + ``buyer_agency_code`` and some other buyer and contact fields are sparse on older requests. + The contract number a request was posted under is never returned. + """ + + rfq_id: str | None = None + request_type: str | None = None + title: str | None = None + description: str | None = None + schedule: str | None = None + sin: str | None = None + status: str | None = None + buyer_name: str | None = None + buyer_agency: str | None = None + buyer_agency_code: str | None = None + buyer_email: str | None = None + buyer_user_id: str | None = None + reference_number: str | None = None + award_method: str | None = None + contract_type: str | None = None + commercial_type: str | None = None + follow_on: bool | None = None + source_sought: bool | None = None + issue_date: datetime | None = None + close_date: datetime | None = None + cancel_date: datetime | None = None + last_mod_date: datetime | None = None + pop_start_date: datetime | None = None + pop_end_date: datetime | None = None + oco_name: str | None = None + oco_title: str | None = None + oco_agency: str | None = None + oco_phone: str | None = None + oco_aac: str | None = None + ocs_name: str | None = None + ocs_title: str | None = None + ocs_agency: str | None = None + ocs_phone: str | None = None + ocs_aac: str | None = None + amendment_count: int | None = None + mod_version: int | None = None + qa_document_count: int | None = None + attachment_count: int | None = None + link_count: int | None = None + amendments: list[dict[str, Any]] | None = None + line_items: list[dict[str, Any]] | None = None + addresses: list[dict[str, Any]] | None = None + detail_fetched: bool | None = None + first_seen: datetime | None = None + last_seen: datetime | None = None + organization: dict[str, Any] | None = None + attachments: list[EbuyAttachment] | None = None + + +@dataclass +class EbuyAccess: + """Result of ``GET /api/ebuy/access/``: whether your account can read eBuy requests. + + ``list_ebuy_requests()`` returns an empty page, not an error, when no contract is linked, so this is how to tell "no access" from "no matches". + """ + + enabled: bool + reason: Literal["tier_required", "no_contract_grant"] | None + contracts: list[str] + + @dataclass class PaginatedResponse[T]: """Paginated API response @@ -1471,6 +1562,26 @@ class ShapeConfig: "comment_url,regulations_dot_gov_url,raw_text_url,body_html_url" ) + # Default for list_ebuy_requests(). Mirrors the API's own list default. + EBUY_REQUESTS_MINIMAL: Final = ( + "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" + ) + + # Default for get_ebuy_request(). Mirrors the API's own retrieve default; the attachment fields are named rather than `attachments(*)` so their dates parse. + 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," + "award_method,contract_type,commercial_type,follow_on,source_sought,issue_date," + "close_date,cancel_date,last_mod_date,pop_start_date,pop_end_date,oco_name," + "oco_title,oco_agency,oco_phone,oco_aac,ocs_name,ocs_title,ocs_agency,ocs_phone," + "ocs_aac,amendment_count,mod_version,qa_document_count,attachment_count," + "link_count,amendments,line_items,addresses,detail_fetched,first_seen,last_seen," + "organization(*)," + "attachments(doc_seq_num,doc_name,doc_type,doc_path,is_link,doc_session_date)" + ) + # Default for list_dibbs_rfqs() DIBBS_RFQS_MINIMAL: Final = ( "uuid,solicitation,nsn,part_number,nomenclature,quantity,issue_date,return_by_date,is_open" diff --git a/tango/shapes/explicit_schemas.py b/tango/shapes/explicit_schemas.py index ed94621..2279959 100644 --- a/tango/shapes/explicit_schemas.py +++ b/tango/shapes/explicit_schemas.py @@ -877,6 +877,97 @@ } +EBUY_ATTACHMENT_SCHEMA: dict[str, FieldSchema] = { + "doc_seq_num": FieldSchema(name="doc_seq_num", type=int, is_optional=True, is_list=False), + "doc_name": FieldSchema(name="doc_name", type=str, is_optional=True, is_list=False), + "doc_type": FieldSchema(name="doc_type", type=int, is_optional=True, is_list=False), + "doc_path": FieldSchema(name="doc_path", type=str, is_optional=True, is_list=False), + "is_link": FieldSchema(name="is_link", type=bool, is_optional=True, is_list=False), + "doc_session_date": FieldSchema( + name="doc_session_date", type=datetime, is_optional=True, is_list=False + ), +} + + +EBUY_REQUEST_SCHEMA: dict[str, FieldSchema] = { + "rfq_id": FieldSchema(name="rfq_id", type=str, is_optional=True, is_list=False), + "request_type": FieldSchema(name="request_type", type=str, is_optional=True, is_list=False), + "title": FieldSchema(name="title", type=str, is_optional=True, is_list=False), + "description": FieldSchema(name="description", type=str, is_optional=True, is_list=False), + "schedule": FieldSchema(name="schedule", type=str, is_optional=True, is_list=False), + "sin": FieldSchema(name="sin", type=str, is_optional=True, is_list=False), + "status": FieldSchema(name="status", type=str, is_optional=True, is_list=False), + "buyer_name": FieldSchema(name="buyer_name", type=str, is_optional=True, is_list=False), + "buyer_agency": FieldSchema(name="buyer_agency", type=str, is_optional=True, is_list=False), + "buyer_agency_code": FieldSchema( + name="buyer_agency_code", type=str, is_optional=True, is_list=False + ), + "buyer_email": FieldSchema(name="buyer_email", type=str, is_optional=True, is_list=False), + "buyer_user_id": FieldSchema(name="buyer_user_id", type=str, is_optional=True, is_list=False), + "reference_number": FieldSchema( + name="reference_number", type=str, is_optional=True, is_list=False + ), + "award_method": FieldSchema(name="award_method", type=str, is_optional=True, is_list=False), + "contract_type": FieldSchema(name="contract_type", type=str, is_optional=True, is_list=False), + "commercial_type": FieldSchema( + name="commercial_type", type=str, is_optional=True, is_list=False + ), + "follow_on": FieldSchema(name="follow_on", type=bool, is_optional=True, is_list=False), + "source_sought": FieldSchema(name="source_sought", type=bool, is_optional=True, is_list=False), + "issue_date": FieldSchema(name="issue_date", type=datetime, is_optional=True, is_list=False), + "close_date": FieldSchema(name="close_date", type=datetime, is_optional=True, is_list=False), + "cancel_date": FieldSchema(name="cancel_date", type=datetime, is_optional=True, is_list=False), + "last_mod_date": FieldSchema( + name="last_mod_date", type=datetime, is_optional=True, is_list=False + ), + "pop_start_date": FieldSchema( + name="pop_start_date", type=datetime, is_optional=True, is_list=False + ), + "pop_end_date": FieldSchema( + name="pop_end_date", type=datetime, is_optional=True, is_list=False + ), + "oco_name": FieldSchema(name="oco_name", type=str, is_optional=True, is_list=False), + "oco_title": FieldSchema(name="oco_title", type=str, is_optional=True, is_list=False), + "oco_agency": FieldSchema(name="oco_agency", type=str, is_optional=True, is_list=False), + "oco_phone": FieldSchema(name="oco_phone", type=str, is_optional=True, is_list=False), + "oco_aac": FieldSchema(name="oco_aac", type=str, is_optional=True, is_list=False), + "ocs_name": FieldSchema(name="ocs_name", type=str, is_optional=True, is_list=False), + "ocs_title": FieldSchema(name="ocs_title", type=str, is_optional=True, is_list=False), + "ocs_agency": FieldSchema(name="ocs_agency", type=str, is_optional=True, is_list=False), + "ocs_phone": FieldSchema(name="ocs_phone", type=str, is_optional=True, is_list=False), + "ocs_aac": FieldSchema(name="ocs_aac", type=str, is_optional=True, is_list=False), + "amendment_count": FieldSchema( + name="amendment_count", type=int, is_optional=True, is_list=False + ), + "mod_version": FieldSchema(name="mod_version", type=int, is_optional=True, is_list=False), + "qa_document_count": FieldSchema( + name="qa_document_count", type=int, is_optional=True, is_list=False + ), + "attachment_count": FieldSchema( + name="attachment_count", type=int, is_optional=True, is_list=False + ), + "link_count": FieldSchema(name="link_count", type=int, is_optional=True, is_list=False), + "amendments": FieldSchema(name="amendments", type=dict, is_optional=True, is_list=True), + "line_items": FieldSchema(name="line_items", type=dict, is_optional=True, is_list=True), + "addresses": FieldSchema(name="addresses", type=dict, is_optional=True, is_list=True), + "detail_fetched": FieldSchema( + name="detail_fetched", type=bool, is_optional=True, is_list=False + ), + "first_seen": FieldSchema(name="first_seen", type=datetime, is_optional=True, is_list=False), + "last_seen": FieldSchema(name="last_seen", type=datetime, is_optional=True, is_list=False), + "organization": FieldSchema( + name="organization", + type=dict, + is_optional=True, + is_list=False, + nested_model="OrganizationOffice", + ), + "attachments": FieldSchema( + name="attachments", type=dict, is_optional=True, is_list=True, nested_model="EbuyAttachment" + ), +} + + AGENCY_SCHEMA: dict[str, FieldSchema] = { "abbreviation": FieldSchema(name="abbreviation", type=str, is_optional=True, is_list=False), "code": FieldSchema(name="code", type=str, is_optional=False, is_list=False), @@ -1623,6 +1714,8 @@ "ProtestDocket": PROTEST_DOCKET_SCHEMA, "ContractAppeal": CONTRACT_APPEAL_SCHEMA, "FederalRegisterDocument": FEDERAL_REGISTER_DOCUMENT_SCHEMA, + "EbuyRequest": EBUY_REQUEST_SCHEMA, + "EbuyAttachment": EBUY_ATTACHMENT_SCHEMA, "Agency": AGENCY_SCHEMA, "Grant": GRANT_SCHEMA, # Vehicles (Awards) diff --git a/tango/shapes/generated_overlay.py b/tango/shapes/generated_overlay.py index d4d96be..c781166 100644 --- a/tango/shapes/generated_overlay.py +++ b/tango/shapes/generated_overlay.py @@ -81,17 +81,6 @@ } ATTACHMENTS_SCHEMA: dict[str, FieldSchema] = { - "doc_name": FieldSchema(name="doc_name", type=str, is_optional=True, is_list=False), - "doc_path": FieldSchema(name="doc_path", type=str, is_optional=True, is_list=False), - "doc_seq_num": FieldSchema(name="doc_seq_num", type=str, is_optional=True, is_list=False), - "doc_session_date": FieldSchema( - name="doc_session_date", type=date, is_optional=True, is_list=False - ), - "doc_type": FieldSchema(name="doc_type", type=str, is_optional=True, is_list=False), - "is_link": FieldSchema(name="is_link", type=bool, is_optional=True, is_list=False), -} - -ATTACHMENTS2_SCHEMA: dict[str, FieldSchema] = { "attachment_id": FieldSchema(name="attachment_id", type=str, is_optional=True, is_list=False), "doc_role": FieldSchema(name="doc_role", type=str, is_optional=True, is_list=False), "doc_role_alt": FieldSchema(name="doc_role_alt", type=str, is_optional=True, is_list=False), @@ -105,7 +94,7 @@ "url": FieldSchema(name="url", type=str, is_optional=True, is_list=False), } -ATTACHMENTS3_SCHEMA: dict[str, FieldSchema] = { +ATTACHMENTS2_SCHEMA: dict[str, FieldSchema] = { "attachment_id": FieldSchema(name="attachment_id", type=str, is_optional=True, is_list=False), "doc_role": FieldSchema(name="doc_role", type=str, is_optional=True, is_list=False), "doc_role_alt": FieldSchema(name="doc_role_alt", type=str, is_optional=True, is_list=False), @@ -119,7 +108,7 @@ "url": FieldSchema(name="url", type=str, is_optional=True, is_list=False), } -ATTACHMENTS4_SCHEMA: dict[str, FieldSchema] = { +ATTACHMENTS3_SCHEMA: dict[str, FieldSchema] = { "char_count": FieldSchema(name="char_count", type=int, is_optional=True, is_list=False), "checksum": FieldSchema(name="checksum", type=str, is_optional=True, is_list=False), "download_status": FieldSchema( @@ -144,7 +133,7 @@ "word_count": FieldSchema(name="word_count", type=int, is_optional=True, is_list=False), } -ATTACHMENTS5_SCHEMA: dict[str, FieldSchema] = { +ATTACHMENTS4_SCHEMA: dict[str, FieldSchema] = { "attachment_id": FieldSchema(name="attachment_id", type=str, is_optional=True, is_list=False), "file_size": FieldSchema(name="file_size", type=str, is_optional=True, is_list=False), "mime_type": FieldSchema(name="mime_type", type=str, is_optional=True, is_list=False), @@ -155,7 +144,7 @@ "url": FieldSchema(name="url", type=str, is_optional=True, is_list=False), } -ATTACHMENTS6_SCHEMA: dict[str, FieldSchema] = { +ATTACHMENTS5_SCHEMA: dict[str, FieldSchema] = { "attachment_id": FieldSchema(name="attachment_id", type=str, is_optional=True, is_list=False), "doc_role": FieldSchema(name="doc_role", type=str, is_optional=True, is_list=False), "doc_role_alt": FieldSchema(name="doc_role_alt", type=str, is_optional=True, is_list=False), @@ -1119,7 +1108,6 @@ "Attachments3": ATTACHMENTS3_SCHEMA, "Attachments4": ATTACHMENTS4_SCHEMA, "Attachments5": ATTACHMENTS5_SCHEMA, - "Attachments6": ATTACHMENTS6_SCHEMA, "Awardee": AWARDEE_SCHEMA, "AwardingOffice": AWARDING_OFFICE_SCHEMA, "AwardingOffice2": AWARDING_OFFICE2_SCHEMA, @@ -2238,7 +2226,7 @@ type=dict, is_optional=True, is_list=True, - nested_model="Attachments2", + nested_model="Attachments", ), "meta": FieldSchema( name="meta", type=dict, is_optional=True, is_list=False, nested_model="Meta" @@ -2486,7 +2474,7 @@ type=dict, is_optional=True, is_list=False, - nested_model="Attachments6", + nested_model="Attachments5", ), "department": FieldSchema( name="department", @@ -2873,7 +2861,7 @@ type=dict, is_optional=True, is_list=True, - nested_model="Attachments4", + nested_model="Attachments3", ), "bid_opening_date": FieldSchema( name="bid_opening_date", type=datetime, is_optional=True, is_list=False diff --git a/tests/test_ebuy.py b/tests/test_ebuy.py new file mode 100644 index 0000000..5da6bec --- /dev/null +++ b/tests/test_ebuy.py @@ -0,0 +1,320 @@ +"""Tests for the GSA eBuy requests endpoints. + +Covers the request contract for the four methods (paths, filters passed through under the API's own param names, the documented default shapes, the attachment redirect read without following it) plus the shape schema that backs them. Requests are mocked. +""" + +from datetime import datetime +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from tango import EbuyAccess, TangoClient +from tango.exceptions import ( + ShapeValidationError, + TangoAPIError, + TangoNotFoundError, + TangoValidationError, +) +from tango.models import EbuyRequest, ShapeConfig +from tango.shapes.parser import ShapeParser + +RFQ_ID = "RFQ1835158" +SIGNED_URL = "https://documents.example.com/rfq/solicitation.pdf?signature=abc" + + +def _response( + status: int = 200, + payload: Any = None, + headers: dict[str, str] | None = None, +) -> Mock: + response = Mock() + response.status_code = status + response.is_success = 200 <= status < 300 + response.is_redirect = status in (301, 302, 303, 307, 308) + response.headers = headers or {} + response.json.return_value = payload if payload is not None else {} + response.content = b"{}" if payload is not None else b"" + return response + + +def _mock(mock_request, payload=None): + body = ( + payload + if payload is not None + else {"count": 0, "next": None, "previous": None, "results": []} + ) + mock_request.return_value = _response(200, body) + return mock_request.return_value + + +def _call_params(mock_request) -> dict: + return mock_request.call_args.kwargs.get("params") or {} + + +def _call_url(mock_request) -> str: + args = mock_request.call_args.args + return str(args[1]) if len(args) > 1 else str(mock_request.call_args.kwargs.get("url", "")) + + +def _row(**overrides) -> dict: + row = { + "rfq_id": RFQ_ID, + "request_type": "RFQ", + "title": "Cybersecurity assessment support", + "schedule": "MAS", + "sin": "54151HACS", + "status": "Open", + "buyer_name": "Jane Buyer", + "buyer_agency": "General Services Administration", + "buyer_agency_code": None, + "reference_number": "W912DY-26-Q-0012", + "issue_date": "2026-09-01T14:30:00Z", + "close_date": "2026-09-30T21:00:00Z", + "attachment_count": 2, + "link_count": 1, + "last_seen": "2026-09-27T06:00:00Z", + } + row.update(overrides) + return row + + +class TestListEbuyRequests: + @patch("tango.client.httpx.Client.request") + def test_list_path_and_filters(self, mock_request): + _mock(mock_request) + TangoClient(api_key="k").list_ebuy_requests( + search="cybersecurity assessment", + rfq_id="RFQ1835158|RFQ1835159", + reference_number="W912DY26Q0012", + request_type="RFQ|RFP", + status="Open", + sin="54151HACS", + schedule="MAS", + buyer_agency="General Services Administration", + agency="GSA", + contract_number="EXAMPLE-0001", + issue_date_after="2026-01-01", + issue_date_before="2026-06-30", + close_date_after="2026-07-01", + close_date_before="2026-12-31", + ordering="-close_date", + limit=10, + ) + + assert "/api/ebuy/requests/" in _call_url(mock_request) + assert _call_params(mock_request) == { + "page": 1, + "limit": 10, + "shape": ShapeConfig.EBUY_REQUESTS_MINIMAL, + "search": "cybersecurity assessment", + "rfq_id": "RFQ1835158|RFQ1835159", + "reference_number": "W912DY26Q0012", + "request_type": "RFQ|RFP", + "status": "Open", + "sin": "54151HACS", + "schedule": "MAS", + "buyer_agency": "General Services Administration", + "agency": "GSA", + "contract_number": "EXAMPLE-0001", + "issue_date_after": "2026-01-01", + "issue_date_before": "2026-06-30", + "close_date_after": "2026-07-01", + "close_date_before": "2026-12-31", + "ordering": "-close_date", + } + + @patch("tango.client.httpx.Client.request") + def test_no_filters_are_synthesized(self, mock_request): + _mock(mock_request) + TangoClient(api_key="k").list_ebuy_requests() + assert set(_call_params(mock_request)) == {"page", "limit", "shape"} + + @patch("tango.client.httpx.Client.request") + def test_limit_is_capped_at_the_api_maximum(self, mock_request): + _mock(mock_request) + TangoClient(api_key="k").list_ebuy_requests(limit=500) + assert _call_params(mock_request)["limit"] == 100 + + @patch("tango.client.httpx.Client.request") + def test_results_are_parsed(self, mock_request): + _mock(mock_request, {"count": 1, "next": None, "previous": None, "results": [_row()]}) + page = TangoClient(api_key="k").list_ebuy_requests() + + assert page.count == 1 + row = page.results[0] + assert row["rfq_id"] == RFQ_ID + assert isinstance(row["issue_date"], datetime) + assert (row["issue_date"].year, row["issue_date"].month, row["issue_date"].day) == ( + 2026, + 9, + 1, + ) + assert row["attachment_count"] == 2 + assert row["buyer_agency_code"] is None + + +class TestGetEbuyRequest: + @patch("tango.client.httpx.Client.request") + def test_get_uses_rfq_id_route_and_comprehensive_shape(self, mock_request): + _mock(mock_request, _row()) + TangoClient(api_key="k").get_ebuy_request(RFQ_ID) + + assert f"/api/ebuy/requests/{RFQ_ID}/" in _call_url(mock_request) + assert _call_params(mock_request)["shape"] == ShapeConfig.EBUY_REQUESTS_COMPREHENSIVE + + @patch("tango.client.httpx.Client.request") + def test_detail_fields_and_expands_parse(self, mock_request): + _mock( + mock_request, + _row( + description="Assess the agency's boundary controls.", + follow_on=False, + mod_version=3, + line_items=[{"description": "Assessment"}], + organization={ + "organization_id": "0f6c1d9e-5b7a-4c2e-9a41-3e8d2b7c6a10", + "department_code": "047", + "department_name": "General Services Administration", + "agency_code": "4732", + "agency_name": "Federal Acquisition Service", + "office_code": None, + "office_name": None, + }, + attachments=[ + { + "doc_seq_num": 1, + "doc_name": "solicitation.pdf", + "doc_type": 1, + "doc_path": "solicitation.pdf", + "is_link": False, + "doc_session_date": "2026-09-01T14:31:00Z", + }, + { + "doc_seq_num": 2, + "doc_name": "Q&A portal", + "doc_type": 3, + "doc_path": "https://example.gov/qa", + "is_link": True, + "doc_session_date": None, + }, + ], + ), + ) + row = TangoClient(api_key="k").get_ebuy_request(RFQ_ID) + + assert row["follow_on"] is False + assert row["mod_version"] == 3 + assert row["line_items"] == [{"description": "Assessment"}] + assert row["organization"]["agency_code"] == "4732" + assert [a["doc_seq_num"] for a in row["attachments"]] == [1, 2] + 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_out_of_scope_request_is_not_found(self, mock_request): + mock_request.return_value = _response(404, {"detail": "No EbuyRequest matches."}) + with pytest.raises(TangoNotFoundError): + TangoClient(api_key="k").get_ebuy_request("RFQ0000000") + + +class TestGetEbuyAttachmentUrl: + @patch("tango.client.httpx.Client.request") + def test_returns_redirect_target_without_following_it(self, mock_request): + mock_request.return_value = _response(302, headers={"Location": SIGNED_URL}) + + url = TangoClient(api_key="k").get_ebuy_attachment_url(RFQ_ID, 1) + + assert url == SIGNED_URL + assert mock_request.call_args.kwargs["follow_redirects"] is False + assert _call_url(mock_request).endswith( + f"/api/ebuy/requests/{RFQ_ID}/attachments/1/download/" + ) + + @patch("tango.client.httpx.Client.request") + def test_link_entry_surfaces_the_url(self, mock_request): + mock_request.return_value = _response( + 400, + { + "detail": "This entry is an external link, not a stored document.", + "url": "https://example.gov/qa", + }, + ) + + with pytest.raises(TangoValidationError) as excinfo: + TangoClient(api_key="k").get_ebuy_attachment_url(RFQ_ID, 2) + + assert "https://example.gov/qa" in str(excinfo.value) + assert excinfo.value.response_data["url"] == "https://example.gov/qa" + + @patch("tango.client.httpx.Client.request") + def test_uncaptured_document_is_not_found_with_detail(self, mock_request): + mock_request.return_value = _response( + 404, {"detail": "The document for this attachment has not been captured yet."} + ) + + with pytest.raises(TangoNotFoundError, match="not been captured yet"): + TangoClient(api_key="k").get_ebuy_attachment_url(RFQ_ID, 1) + + @patch("tango.client.httpx.Client.request") + def test_below_tier_raises_api_error(self, mock_request): + mock_request.return_value = _response(403, {"detail": "Upgrade required."}) + + with pytest.raises(TangoAPIError) as excinfo: + TangoClient(api_key="k").get_ebuy_attachment_url(RFQ_ID, 1) + + assert excinfo.value.status_code == 403 + + @patch("tango.client.httpx.Client.request") + def test_success_without_redirect_is_an_error(self, mock_request): + mock_request.return_value = _response(200, {}) + + with pytest.raises(TangoAPIError, match="Expected a redirect"): + TangoClient(api_key="k").get_ebuy_attachment_url(RFQ_ID, 1) + + +class TestGetEbuyAccess: + @patch("tango.client.httpx.Client.request") + def test_enabled(self, mock_request): + mock_request.return_value = _response( + 200, {"enabled": True, "reason": None, "contracts": ["EXAMPLE-0001", "EXAMPLE-0002"]} + ) + + access = TangoClient(api_key="k").get_ebuy_access() + + assert "/api/ebuy/access/" in _call_url(mock_request) + assert access == EbuyAccess( + enabled=True, reason=None, contracts=["EXAMPLE-0001", "EXAMPLE-0002"] + ) + + @patch("tango.client.httpx.Client.request") + def test_no_grant(self, mock_request): + mock_request.return_value = _response( + 200, {"enabled": False, "reason": "no_contract_grant", "contracts": []} + ) + + access = TangoClient(api_key="k").get_ebuy_access() + + assert access.enabled is False + assert access.reason == "no_contract_grant" + assert access.contracts == [] + + +class TestEbuyShapes: + @pytest.mark.parametrize( + "shape", + [ + ShapeConfig.EBUY_REQUESTS_MINIMAL, + 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", + ], + ) + def test_shape_validates(self, shape): + parser = ShapeParser(cache_enabled=True) + parser.validate(parser.parse(shape), EbuyRequest) + + def test_contract_number_is_a_filter_not_a_response_field(self): + parser = ShapeParser(cache_enabled=True) + with pytest.raises(ShapeValidationError): + parser.validate(parser.parse("rfq_id,contract_number"), EbuyRequest) From a60c6e946bca6720b1d9c13b6b33452c9b507b5c Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 28 Sep 2026 14:32:19 -0500 Subject: [PATCH 2/2] feat(ebuy): raise TangoAttachmentLinkError for link attachments `get_ebuy_attachment_url()` now raises `TangoAttachmentLinkError`, a `TangoValidationError` subclass exposing the link as `.url`, when the entry is an external link rather than a stored document. The `agency` filter on `list_ebuy_requests()` documents that it requires Tango API 5.3.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/API_REFERENCE.md | 4 ++-- tango/__init__.py | 2 ++ tango/client.py | 7 ++++--- tango/exceptions.py | 10 ++++++++++ tango/models.py | 2 +- tests/test_ebuy.py | 6 ++++-- 7 files changed, 24 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e56fcfa..50a1b6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **GSA eBuy requests** (Tango API 5.1.0). Four methods over the `/api/ebuy/` resource: `list_ebuy_requests()`, `get_ebuy_request()`, `get_ebuy_attachment_url()` and `get_ebuy_access()`, plus `EbuyRequest` / `EbuyAttachment` schemas, an `EbuyAccess` result type, and the `EBUY_REQUESTS_MINIMAL` / `EBUY_REQUESTS_COMPREHENSIVE` defaults. These are the RFQs, RFPs and RFIs posted to GSA eBuy. All fourteen of the API's filters are explicit named parameters, including `search` (which also matches attachment text), `reference_number`, `sin`, `agency` and `contract_number`. - Access is scoped to your account: you see only requests posted under the GSA schedule contracts linked to it, and the endpoints require the Pro tier or above. With no linked contract, `list_ebuy_requests()` returns an empty page rather than an error, so `get_ebuy_access()` reports whether access is enabled, why not (`tier_required` or `no_contract_grant`), and which contracts are linked. `status` is frozen at the last state a request was seen in, because a request that closes stops appearing rather than getting a final row; read `last_seen` for staleness. `get_ebuy_attachment_url()` returns the short-lived signed URL the API redirects to without downloading the document, and raises `TangoValidationError` carrying the link when the entry is an external link rather than a stored document. + Access is scoped to your account: you see only requests posted under the GSA schedule contracts linked to it, and the endpoints require the Pro tier or above. With no linked contract, `list_ebuy_requests()` returns an empty page rather than an error, so `get_ebuy_access()` reports whether access is enabled, why not (`tier_required` or `no_contract_grant`), and which contracts are linked. `status` is frozen at the last state a request was seen in, because a request that closes stops appearing rather than getting a final row; read `last_seen` for staleness. `get_ebuy_attachment_url()` returns the short-lived signed URL the API redirects to without downloading the document, and raises the new `TangoAttachmentLinkError` (a `TangoValidationError`) with the link on `.url` when the entry is an external link rather than a stored document. The `agency` filter requires Tango API 5.3.0. - **`alerts.ebuy_request.match` is an alertable event type.** `create_webhook_alert(query_type="ebuy_request", ...)` works. ## [1.8.0] - 2026-09-28 diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 32d5094..71c4c7d 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -1646,7 +1646,7 @@ requests = client.list_ebuy_requests( - `sin` - Special Item Number, e.g. `"54151S"` - `schedule` - GSA schedule - `buyer_agency` - The buyer agency as eBuy names it (free text) -- `agency` - A Tango agency name, abbreviation, code or organization key, e.g. `"GSA"`. Matches the whole organization subtree, so a department includes its sub-agencies +- `agency` - A Tango agency name, abbreviation, code or organization key, e.g. `"GSA"`. Matches the whole organization subtree, so a department includes its sub-agencies. Requires Tango API 5.3.0 - `contract_number` - Narrow to requests posted under one of your own linked contracts. A contract not linked to your account returns an empty page, not an error - `issue_date_after` / `issue_date_before` - Issue date range (`YYYY-MM-DD`, inclusive) - `close_date_after` / `close_date_before` - Close date range (`YYYY-MM-DD`, inclusive) @@ -1697,7 +1697,7 @@ url = client.get_ebuy_attachment_url("RFQ1835158", doc_seq_num=1) **Returns:** The signed URL the API redirects to, as a string. The SDK reads the redirect without following it, so no document is downloaded. The URL expires after about five minutes: fetch it promptly, and call this again rather than storing it. **Raises:** -- `TangoValidationError` - The entry is an external link (`is_link`), not a stored document. The link is in the message and in `error.response_data["url"]` +- `TangoAttachmentLinkError` (a `TangoValidationError`) - The entry is an external link (`is_link`), not a stored document. The link is on `error.url` - `TangoNotFoundError` - The request is unknown or outside your scope, the attachment does not exist, or its document has not been captured yet ### get_ebuy_access() diff --git a/tango/__init__.py b/tango/__init__.py index eb0bc30..987cf34 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -3,6 +3,7 @@ from .client import TangoClient from .exceptions import ( TangoAPIError, + TangoAttachmentLinkError, TangoAuthError, TangoNotFoundError, TangoRateLimitError, @@ -65,6 +66,7 @@ "TangoAuthError", "TangoNotFoundError", "TangoValidationError", + "TangoAttachmentLinkError", "TangoRateLimitError", "RateLimitInfo", "ResolveCandidate", diff --git a/tango/client.py b/tango/client.py index 274e77b..64854d1 100644 --- a/tango/client.py +++ b/tango/client.py @@ -11,6 +11,7 @@ from tango.exceptions import ( TangoAPIError, + TangoAttachmentLinkError, TangoAuthError, TangoNotFoundError, TangoRateLimitError, @@ -3019,7 +3020,7 @@ def list_ebuy_requests( sin: Special Item Number, e.g. ``54151S``. OR several with ``|`` schedule: GSA schedule. OR several with ``|`` buyer_agency: The buyer agency as eBuy names it (free text). OR several with ``|`` - agency: A Tango agency name, abbreviation, code or organization key, e.g. ``GSA``. Matches the whole organization subtree, so a department includes its sub-agencies. OR several with ``|`` + agency: A Tango agency name, abbreviation, code or organization key, e.g. ``GSA``. Matches the whole organization subtree, so a department includes its sub-agencies. OR several with ``|``. Requires Tango API 5.3.0 contract_number: Narrow to requests posted under one of your own linked contracts. A contract not linked to your account returns an empty page, not an error issue_date_after: Issued on or after (YYYY-MM-DD) issue_date_before: Issued on or before (YYYY-MM-DD) @@ -3116,7 +3117,7 @@ def get_ebuy_attachment_url(self, rfq_id: str, doc_seq_num: int) -> str: doc_seq_num: The attachment's ``doc_seq_num`` from ``get_ebuy_request()`` Raises: - TangoValidationError: The entry is an external link (``is_link``), not a stored document. The link is in the message and in ``response_data["url"]``. + TangoAttachmentLinkError: The entry is an external link (``is_link``), not a stored document. The link is on ``.url`` and in the message. Subclasses ``TangoValidationError``. TangoNotFoundError: The request is unknown or outside your scope, the attachment does not exist, or its document has not been captured yet. """ endpoint = ( @@ -3143,7 +3144,7 @@ def get_ebuy_attachment_url(self, rfq_id: str, doc_seq_num: int) -> str: if not isinstance(error_data, dict): error_data = {} if response.status_code == 400 and error_data.get("url"): - raise TangoValidationError( + raise TangoAttachmentLinkError( f"Attachment {doc_seq_num} on {rfq_id} is an external link, not a stored document: {error_data['url']}", response.status_code, error_data, diff --git a/tango/exceptions.py b/tango/exceptions.py index 7b12344..b2f4c1a 100644 --- a/tango/exceptions.py +++ b/tango/exceptions.py @@ -53,6 +53,16 @@ def available_fields(self) -> dict[str, Any] | None: return val if isinstance(val, dict) else None +class TangoAttachmentLinkError(TangoValidationError): + """The requested attachment is an external link, not a stored document, so there is nothing to download.""" + + @property + def url(self) -> str | None: + """The external link the entry points at.""" + val = self.response_data.get("url") + return val if isinstance(val, str) else None + + class TangoRateLimitError(TangoAPIError): """Rate limit exceeded error""" diff --git a/tango/models.py b/tango/models.py index 7b4ebd4..56f0e6c 100644 --- a/tango/models.py +++ b/tango/models.py @@ -1275,7 +1275,7 @@ class FederalRegisterDocument: 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()`` refuses it. + ``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. """ doc_seq_num: int | None = None diff --git a/tests/test_ebuy.py b/tests/test_ebuy.py index 5da6bec..dbbddf4 100644 --- a/tests/test_ebuy.py +++ b/tests/test_ebuy.py @@ -13,6 +13,7 @@ from tango.exceptions import ( ShapeValidationError, TangoAPIError, + TangoAttachmentLinkError, TangoNotFoundError, TangoValidationError, ) @@ -241,11 +242,12 @@ def test_link_entry_surfaces_the_url(self, mock_request): }, ) - with pytest.raises(TangoValidationError) as excinfo: + with pytest.raises(TangoAttachmentLinkError) as excinfo: TangoClient(api_key="k").get_ebuy_attachment_url(RFQ_ID, 2) + assert isinstance(excinfo.value, TangoValidationError) + assert excinfo.value.url == "https://example.gov/qa" assert "https://example.gov/qa" in str(excinfo.value) - assert excinfo.value.response_data["url"] == "https://example.gov/qa" @patch("tango.client.httpx.Client.request") def test_uncaptured_document_is_not_found_with_detail(self, mock_request):