tango-python 1.8.0__py3-none-any.whl → 1.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tango/__init__.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from .client import TangoClient
4
4
  from .exceptions import (
5
5
  TangoAPIError,
6
+ TangoAttachmentLinkError,
6
7
  TangoAuthError,
7
8
  TangoNotFoundError,
8
9
  TangoRateLimitError,
@@ -14,6 +15,9 @@ from .models import (
14
15
  DibbsAward,
15
16
  DibbsRfp,
16
17
  DibbsRfq,
18
+ EbuyAccess,
19
+ EbuyAttachment,
20
+ EbuyRequest,
17
21
  Exclusion,
18
22
  FederalRegisterDocument,
19
23
  GsaElibraryContract,
@@ -55,13 +59,14 @@ from .webhooks import (
55
59
  )
56
60
  from .webhooks.receiver import Delivery, WebhookReceiver
57
61
 
58
- __version__ = "1.8.0"
62
+ __version__ = "1.10.0"
59
63
  __all__ = [
60
64
  "TangoClient",
61
65
  "TangoAPIError",
62
66
  "TangoAuthError",
63
67
  "TangoNotFoundError",
64
68
  "TangoValidationError",
69
+ "TangoAttachmentLinkError",
65
70
  "TangoRateLimitError",
66
71
  "RateLimitInfo",
67
72
  "ResolveCandidate",
@@ -69,6 +74,9 @@ __all__ = [
69
74
  "BudgetAccount",
70
75
  "ContractAppeal",
71
76
  "FederalRegisterDocument",
77
+ "EbuyAccess",
78
+ "EbuyAttachment",
79
+ "EbuyRequest",
72
80
  "DibbsAward",
73
81
  "DibbsRfp",
74
82
  "DibbsRfq",
tango/client.py CHANGED
@@ -5,12 +5,13 @@ import warnings
5
5
  from datetime import date, datetime
6
6
  from decimal import Decimal
7
7
  from typing import Any, Literal, cast
8
- from urllib.parse import urljoin
8
+ from urllib.parse import quote, urljoin
9
9
 
10
10
  import httpx
11
11
 
12
12
  from tango.exceptions import (
13
13
  TangoAPIError,
14
+ TangoAttachmentLinkError,
14
15
  TangoAuthError,
15
16
  TangoNotFoundError,
16
17
  TangoRateLimitError,
@@ -28,6 +29,8 @@ from tango.models import (
28
29
  DibbsAward,
29
30
  DibbsRfp,
30
31
  DibbsRfq,
32
+ EbuyAccess,
33
+ EbuyRequest,
31
34
  Entity,
32
35
  Exclusion,
33
36
  FederalRegisterDocument,
@@ -175,55 +178,57 @@ class TangoClient:
175
178
  self._last_response_headers = response.headers
176
179
  self._last_rate_limit_info = self._parse_rate_limit_headers(response.headers)
177
180
 
178
- if response.status_code == 401:
179
- raise TangoAuthError(
180
- "Invalid API key or authentication required", response.status_code
181
- )
182
- elif response.status_code == 404:
183
- raise TangoNotFoundError("Resource not found", response.status_code)
184
- elif response.status_code == 400:
185
- error_data = response.json() if response.content else {}
186
- error_msg = "Invalid request parameters"
187
- if error_data:
188
- # Try to extract a more specific error message
189
- if isinstance(error_data, dict):
190
- detail = (
191
- error_data.get("detail")
192
- or error_data.get("message")
193
- or error_data.get("error")
194
- )
195
- if detail:
196
- error_msg = f"Invalid request parameters: {detail}"
197
- issues = error_data.get("issues")
198
- if isinstance(issues, list):
199
- rejected = [
200
- f"{issue['path']} ({issue['reason']})"
201
- if issue.get("reason")
202
- else str(issue["path"])
203
- for issue in issues
204
- if isinstance(issue, dict) and issue.get("path")
205
- ]
206
- if rejected:
207
- error_msg = f"{error_msg}: {', '.join(rejected)}"
208
- raise TangoValidationError(
209
- error_msg,
210
- response.status_code,
211
- error_data,
212
- )
213
- elif response.status_code == 429:
214
- error_data = response.json() if response.content else {}
215
- detail = error_data.get("detail", "Rate limit exceeded")
216
- raise TangoRateLimitError(detail, response.status_code, error_data)
217
- elif not response.is_success:
218
- raise TangoAPIError(
219
- f"API request failed with status {response.status_code}", response.status_code
220
- )
181
+ self._raise_for_status(response)
221
182
 
222
183
  return response.json() if response.content else {}
223
184
 
224
185
  except httpx.HTTPError as e:
225
186
  raise TangoAPIError(f"Request failed: {str(e)}") from e
226
187
 
188
+ def _raise_for_status(self, response: httpx.Response) -> None:
189
+ """Raise the SDK exception matching a non-success response; return quietly on success."""
190
+ if response.status_code == 401:
191
+ raise TangoAuthError("Invalid API key or authentication required", response.status_code)
192
+ elif response.status_code == 404:
193
+ raise TangoNotFoundError("Resource not found", response.status_code)
194
+ elif response.status_code == 400:
195
+ error_data = response.json() if response.content else {}
196
+ error_msg = "Invalid request parameters"
197
+ if error_data:
198
+ # Try to extract a more specific error message
199
+ if isinstance(error_data, dict):
200
+ detail = (
201
+ error_data.get("detail")
202
+ or error_data.get("message")
203
+ or error_data.get("error")
204
+ )
205
+ if detail:
206
+ error_msg = f"Invalid request parameters: {detail}"
207
+ issues = error_data.get("issues")
208
+ if isinstance(issues, list):
209
+ rejected = [
210
+ f"{issue['path']} ({issue['reason']})"
211
+ if issue.get("reason")
212
+ else str(issue["path"])
213
+ for issue in issues
214
+ if isinstance(issue, dict) and issue.get("path")
215
+ ]
216
+ if rejected:
217
+ error_msg = f"{error_msg}: {', '.join(rejected)}"
218
+ raise TangoValidationError(
219
+ error_msg,
220
+ response.status_code,
221
+ error_data,
222
+ )
223
+ elif response.status_code == 429:
224
+ error_data = response.json() if response.content else {}
225
+ detail = error_data.get("detail", "Rate limit exceeded")
226
+ raise TangoRateLimitError(detail, response.status_code, error_data)
227
+ elif not response.is_success:
228
+ raise TangoAPIError(
229
+ f"API request failed with status {response.status_code}", response.status_code
230
+ )
231
+
227
232
  def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
228
233
  """Make a GET request"""
229
234
  return self._request("GET", endpoint, params=params)
@@ -2963,6 +2968,215 @@ class TangoClient:
2963
2968
  data, shape, FederalRegisterDocument, flat, flat_lists
2964
2969
  )
2965
2970
 
2971
+ # ============================================================================
2972
+ # GSA eBuy requests
2973
+ # ============================================================================
2974
+
2975
+ def list_ebuy_requests(
2976
+ self,
2977
+ page: int = 1,
2978
+ limit: int = 25,
2979
+ shape: str | None = None,
2980
+ flat: bool = False,
2981
+ flat_lists: bool = False,
2982
+ search: str | None = None,
2983
+ rfq_id: str | None = None,
2984
+ reference_number: str | None = None,
2985
+ request_type: str | None = None,
2986
+ status: str | None = None,
2987
+ sin: str | None = None,
2988
+ schedule: str | None = None,
2989
+ buyer_agency: str | None = None,
2990
+ agency: str | None = None,
2991
+ contract_number: str | None = None,
2992
+ issue_date_after: str | None = None,
2993
+ issue_date_before: str | None = None,
2994
+ close_date_after: str | None = None,
2995
+ close_date_before: str | None = None,
2996
+ ordering: str | None = None,
2997
+ ) -> PaginatedResponse:
2998
+ """
2999
+ List GSA eBuy requests (RFQs, RFPs and RFIs).
3000
+
3001
+ Requires the Pro tier or above; below it the API returns 403.
3002
+ Results are scoped to your account: you see only requests posted under the GSA schedule contracts linked to it.
3003
+ With no linked contract the page is empty rather than an error, so use ``get_ebuy_access()`` to tell "no access" from "no matches".
3004
+
3005
+ ``status`` is frozen at the last state the request was seen in.
3006
+ 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".
3007
+ Read ``last_seen`` for staleness.
3008
+
3009
+ Args:
3010
+ page: Page number
3011
+ limit: Results per page (max 100)
3012
+ shape: Response shape string (defaults to minimal shape)
3013
+ flat: If True, flatten nested objects in shaped response
3014
+ flat_lists: If True, flatten arrays using indexed keys
3015
+ search: Full-text search over the title, description, reference number, request id and attachment text. Results rank by relevance unless ``ordering`` is given
3016
+ rfq_id: Exact request id, e.g. ``RFQ1835158``. OR several with ``|``
3017
+ reference_number: The buyer's own solicitation number; dashes are ignored
3018
+ request_type: ``RFQ``, ``RFP`` or ``RFI``. OR several with ``|``
3019
+ status: ``Open`` or ``Cancelled``, as last seen. OR several with ``|``
3020
+ sin: Special Item Number, e.g. ``54151S``. OR several with ``|``
3021
+ schedule: GSA schedule. OR several with ``|``
3022
+ buyer_agency: The buyer agency as eBuy names it (free text). OR several with ``|``
3023
+ 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
3024
+ 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
3025
+ issue_date_after: Issued on or after (YYYY-MM-DD)
3026
+ issue_date_before: Issued on or before (YYYY-MM-DD)
3027
+ close_date_after: Closes on or after (YYYY-MM-DD)
3028
+ close_date_before: Closes on or before (YYYY-MM-DD)
3029
+ ordering: Sort field — ``issue_date`` (the default, as ``-issue_date``), ``close_date``, ``last_seen`` or ``modified``. Prefix ``-`` for descending
3030
+ """
3031
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
3032
+
3033
+ if shape is None:
3034
+ shape = ShapeConfig.EBUY_REQUESTS_MINIMAL
3035
+ if shape:
3036
+ params["shape"] = shape
3037
+ if flat:
3038
+ params["flat"] = "true"
3039
+ if flat_lists:
3040
+ params["flat_lists"] = "true"
3041
+
3042
+ for key, val in (
3043
+ ("search", search),
3044
+ ("rfq_id", rfq_id),
3045
+ ("reference_number", reference_number),
3046
+ ("request_type", request_type),
3047
+ ("status", status),
3048
+ ("sin", sin),
3049
+ ("schedule", schedule),
3050
+ ("buyer_agency", buyer_agency),
3051
+ ("agency", agency),
3052
+ ("contract_number", contract_number),
3053
+ ("issue_date_after", issue_date_after),
3054
+ ("issue_date_before", issue_date_before),
3055
+ ("close_date_after", close_date_after),
3056
+ ("close_date_before", close_date_before),
3057
+ ("ordering", ordering),
3058
+ ):
3059
+ if val is not None:
3060
+ params[key] = val
3061
+
3062
+ data = self._get("/api/ebuy/requests/", params)
3063
+
3064
+ results = [
3065
+ self._parse_response_with_shape(item, shape, EbuyRequest, flat, flat_lists)
3066
+ for item in data["results"]
3067
+ ]
3068
+
3069
+ return PaginatedResponse(
3070
+ count=data["count"],
3071
+ next=data.get("next"),
3072
+ previous=data.get("previous"),
3073
+ results=results,
3074
+ meta=data.get("meta"),
3075
+ )
3076
+
3077
+ def get_ebuy_request(
3078
+ self,
3079
+ rfq_id: str,
3080
+ shape: str | None = None,
3081
+ flat: bool = False,
3082
+ flat_lists: bool = False,
3083
+ ) -> Any:
3084
+ """
3085
+ Get a single GSA eBuy request by its request id.
3086
+
3087
+ A request outside your account's scope raises ``TangoNotFoundError``, the same as an id that does not exist.
3088
+
3089
+ Args:
3090
+ rfq_id: Request id, e.g. ``RFQ1835158``
3091
+ shape: Response shape string (defaults to the comprehensive shape: every field plus the ``organization`` and ``attachments`` expands)
3092
+ flat: If True, flatten nested objects in shaped response
3093
+ flat_lists: If True, flatten arrays using indexed keys
3094
+ """
3095
+ params: dict[str, Any] = {}
3096
+ if shape is None:
3097
+ shape = ShapeConfig.EBUY_REQUESTS_COMPREHENSIVE
3098
+ if shape:
3099
+ params["shape"] = shape
3100
+ if flat:
3101
+ params["flat"] = "true"
3102
+ if flat_lists:
3103
+ params["flat_lists"] = "true"
3104
+
3105
+ data = self._get(f"/api/ebuy/requests/{quote(rfq_id, safe='')}/", params)
3106
+ return self._parse_response_with_shape(data, shape, EbuyRequest, flat, flat_lists)
3107
+
3108
+ def get_ebuy_attachment_url(self, rfq_id: str, doc_seq_num: int) -> str:
3109
+ """
3110
+ Get a short-lived download URL for one attachment on a GSA eBuy request.
3111
+
3112
+ The API answers with a redirect to a signed URL that expires after about five minutes; this returns that URL without downloading the document.
3113
+ Fetch it promptly, and call this again rather than storing it.
3114
+
3115
+ Args:
3116
+ rfq_id: Request id, e.g. ``RFQ1835158``
3117
+ doc_seq_num: The attachment's ``doc_seq_num`` from ``get_ebuy_request()``
3118
+
3119
+ Raises:
3120
+ TangoAttachmentLinkError: The entry is an external link (``is_link``), not a stored document. The link is on ``.url`` and in the message. Subclasses ``TangoValidationError``.
3121
+ TangoNotFoundError: The request is unknown or outside your scope, the attachment does not exist, or its document has not been captured yet.
3122
+ """
3123
+ endpoint = (
3124
+ f"/api/ebuy/requests/{quote(rfq_id, safe='')}/attachments/{int(doc_seq_num)}/download/"
3125
+ )
3126
+ url = urljoin(f"{self.base_url}/", endpoint.lstrip("/"))
3127
+
3128
+ try:
3129
+ response = self.client.request(method="GET", url=url, follow_redirects=False)
3130
+ except httpx.HTTPError as e:
3131
+ raise TangoAPIError(f"Request failed: {str(e)}") from e
3132
+ self._last_response_headers = response.headers
3133
+ self._last_rate_limit_info = self._parse_rate_limit_headers(response.headers)
3134
+
3135
+ location = response.headers.get("Location")
3136
+ if response.is_redirect and location:
3137
+ return str(location)
3138
+
3139
+ if response.status_code in (400, 404):
3140
+ try:
3141
+ error_data = response.json() if response.content else {}
3142
+ except ValueError:
3143
+ error_data = {}
3144
+ if not isinstance(error_data, dict):
3145
+ error_data = {}
3146
+ if response.status_code == 400 and error_data.get("url"):
3147
+ raise TangoAttachmentLinkError(
3148
+ f"Attachment {doc_seq_num} on {rfq_id} is an external link, not a stored document: {error_data['url']}",
3149
+ response.status_code,
3150
+ error_data,
3151
+ )
3152
+ if response.status_code == 404:
3153
+ raise TangoNotFoundError(
3154
+ error_data.get("detail") or "Resource not found",
3155
+ response.status_code,
3156
+ error_data,
3157
+ )
3158
+
3159
+ self._raise_for_status(response)
3160
+ raise TangoAPIError(
3161
+ f"Expected a redirect to the attachment, got status {response.status_code}",
3162
+ response.status_code,
3163
+ )
3164
+
3165
+ def get_ebuy_access(self) -> EbuyAccess:
3166
+ """
3167
+ Check whether your account can read GSA eBuy requests.
3168
+
3169
+ ``list_ebuy_requests()`` returns an empty page, not an error, when no contract is linked; this tells the two apart.
3170
+ ``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.
3171
+ ``contracts`` lists your own linked contracts, sorted.
3172
+ """
3173
+ data = self._get("/api/ebuy/access/")
3174
+ return EbuyAccess(
3175
+ enabled=bool(data.get("enabled")),
3176
+ reason=data.get("reason"),
3177
+ contracts=list(data.get("contracts") or []),
3178
+ )
3179
+
2966
3180
  # ============================================================================
2967
3181
  # DLA DIBBS (RFQs, RFPs, awards)
2968
3182
  # ============================================================================
@@ -3519,6 +3733,7 @@ class TangoClient:
3519
3733
  flat_lists: bool = False,
3520
3734
  topic_number: str | None = None,
3521
3735
  solicitation_number: str | None = None,
3736
+ cycle_name: str | None = None,
3522
3737
  agency: str | None = None,
3523
3738
  activity: str | None = None,
3524
3739
  year: int | None = None,
@@ -3545,6 +3760,7 @@ class TangoClient:
3545
3760
  flat_lists: If True, flatten arrays using indexed keys
3546
3761
  topic_number: Filter by topic number
3547
3762
  solicitation_number: Filter by solicitation number
3763
+ cycle_name: Filter by DSIP solicitation cycle, exact (e.g. "DOD_SBIR_2026_P1_CBZ"; requires Tango API 5.3.1)
3548
3764
  agency: Filter by agency
3549
3765
  activity: Filter by activity (component/branch)
3550
3766
  year: Filter by solicitation year
@@ -3572,6 +3788,7 @@ class TangoClient:
3572
3788
  for key, val in (
3573
3789
  ("topic_number", topic_number),
3574
3790
  ("solicitation_number", solicitation_number),
3791
+ ("cycle_name", cycle_name),
3575
3792
  ("agency", agency),
3576
3793
  ("activity", activity),
3577
3794
  ("year", year),
@@ -5022,7 +5239,7 @@ class TangoClient:
5022
5239
  query_type: One of ``opportunity``, ``contract``, ``idv``, ``ota``,
5023
5240
  ``otidv``, ``entity``, ``grant``, ``forecast``, ``exclusion``,
5024
5241
  ``dibbs_rfq``, ``dibbs_rfp``, ``sled_opportunity``,
5025
- ``contract_appeal`` or ``federal_register``. :meth:`list_webhook_event_types` is the
5242
+ ``contract_appeal``, ``federal_register`` or ``ebuy_request``. :meth:`list_webhook_event_types` is the
5026
5243
  current authority — this list can only go stale.
5027
5244
  filters: Dict of query parameters that the alert matches against
5028
5245
  (e.g. ``{"naics": "541330", "set_aside": "SBA"}``).
tango/exceptions.py CHANGED
@@ -53,6 +53,16 @@ class TangoValidationError(TangoAPIError):
53
53
  return val if isinstance(val, dict) else None
54
54
 
55
55
 
56
+ class TangoAttachmentLinkError(TangoValidationError):
57
+ """The requested attachment is an external link, not a stored document, so there is nothing to download."""
58
+
59
+ @property
60
+ def url(self) -> str | None:
61
+ """The external link the entry points at."""
62
+ val = self.response_data.get("url")
63
+ return val if isinstance(val, str) else None
64
+
65
+
56
66
  class TangoRateLimitError(TangoAPIError):
57
67
  """Rate limit exceeded error"""
58
68
 
tango/models.py CHANGED
@@ -1000,6 +1000,7 @@ class SbirTopic:
1000
1000
  activity: str | None = None
1001
1001
  year: int | None = None
1002
1002
  solicitation_number: str | None = None
1003
+ cycle_name: str | None = None
1003
1004
  solicitation_status: str | None = None
1004
1005
  release_date: str | None = None
1005
1006
  open_date: str | None = None
@@ -1272,6 +1273,97 @@ class FederalRegisterDocument:
1272
1273
  full_text: str | None = None
1273
1274
 
1274
1275
 
1276
+ class EbuyAttachment:
1277
+ """Schema definition for one entry in an eBuy request's ``attachments`` (not used for instances).
1278
+
1279
+ ``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.
1280
+ """
1281
+
1282
+ doc_seq_num: int | None = None
1283
+ doc_name: str | None = None
1284
+ doc_type: int | None = None
1285
+ doc_path: str | None = None
1286
+ is_link: bool | None = None
1287
+ doc_session_date: datetime | None = None
1288
+
1289
+
1290
+ class EbuyRequest:
1291
+ """Schema definition for EbuyRequest (not used for instances).
1292
+
1293
+ GSA eBuy requests (RFQs, RFPs, RFIs) at ``/api/ebuy/requests/``, keyed by ``rfq_id``.
1294
+ Shape-on-demand, so every field is optional.
1295
+
1296
+ The resource is scoped to your account: you see only requests posted under the GSA schedule contracts linked to it.
1297
+
1298
+ ``status`` is frozen at the last state the request was seen in.
1299
+ 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".
1300
+ Read ``last_seen`` for staleness.
1301
+
1302
+ ``buyer_agency_code`` and some other buyer and contact fields are sparse on older requests.
1303
+ The contract number a request was posted under is never returned.
1304
+ """
1305
+
1306
+ rfq_id: str | None = None
1307
+ request_type: str | None = None
1308
+ title: str | None = None
1309
+ description: str | None = None
1310
+ schedule: str | None = None
1311
+ sin: str | None = None
1312
+ status: str | None = None
1313
+ buyer_name: str | None = None
1314
+ buyer_agency: str | None = None
1315
+ buyer_agency_code: str | None = None
1316
+ buyer_email: str | None = None
1317
+ buyer_user_id: str | None = None
1318
+ reference_number: str | None = None
1319
+ award_method: str | None = None
1320
+ contract_type: str | None = None
1321
+ commercial_type: str | None = None
1322
+ follow_on: bool | None = None
1323
+ source_sought: bool | None = None
1324
+ issue_date: datetime | None = None
1325
+ close_date: datetime | None = None
1326
+ cancel_date: datetime | None = None
1327
+ last_mod_date: datetime | None = None
1328
+ pop_start_date: datetime | None = None
1329
+ pop_end_date: datetime | None = None
1330
+ oco_name: str | None = None
1331
+ oco_title: str | None = None
1332
+ oco_agency: str | None = None
1333
+ oco_phone: str | None = None
1334
+ oco_aac: str | None = None
1335
+ ocs_name: str | None = None
1336
+ ocs_title: str | None = None
1337
+ ocs_agency: str | None = None
1338
+ ocs_phone: str | None = None
1339
+ ocs_aac: str | None = None
1340
+ amendment_count: int | None = None
1341
+ mod_version: int | None = None
1342
+ qa_document_count: int | None = None
1343
+ attachment_count: int | None = None
1344
+ link_count: int | None = None
1345
+ amendments: list[dict[str, Any]] | None = None
1346
+ line_items: list[dict[str, Any]] | None = None
1347
+ addresses: list[dict[str, Any]] | None = None
1348
+ detail_fetched: bool | None = None
1349
+ first_seen: datetime | None = None
1350
+ last_seen: datetime | None = None
1351
+ organization: dict[str, Any] | None = None
1352
+ attachments: list[EbuyAttachment] | None = None
1353
+
1354
+
1355
+ @dataclass
1356
+ class EbuyAccess:
1357
+ """Result of ``GET /api/ebuy/access/``: whether your account can read eBuy requests.
1358
+
1359
+ ``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".
1360
+ """
1361
+
1362
+ enabled: bool
1363
+ reason: Literal["tier_required", "no_contract_grant"] | None
1364
+ contracts: list[str]
1365
+
1366
+
1275
1367
  @dataclass
1276
1368
  class PaginatedResponse[T]:
1277
1369
  """Paginated API response
@@ -1471,6 +1563,26 @@ class ShapeConfig:
1471
1563
  "comment_url,regulations_dot_gov_url,raw_text_url,body_html_url"
1472
1564
  )
1473
1565
 
1566
+ # Default for list_ebuy_requests(). Mirrors the API's own list default.
1567
+ EBUY_REQUESTS_MINIMAL: Final = (
1568
+ "rfq_id,request_type,title,schedule,sin,status,buyer_name,buyer_agency,"
1569
+ "buyer_agency_code,reference_number,issue_date,close_date,attachment_count,"
1570
+ "link_count,last_seen"
1571
+ )
1572
+
1573
+ # Default for get_ebuy_request(). Mirrors the API's own retrieve default; the attachment fields are named rather than `attachments(*)` so their dates parse.
1574
+ EBUY_REQUESTS_COMPREHENSIVE: Final = (
1575
+ "rfq_id,request_type,title,description,schedule,sin,status,buyer_name,"
1576
+ "buyer_agency,buyer_agency_code,buyer_email,buyer_user_id,reference_number,"
1577
+ "award_method,contract_type,commercial_type,follow_on,source_sought,issue_date,"
1578
+ "close_date,cancel_date,last_mod_date,pop_start_date,pop_end_date,oco_name,"
1579
+ "oco_title,oco_agency,oco_phone,oco_aac,ocs_name,ocs_title,ocs_agency,ocs_phone,"
1580
+ "ocs_aac,amendment_count,mod_version,qa_document_count,attachment_count,"
1581
+ "link_count,amendments,line_items,addresses,detail_fetched,first_seen,last_seen,"
1582
+ "organization(*),"
1583
+ "attachments(doc_seq_num,doc_name,doc_type,doc_path,is_link,doc_session_date)"
1584
+ )
1585
+
1474
1586
  # Default for list_dibbs_rfqs()
1475
1587
  DIBBS_RFQS_MINIMAL: Final = (
1476
1588
  "uuid,solicitation,nsn,part_number,nomenclature,quantity,issue_date,return_by_date,is_open"
@@ -877,6 +877,97 @@ FEDERAL_REGISTER_DOCUMENT_SCHEMA: dict[str, FieldSchema] = {
877
877
  }
878
878
 
879
879
 
880
+ EBUY_ATTACHMENT_SCHEMA: dict[str, FieldSchema] = {
881
+ "doc_seq_num": FieldSchema(name="doc_seq_num", type=int, is_optional=True, is_list=False),
882
+ "doc_name": FieldSchema(name="doc_name", type=str, is_optional=True, is_list=False),
883
+ "doc_type": FieldSchema(name="doc_type", type=int, is_optional=True, is_list=False),
884
+ "doc_path": FieldSchema(name="doc_path", type=str, is_optional=True, is_list=False),
885
+ "is_link": FieldSchema(name="is_link", type=bool, is_optional=True, is_list=False),
886
+ "doc_session_date": FieldSchema(
887
+ name="doc_session_date", type=datetime, is_optional=True, is_list=False
888
+ ),
889
+ }
890
+
891
+
892
+ EBUY_REQUEST_SCHEMA: dict[str, FieldSchema] = {
893
+ "rfq_id": FieldSchema(name="rfq_id", type=str, is_optional=True, is_list=False),
894
+ "request_type": FieldSchema(name="request_type", type=str, is_optional=True, is_list=False),
895
+ "title": FieldSchema(name="title", type=str, is_optional=True, is_list=False),
896
+ "description": FieldSchema(name="description", type=str, is_optional=True, is_list=False),
897
+ "schedule": FieldSchema(name="schedule", type=str, is_optional=True, is_list=False),
898
+ "sin": FieldSchema(name="sin", type=str, is_optional=True, is_list=False),
899
+ "status": FieldSchema(name="status", type=str, is_optional=True, is_list=False),
900
+ "buyer_name": FieldSchema(name="buyer_name", type=str, is_optional=True, is_list=False),
901
+ "buyer_agency": FieldSchema(name="buyer_agency", type=str, is_optional=True, is_list=False),
902
+ "buyer_agency_code": FieldSchema(
903
+ name="buyer_agency_code", type=str, is_optional=True, is_list=False
904
+ ),
905
+ "buyer_email": FieldSchema(name="buyer_email", type=str, is_optional=True, is_list=False),
906
+ "buyer_user_id": FieldSchema(name="buyer_user_id", type=str, is_optional=True, is_list=False),
907
+ "reference_number": FieldSchema(
908
+ name="reference_number", type=str, is_optional=True, is_list=False
909
+ ),
910
+ "award_method": FieldSchema(name="award_method", type=str, is_optional=True, is_list=False),
911
+ "contract_type": FieldSchema(name="contract_type", type=str, is_optional=True, is_list=False),
912
+ "commercial_type": FieldSchema(
913
+ name="commercial_type", type=str, is_optional=True, is_list=False
914
+ ),
915
+ "follow_on": FieldSchema(name="follow_on", type=bool, is_optional=True, is_list=False),
916
+ "source_sought": FieldSchema(name="source_sought", type=bool, is_optional=True, is_list=False),
917
+ "issue_date": FieldSchema(name="issue_date", type=datetime, is_optional=True, is_list=False),
918
+ "close_date": FieldSchema(name="close_date", type=datetime, is_optional=True, is_list=False),
919
+ "cancel_date": FieldSchema(name="cancel_date", type=datetime, is_optional=True, is_list=False),
920
+ "last_mod_date": FieldSchema(
921
+ name="last_mod_date", type=datetime, is_optional=True, is_list=False
922
+ ),
923
+ "pop_start_date": FieldSchema(
924
+ name="pop_start_date", type=datetime, is_optional=True, is_list=False
925
+ ),
926
+ "pop_end_date": FieldSchema(
927
+ name="pop_end_date", type=datetime, is_optional=True, is_list=False
928
+ ),
929
+ "oco_name": FieldSchema(name="oco_name", type=str, is_optional=True, is_list=False),
930
+ "oco_title": FieldSchema(name="oco_title", type=str, is_optional=True, is_list=False),
931
+ "oco_agency": FieldSchema(name="oco_agency", type=str, is_optional=True, is_list=False),
932
+ "oco_phone": FieldSchema(name="oco_phone", type=str, is_optional=True, is_list=False),
933
+ "oco_aac": FieldSchema(name="oco_aac", type=str, is_optional=True, is_list=False),
934
+ "ocs_name": FieldSchema(name="ocs_name", type=str, is_optional=True, is_list=False),
935
+ "ocs_title": FieldSchema(name="ocs_title", type=str, is_optional=True, is_list=False),
936
+ "ocs_agency": FieldSchema(name="ocs_agency", type=str, is_optional=True, is_list=False),
937
+ "ocs_phone": FieldSchema(name="ocs_phone", type=str, is_optional=True, is_list=False),
938
+ "ocs_aac": FieldSchema(name="ocs_aac", type=str, is_optional=True, is_list=False),
939
+ "amendment_count": FieldSchema(
940
+ name="amendment_count", type=int, is_optional=True, is_list=False
941
+ ),
942
+ "mod_version": FieldSchema(name="mod_version", type=int, is_optional=True, is_list=False),
943
+ "qa_document_count": FieldSchema(
944
+ name="qa_document_count", type=int, is_optional=True, is_list=False
945
+ ),
946
+ "attachment_count": FieldSchema(
947
+ name="attachment_count", type=int, is_optional=True, is_list=False
948
+ ),
949
+ "link_count": FieldSchema(name="link_count", type=int, is_optional=True, is_list=False),
950
+ "amendments": FieldSchema(name="amendments", type=dict, is_optional=True, is_list=True),
951
+ "line_items": FieldSchema(name="line_items", type=dict, is_optional=True, is_list=True),
952
+ "addresses": FieldSchema(name="addresses", type=dict, is_optional=True, is_list=True),
953
+ "detail_fetched": FieldSchema(
954
+ name="detail_fetched", type=bool, is_optional=True, is_list=False
955
+ ),
956
+ "first_seen": FieldSchema(name="first_seen", type=datetime, is_optional=True, is_list=False),
957
+ "last_seen": FieldSchema(name="last_seen", type=datetime, is_optional=True, is_list=False),
958
+ "organization": FieldSchema(
959
+ name="organization",
960
+ type=dict,
961
+ is_optional=True,
962
+ is_list=False,
963
+ nested_model="OrganizationOffice",
964
+ ),
965
+ "attachments": FieldSchema(
966
+ name="attachments", type=dict, is_optional=True, is_list=True, nested_model="EbuyAttachment"
967
+ ),
968
+ }
969
+
970
+
880
971
  AGENCY_SCHEMA: dict[str, FieldSchema] = {
881
972
  "abbreviation": FieldSchema(name="abbreviation", type=str, is_optional=True, is_list=False),
882
973
  "code": FieldSchema(name="code", type=str, is_optional=False, is_list=False),
@@ -1623,6 +1714,8 @@ EXPLICIT_SCHEMAS: dict[str, dict[str, FieldSchema]] = {
1623
1714
  "ProtestDocket": PROTEST_DOCKET_SCHEMA,
1624
1715
  "ContractAppeal": CONTRACT_APPEAL_SCHEMA,
1625
1716
  "FederalRegisterDocument": FEDERAL_REGISTER_DOCUMENT_SCHEMA,
1717
+ "EbuyRequest": EBUY_REQUEST_SCHEMA,
1718
+ "EbuyAttachment": EBUY_ATTACHMENT_SCHEMA,
1626
1719
  "Agency": AGENCY_SCHEMA,
1627
1720
  "Grant": GRANT_SCHEMA,
1628
1721
  # Vehicles (Awards)
@@ -81,17 +81,6 @@ ARCHIVE_SCHEMA: dict[str, FieldSchema] = {
81
81
  }
82
82
 
83
83
  ATTACHMENTS_SCHEMA: dict[str, FieldSchema] = {
84
- "doc_name": FieldSchema(name="doc_name", type=str, is_optional=True, is_list=False),
85
- "doc_path": FieldSchema(name="doc_path", type=str, is_optional=True, is_list=False),
86
- "doc_seq_num": FieldSchema(name="doc_seq_num", type=str, is_optional=True, is_list=False),
87
- "doc_session_date": FieldSchema(
88
- name="doc_session_date", type=date, is_optional=True, is_list=False
89
- ),
90
- "doc_type": FieldSchema(name="doc_type", type=str, is_optional=True, is_list=False),
91
- "is_link": FieldSchema(name="is_link", type=bool, is_optional=True, is_list=False),
92
- }
93
-
94
- ATTACHMENTS2_SCHEMA: dict[str, FieldSchema] = {
95
84
  "attachment_id": FieldSchema(name="attachment_id", type=str, is_optional=True, is_list=False),
96
85
  "doc_role": FieldSchema(name="doc_role", type=str, is_optional=True, is_list=False),
97
86
  "doc_role_alt": FieldSchema(name="doc_role_alt", type=str, is_optional=True, is_list=False),
@@ -105,7 +94,7 @@ ATTACHMENTS2_SCHEMA: dict[str, FieldSchema] = {
105
94
  "url": FieldSchema(name="url", type=str, is_optional=True, is_list=False),
106
95
  }
107
96
 
108
- ATTACHMENTS3_SCHEMA: dict[str, FieldSchema] = {
97
+ ATTACHMENTS2_SCHEMA: dict[str, FieldSchema] = {
109
98
  "attachment_id": FieldSchema(name="attachment_id", type=str, is_optional=True, is_list=False),
110
99
  "doc_role": FieldSchema(name="doc_role", type=str, is_optional=True, is_list=False),
111
100
  "doc_role_alt": FieldSchema(name="doc_role_alt", type=str, is_optional=True, is_list=False),
@@ -119,7 +108,7 @@ ATTACHMENTS3_SCHEMA: dict[str, FieldSchema] = {
119
108
  "url": FieldSchema(name="url", type=str, is_optional=True, is_list=False),
120
109
  }
121
110
 
122
- ATTACHMENTS4_SCHEMA: dict[str, FieldSchema] = {
111
+ ATTACHMENTS3_SCHEMA: dict[str, FieldSchema] = {
123
112
  "char_count": FieldSchema(name="char_count", type=int, is_optional=True, is_list=False),
124
113
  "checksum": FieldSchema(name="checksum", type=str, is_optional=True, is_list=False),
125
114
  "download_status": FieldSchema(
@@ -144,7 +133,7 @@ ATTACHMENTS4_SCHEMA: dict[str, FieldSchema] = {
144
133
  "word_count": FieldSchema(name="word_count", type=int, is_optional=True, is_list=False),
145
134
  }
146
135
 
147
- ATTACHMENTS5_SCHEMA: dict[str, FieldSchema] = {
136
+ ATTACHMENTS4_SCHEMA: dict[str, FieldSchema] = {
148
137
  "attachment_id": FieldSchema(name="attachment_id", type=str, is_optional=True, is_list=False),
149
138
  "file_size": FieldSchema(name="file_size", type=str, is_optional=True, is_list=False),
150
139
  "mime_type": FieldSchema(name="mime_type", type=str, is_optional=True, is_list=False),
@@ -155,7 +144,7 @@ ATTACHMENTS5_SCHEMA: dict[str, FieldSchema] = {
155
144
  "url": FieldSchema(name="url", type=str, is_optional=True, is_list=False),
156
145
  }
157
146
 
158
- ATTACHMENTS6_SCHEMA: dict[str, FieldSchema] = {
147
+ ATTACHMENTS5_SCHEMA: dict[str, FieldSchema] = {
159
148
  "attachment_id": FieldSchema(name="attachment_id", type=str, is_optional=True, is_list=False),
160
149
  "doc_role": FieldSchema(name="doc_role", type=str, is_optional=True, is_list=False),
161
150
  "doc_role_alt": FieldSchema(name="doc_role_alt", type=str, is_optional=True, is_list=False),
@@ -1119,7 +1108,6 @@ GENERATED_NESTED: dict[str, dict[str, FieldSchema]] = {
1119
1108
  "Attachments3": ATTACHMENTS3_SCHEMA,
1120
1109
  "Attachments4": ATTACHMENTS4_SCHEMA,
1121
1110
  "Attachments5": ATTACHMENTS5_SCHEMA,
1122
- "Attachments6": ATTACHMENTS6_SCHEMA,
1123
1111
  "Awardee": AWARDEE_SCHEMA,
1124
1112
  "AwardingOffice": AWARDING_OFFICE_SCHEMA,
1125
1113
  "AwardingOffice2": AWARDING_OFFICE2_SCHEMA,
@@ -2238,7 +2226,7 @@ GENERATED_OVERLAY: dict[str, dict[str, FieldSchema]] = {
2238
2226
  type=dict,
2239
2227
  is_optional=True,
2240
2228
  is_list=True,
2241
- nested_model="Attachments2",
2229
+ nested_model="Attachments",
2242
2230
  ),
2243
2231
  "meta": FieldSchema(
2244
2232
  name="meta", type=dict, is_optional=True, is_list=False, nested_model="Meta"
@@ -2486,7 +2474,7 @@ GENERATED_OVERLAY: dict[str, dict[str, FieldSchema]] = {
2486
2474
  type=dict,
2487
2475
  is_optional=True,
2488
2476
  is_list=False,
2489
- nested_model="Attachments6",
2477
+ nested_model="Attachments5",
2490
2478
  ),
2491
2479
  "department": FieldSchema(
2492
2480
  name="department",
@@ -2743,6 +2731,7 @@ GENERATED_OVERLAY: dict[str, dict[str, FieldSchema]] = {
2743
2731
  "activity": FieldSchema(name="activity", type=str, is_optional=True, is_list=False),
2744
2732
  "agency": FieldSchema(name="agency", type=str, is_optional=True, is_list=False),
2745
2733
  "close_date": FieldSchema(name="close_date", type=date, is_optional=True, is_list=False),
2734
+ "cycle_name": FieldSchema(name="cycle_name", type=str, is_optional=True, is_list=False),
2746
2735
  "description": FieldSchema(name="description", type=str, is_optional=True, is_list=False),
2747
2736
  "doc_source": FieldSchema(name="doc_source", type=str, is_optional=True, is_list=False),
2748
2737
  "due_dates_text": FieldSchema(
@@ -2873,7 +2862,7 @@ GENERATED_OVERLAY: dict[str, dict[str, FieldSchema]] = {
2873
2862
  type=dict,
2874
2863
  is_optional=True,
2875
2864
  is_list=True,
2876
- nested_model="Attachments4",
2865
+ nested_model="Attachments3",
2877
2866
  ),
2878
2867
  "bid_opening_date": FieldSchema(
2879
2868
  name="bid_opening_date", type=datetime, is_optional=True, is_list=False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tango-python
3
- Version: 1.8.0
3
+ Version: 1.10.0
4
4
  Summary: Python SDK for the Tango API
5
5
  Project-URL: Homepage, https://github.com/makegov/tango-python
6
6
  Project-URL: Documentation, https://docs.makegov.com/tango-python
@@ -308,6 +308,15 @@ protests = client.list_protests(source_system="gao", outcome="Sustained", limit=
308
308
  protest = client.get_protest("CASE_UUID")
309
309
  ```
310
310
 
311
+ ### GSA eBuy
312
+
313
+ ```python
314
+ access = client.get_ebuy_access() # scoped to the schedule contracts linked to your account
315
+ requests = client.list_ebuy_requests(status="Open", sin="54151S", limit=25)
316
+ request = client.get_ebuy_request("RFQ1835158")
317
+ url = client.get_ebuy_attachment_url("RFQ1835158", doc_seq_num=1) # short-lived signed URL
318
+ ```
319
+
311
320
  ### GSA eLibrary Contracts
312
321
 
313
322
  ```python
@@ -1,11 +1,11 @@
1
- tango/__init__.py,sha256=uUcnPf4IZjC6ldsu-8CBI6gJMrvg1_EfbTILjLYevW4,2360
2
- tango/client.py,sha256=hLYbP-lBIBVzSVamOJwSATHqi6ijba6-XtMAB-XTyyo,228019
3
- tango/exceptions.py,sha256=B1t27BBxHcPglAfJQyan5khNEYNAyF6Wkp2EmPMdRxM,3858
4
- tango/models.py,sha256=KI0I9I9LBetpRUkl0YS7hHbsM2vrOflN83crNSVBUiY,61897
1
+ tango/__init__.py,sha256=rSPFjxFs-K1mVSICHc9nU94SvN5N2dhBEDVrD9y2BII,2535
2
+ tango/client.py,sha256=Srf-68uv9VvO5Dv0hi9SP-bHorOQl97zOysmX1owTeA,237960
3
+ tango/exceptions.py,sha256=oPiLCccBoWLK5SjHkzP-oI6mUD9twEeKSvEuLV5h01I,4224
4
+ tango/models.py,sha256=MwloM-gYq0ng9obnDn_lqYFG_cJ5aQZq6OvDqrW7S2s,66647
5
5
  tango/shapes/__init__.py,sha256=7ea1WU74jp4znhNw-gXruag6m6eyPZtbVgbDFmFUWro,1072
6
- tango/shapes/explicit_schemas.py,sha256=41k6rOBMFz-6imGIZ5mXeK26M0s-nmIUZJXQeDjQFH0,78508
6
+ tango/shapes/explicit_schemas.py,sha256=spYyMmWKOiMJygz9yViBQrjIxUPFfv7HpVkJcWy13x8,84021
7
7
  tango/shapes/factory.py,sha256=uDPhnp6VVzrLBKodxteZy5AcU43juEv-qKVRWth51j0,31550
8
- tango/shapes/generated_overlay.py,sha256=OuwuyixXplySy5SM0FqQ0KKPOJJ490zyOi3wzP3DEOQ,139031
8
+ tango/shapes/generated_overlay.py,sha256=udDQ1vQ9d4DpAOyhjRhsTD_8gx9QqhZfwk_PKXwZUJE,138465
9
9
  tango/shapes/generator.py,sha256=xzakpwTwqTMUr89I06hp54-FIYZziviWHk2Glscg-LM,22775
10
10
  tango/shapes/models.py,sha256=h3pIhOqrrdlN953Y6r0oney5HFbKPOD-frRndRWimJ0,3018
11
11
  tango/shapes/parser.py,sha256=AecNx5oT_hV_pf2AWZnsR8Y2aLmCtjfgS9-znA2Pesc,27429
@@ -16,8 +16,8 @@ tango/webhooks/cli.py,sha256=f_vQbJ2AeSQjKnQo7MxHFyUB2SAKcgHYmGQULS0isqQ,13858
16
16
  tango/webhooks/receiver.py,sha256=5yhsVhlLyoxmOCGvmbynWAIlDB2OaCPVf1H4GA1SxmU,9279
17
17
  tango/webhooks/signing.py,sha256=92Ee-0B6PR7ZkvY3Np3gzl88-mtfKkh-I7lxqCe2lGw,2374
18
18
  tango/webhooks/simulate.py,sha256=g2Osa0FYU5mJuon07T2aUCtmkUoTEzsY261tlp76fF0,3165
19
- tango_python-1.8.0.dist-info/METADATA,sha256=46doMnyaajvAdOUSTAHyjznVIKJ6Nepwgu4CpEn8SuQ,21779
20
- tango_python-1.8.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
21
- tango_python-1.8.0.dist-info/entry_points.txt,sha256=kGLUbglUjuaAqEFvOZ1QuSW0vWb6VeSpCIFKaOFkKoQ,50
22
- tango_python-1.8.0.dist-info/licenses/LICENSE,sha256=j2kYVHMwTkoGn3ZNScnrdIueG0k2XzB_LCPFoyBc2wk,1064
23
- tango_python-1.8.0.dist-info/RECORD,,
19
+ tango_python-1.10.0.dist-info/METADATA,sha256=GhxRXx0J567G_IXyAKx2JcDMXoyFFUbqezsYjNjvFig,22118
20
+ tango_python-1.10.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
21
+ tango_python-1.10.0.dist-info/entry_points.txt,sha256=kGLUbglUjuaAqEFvOZ1QuSW0vWb6VeSpCIFKaOFkKoQ,50
22
+ tango_python-1.10.0.dist-info/licenses/LICENSE,sha256=j2kYVHMwTkoGn3ZNScnrdIueG0k2XzB_LCPFoyBc2wk,1064
23
+ tango_python-1.10.0.dist-info/RECORD,,