tango-python 1.7.0__py3-none-any.whl → 1.9.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,7 +15,11 @@ from .models import (
14
15
  DibbsAward,
15
16
  DibbsRfp,
16
17
  DibbsRfq,
18
+ EbuyAccess,
19
+ EbuyAttachment,
20
+ EbuyRequest,
17
21
  Exclusion,
22
+ FederalRegisterDocument,
18
23
  GsaElibraryContract,
19
24
  ITDashboardInvestment,
20
25
  PaginatedResponse,
@@ -54,19 +59,24 @@ from .webhooks import (
54
59
  )
55
60
  from .webhooks.receiver import Delivery, WebhookReceiver
56
61
 
57
- __version__ = "1.7.0"
62
+ __version__ = "1.9.0"
58
63
  __all__ = [
59
64
  "TangoClient",
60
65
  "TangoAPIError",
61
66
  "TangoAuthError",
62
67
  "TangoNotFoundError",
63
68
  "TangoValidationError",
69
+ "TangoAttachmentLinkError",
64
70
  "TangoRateLimitError",
65
71
  "RateLimitInfo",
66
72
  "ResolveCandidate",
67
73
  "ResolveResult",
68
74
  "BudgetAccount",
69
75
  "ContractAppeal",
76
+ "FederalRegisterDocument",
77
+ "EbuyAccess",
78
+ "EbuyAttachment",
79
+ "EbuyRequest",
70
80
  "DibbsAward",
71
81
  "DibbsRfp",
72
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,8 +29,11 @@ from tango.models import (
28
29
  DibbsAward,
29
30
  DibbsRfp,
30
31
  DibbsRfq,
32
+ EbuyAccess,
33
+ EbuyRequest,
31
34
  Entity,
32
35
  Exclusion,
36
+ FederalRegisterDocument,
33
37
  Forecast,
34
38
  Grant,
35
39
  GsaElibraryContract,
@@ -174,55 +178,57 @@ class TangoClient:
174
178
  self._last_response_headers = response.headers
175
179
  self._last_rate_limit_info = self._parse_rate_limit_headers(response.headers)
176
180
 
177
- if response.status_code == 401:
178
- raise TangoAuthError(
179
- "Invalid API key or authentication required", response.status_code
180
- )
181
- elif response.status_code == 404:
182
- raise TangoNotFoundError("Resource not found", response.status_code)
183
- elif response.status_code == 400:
184
- error_data = response.json() if response.content else {}
185
- error_msg = "Invalid request parameters"
186
- if error_data:
187
- # Try to extract a more specific error message
188
- if isinstance(error_data, dict):
189
- detail = (
190
- error_data.get("detail")
191
- or error_data.get("message")
192
- or error_data.get("error")
193
- )
194
- if detail:
195
- error_msg = f"Invalid request parameters: {detail}"
196
- issues = error_data.get("issues")
197
- if isinstance(issues, list):
198
- rejected = [
199
- f"{issue['path']} ({issue['reason']})"
200
- if issue.get("reason")
201
- else str(issue["path"])
202
- for issue in issues
203
- if isinstance(issue, dict) and issue.get("path")
204
- ]
205
- if rejected:
206
- error_msg = f"{error_msg}: {', '.join(rejected)}"
207
- raise TangoValidationError(
208
- error_msg,
209
- response.status_code,
210
- error_data,
211
- )
212
- elif response.status_code == 429:
213
- error_data = response.json() if response.content else {}
214
- detail = error_data.get("detail", "Rate limit exceeded")
215
- raise TangoRateLimitError(detail, response.status_code, error_data)
216
- elif not response.is_success:
217
- raise TangoAPIError(
218
- f"API request failed with status {response.status_code}", response.status_code
219
- )
181
+ self._raise_for_status(response)
220
182
 
221
183
  return response.json() if response.content else {}
222
184
 
223
185
  except httpx.HTTPError as e:
224
186
  raise TangoAPIError(f"Request failed: {str(e)}") from e
225
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
+
226
232
  def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
227
233
  """Make a GET request"""
228
234
  return self._request("GET", endpoint, params=params)
@@ -1601,18 +1607,18 @@ class TangoClient:
1601
1607
  cio_rating_max: int | None = None,
1602
1608
  performance_risk: bool | None = None,
1603
1609
  previous_uii: str | None = None,
1610
+ agency: str | None = None,
1604
1611
  ) -> PaginatedResponse:
1605
1612
  """List federal IT investments from the IT Dashboard (`/api/itdashboard/`).
1606
1613
 
1607
1614
  Filters are tier-gated by the API:
1608
1615
 
1609
- - **Free**: ``search`` (full-text across UII, title, description, agency,
1610
- bureau) and ``previous_uii`` — following a retired identifier forward to
1611
- whatever superseded it is recovery, not analysis, so it is ungated
1616
+ - **Free**: ``search`` (full-text across UII, title, description, agency, bureau), ``agency_name`` (text), ``agency`` and ``previous_uii`` — following a retired identifier forward to whatever superseded it is recovery, not analysis, so it is ungated
1612
1617
  - **Pro**: ``agency_code``, ``type_of_investment``,
1613
1618
  ``updated_time_after`` / ``updated_time_before``
1614
- - **Business+**: ``agency_name`` (text), ``cio_rating``,
1615
- ``cio_rating_max``, ``performance_risk``
1619
+ - **Business+**: ``cio_rating``, ``cio_rating_max``, ``performance_risk``
1620
+
1621
+ ``agency`` takes a Tango agency name, abbreviation, code or organization key, e.g. ``DOT``, and matches the whole organization subtree, so a department includes its sub-agencies. OR several with ``|``.
1616
1622
 
1617
1623
  Hitting a gated filter on a lower tier returns a 403 with upgrade info.
1618
1624
 
@@ -1632,6 +1638,7 @@ class TangoClient:
1632
1638
  params["flat_lists"] = "true"
1633
1639
  for k, val in (
1634
1640
  ("search", search),
1641
+ ("agency", agency),
1635
1642
  ("agency_code", agency_code),
1636
1643
  ("agency_name", agency_name),
1637
1644
  ("type_of_investment", type_of_investment),
@@ -2818,6 +2825,358 @@ class TangoClient:
2818
2825
  data = self._get(f"/api/contract_appeals/{appeal_id}/", params)
2819
2826
  return self._parse_response_with_shape(data, shape, ContractAppeal, flat, flat_lists)
2820
2827
 
2828
+ # ============================================================================
2829
+ # Federal Register documents
2830
+ # ============================================================================
2831
+
2832
+ def list_federal_register_documents(
2833
+ self,
2834
+ page: int = 1,
2835
+ limit: int = 25,
2836
+ shape: str | None = None,
2837
+ flat: bool = False,
2838
+ flat_lists: bool = False,
2839
+ search: str | None = None,
2840
+ document_number: str | None = None,
2841
+ type: str | None = None,
2842
+ agency: str | None = None,
2843
+ fr_agency: str | None = None,
2844
+ publication_date_after: str | None = None,
2845
+ publication_date_before: str | None = None,
2846
+ effective_on_after: str | None = None,
2847
+ effective_on_before: str | None = None,
2848
+ comments_close_on_after: str | None = None,
2849
+ comments_close_on_before: str | None = None,
2850
+ comments_open: bool | None = None,
2851
+ cfr_title: str | None = None,
2852
+ cfr_part: str | None = None,
2853
+ significant: bool | None = None,
2854
+ rin: str | None = None,
2855
+ executive_order_number: str | None = None,
2856
+ ordering: str | None = None,
2857
+ ) -> PaginatedResponse:
2858
+ """
2859
+ List Federal Register documents.
2860
+
2861
+ Rules, proposed rules, notices and presidential documents published in the Federal Register since 1994.
2862
+
2863
+ Args:
2864
+ page: Page number
2865
+ limit: Results per page (max 100)
2866
+ shape: Response shape string (defaults to minimal shape)
2867
+ flat: If True, flatten nested objects in shaped response
2868
+ flat_lists: If True, flatten arrays using indexed keys
2869
+ search: Ranked full-text search over the title, abstract and action. Wrap in double quotes for a phrase
2870
+ document_number: Exact FR document number, e.g. ``2016-31922``. OR several with ``|``. A number the Federal Register reused before 2016 matches every document that carries it
2871
+ type: ``Notice``, ``Rule``, ``Proposed Rule``, ``Presidential Document``, ``Correction``, ``Sunshine Act Document`` or ``Uncategorized Document`` (case-insensitive). OR several with ``|``. Documents published before 2008 are mostly ``Uncategorized Document``
2872
+ agency: A Tango agency name, abbreviation, code or organization key, e.g. ``EPA``. Matches a document when any agency it lists falls within that organization, so a department includes its sub-agencies. OR several with ``|``
2873
+ fr_agency: The Federal Register's own agency slug, e.g. ``environmental-protection-agency``. OR several with ``|``
2874
+ publication_date_after: Published on or after (YYYY-MM-DD)
2875
+ publication_date_before: Published on or before (YYYY-MM-DD)
2876
+ effective_on_after: Effective on or after (YYYY-MM-DD)
2877
+ effective_on_before: Effective on or before (YYYY-MM-DD)
2878
+ comments_close_on_after: Comment period closes on or after (YYYY-MM-DD)
2879
+ comments_close_on_before: Comment period closes on or before (YYYY-MM-DD)
2880
+ comments_open: ``True`` for documents whose comment period closes today or later, ``False`` for those already closed. Documents with no comment deadline match neither
2881
+ cfr_title: A CFR title number, e.g. ``40``
2882
+ cfr_part: A CFR part number, e.g. ``52``. Requires ``cfr_title``, and both must match the same CFR reference
2883
+ significant: Significant under Executive Order 12866
2884
+ rin: A Regulation Identifier Number, e.g. ``2060-AV16``. OR several with ``|``
2885
+ executive_order_number: Exact executive order number
2886
+ ordering: Sort field — ``publication_date`` (the default, as ``-publication_date``), ``effective_on``, ``comments_close_on``, ``document_number`` or ``rank``. ``rank`` requires a non-empty ``search``
2887
+ """
2888
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
2889
+
2890
+ if shape is None:
2891
+ shape = ShapeConfig.FEDERAL_REGISTER_MINIMAL
2892
+ if shape:
2893
+ params["shape"] = shape
2894
+ if flat:
2895
+ params["flat"] = "true"
2896
+ if flat_lists:
2897
+ params["flat_lists"] = "true"
2898
+
2899
+ for key, val in (
2900
+ ("search", search),
2901
+ ("document_number", document_number),
2902
+ ("type", type),
2903
+ ("agency", agency),
2904
+ ("fr_agency", fr_agency),
2905
+ ("publication_date_after", publication_date_after),
2906
+ ("publication_date_before", publication_date_before),
2907
+ ("effective_on_after", effective_on_after),
2908
+ ("effective_on_before", effective_on_before),
2909
+ ("comments_close_on_after", comments_close_on_after),
2910
+ ("comments_close_on_before", comments_close_on_before),
2911
+ ("comments_open", comments_open),
2912
+ ("cfr_title", cfr_title),
2913
+ ("cfr_part", cfr_part),
2914
+ ("significant", significant),
2915
+ ("rin", rin),
2916
+ ("executive_order_number", executive_order_number),
2917
+ ("ordering", ordering),
2918
+ ):
2919
+ if val is not None:
2920
+ params[key] = val
2921
+
2922
+ data = self._get("/api/federal_register/", params)
2923
+
2924
+ results = [
2925
+ self._parse_response_with_shape(item, shape, FederalRegisterDocument, flat, flat_lists)
2926
+ for item in data["results"]
2927
+ ]
2928
+
2929
+ return PaginatedResponse(
2930
+ count=data["count"],
2931
+ next=data.get("next"),
2932
+ previous=data.get("previous"),
2933
+ results=results,
2934
+ meta=data.get("meta"),
2935
+ )
2936
+
2937
+ def get_federal_register_document(
2938
+ self,
2939
+ document_uuid: str,
2940
+ shape: str | None = None,
2941
+ flat: bool = False,
2942
+ flat_lists: bool = False,
2943
+ ) -> Any:
2944
+ """
2945
+ Get a single Federal Register document by uuid.
2946
+
2947
+ The detail route takes the document's ``uuid``, not its ``document_number``, which is not unique on its own.
2948
+ To look a document up by number, use ``list_federal_register_documents(document_number=...)``.
2949
+
2950
+ Args:
2951
+ document_uuid: Document UUID
2952
+ shape: Response shape string (defaults to the comprehensive shape). Name ``full_text`` here to get the document's plain text, which can run to several MB
2953
+ flat: If True, flatten nested objects in shaped response
2954
+ flat_lists: If True, flatten arrays using indexed keys
2955
+ """
2956
+ params: dict[str, Any] = {}
2957
+ if shape is None:
2958
+ shape = ShapeConfig.FEDERAL_REGISTER_COMPREHENSIVE
2959
+ if shape:
2960
+ params["shape"] = shape
2961
+ if flat:
2962
+ params["flat"] = "true"
2963
+ if flat_lists:
2964
+ params["flat_lists"] = "true"
2965
+
2966
+ data = self._get(f"/api/federal_register/{document_uuid}/", params)
2967
+ return self._parse_response_with_shape(
2968
+ data, shape, FederalRegisterDocument, flat, flat_lists
2969
+ )
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
+
2821
3180
  # ============================================================================
2822
3181
  # DLA DIBBS (RFQs, RFPs, awards)
2823
3182
  # ============================================================================
@@ -3242,6 +3601,7 @@ class TangoClient:
3242
3601
  update_date_before: str | None = None,
3243
3602
  search: str | None = None,
3244
3603
  ordering: str | None = None,
3604
+ agency: str | None = None,
3245
3605
  ) -> PaginatedResponse:
3246
3606
  """
3247
3607
  List SAM.gov exclusion (debarment) records.
@@ -3261,6 +3621,7 @@ class TangoClient:
3261
3621
  classification_type: Filter by classification (Firm, Individual, Vessel, ...)
3262
3622
  exclusion_type: Filter by exclusion type
3263
3623
  exclusion_program: Filter by exclusion program
3624
+ agency: Excluding agency as a Tango organization. A Tango agency name, abbreviation, code or organization key, e.g. ``EPA``. Matches the whole organization subtree, so a department includes its sub-agencies. OR several with ``|``
3264
3625
  excluding_agency_code: Filter by excluding agency code
3265
3626
  excluding_agency_name: Filter by excluding agency name
3266
3627
  active: True returns only records currently in effect.
@@ -3296,6 +3657,7 @@ class TangoClient:
3296
3657
  ("classification_type", classification_type),
3297
3658
  ("exclusion_type", exclusion_type),
3298
3659
  ("exclusion_program", exclusion_program),
3660
+ ("agency", agency),
3299
3661
  ("excluding_agency_code", excluding_agency_code),
3300
3662
  ("excluding_agency_name", excluding_agency_name),
3301
3663
  ("active", active),
@@ -4153,6 +4515,7 @@ class TangoClient:
4153
4515
  actual_vs_requested_contract_capped_lte: float | None = None,
4154
4516
  search: str | None = None,
4155
4517
  ordering: str | None = None,
4518
+ agency: str | None = None,
4156
4519
  ) -> PaginatedResponse:
4157
4520
  """List budget accounts (`/api/budget/accounts/`).
4158
4521
 
@@ -4170,6 +4533,7 @@ class TangoClient:
4170
4533
  fiscal_year: Fiscal year (exact).
4171
4534
  fiscal_year_gte: Fiscal year >=.
4172
4535
  fiscal_year_lte: Fiscal year <=.
4536
+ agency: A Tango agency name, abbreviation, code or organization key, e.g. ``EPA``. Matches the whole organization subtree, so a department includes its sub-agencies. OR several with ``|``.
4173
4537
  agency_code: Agency code (exact).
4174
4538
  bureau_name: Bureau name (exact).
4175
4539
  account_title: Account title (icontains).
@@ -4234,6 +4598,7 @@ class TangoClient:
4234
4598
  ("fiscal_year", fiscal_year),
4235
4599
  ("fiscal_year__gte", fiscal_year_gte),
4236
4600
  ("fiscal_year__lte", fiscal_year_lte),
4601
+ ("agency", agency),
4237
4602
  ("agency_code", agency_code),
4238
4603
  ("bureau_name", bureau_name),
4239
4604
  ("account_title__icontains", account_title),
@@ -4870,8 +5235,8 @@ class TangoClient:
4870
5235
  name: Human-readable name for this alert.
4871
5236
  query_type: One of ``opportunity``, ``contract``, ``idv``, ``ota``,
4872
5237
  ``otidv``, ``entity``, ``grant``, ``forecast``, ``exclusion``,
4873
- ``dibbs_rfq``, ``dibbs_rfp``, ``sled_opportunity`` or
4874
- ``contract_appeal``. :meth:`list_webhook_event_types` is the
5238
+ ``dibbs_rfq``, ``dibbs_rfp``, ``sled_opportunity``,
5239
+ ``contract_appeal``, ``federal_register`` or ``ebuy_request``. :meth:`list_webhook_event_types` is the
4875
5240
  current authority — this list can only go stale.
4876
5241
  filters: Dict of query parameters that the alert matches against
4877
5242
  (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
@@ -1213,6 +1213,156 @@ class ContractAppeal:
1213
1213
  decision_text: str | None = None
1214
1214
 
1215
1215
 
1216
+ class FederalRegisterDocument:
1217
+ """Schema definition for FederalRegisterDocument (not used for instances).
1218
+
1219
+ Federal Register documents at ``/api/federal_register/``: rules, proposed rules, notices and presidential documents published since 1994.
1220
+ Shape-on-demand, so every field is optional.
1221
+
1222
+ A document is identified by ``uuid``.
1223
+ ``document_number`` alone is not unique: the Federal Register reused some numbers before 2016, so ``document_number=`` can match more than one document.
1224
+
1225
+ ``agencies``, ``cfr_references``, ``dockets`` and ``topics`` are the Federal Register's own structures, served as published.
1226
+ ``full_text`` can run to several MB, so it is available on the detail endpoint only and only when named in ``shape``.
1227
+ """
1228
+
1229
+ uuid: str | None = None
1230
+ document_number: str | None = None
1231
+ publication_date: date | None = None
1232
+ citation: str | None = None
1233
+ title: str | None = None
1234
+ type: str | None = None
1235
+ subtype: str | None = None
1236
+ abstract: str | None = None
1237
+ action: str | None = None
1238
+ dates: str | None = None
1239
+ comments_close_on: date | None = None
1240
+ effective_on: date | None = None
1241
+ signing_date: date | None = None
1242
+ start_page: int | None = None
1243
+ end_page: int | None = None
1244
+ page_length: int | None = None
1245
+ volume: int | None = None
1246
+ significant: bool | None = None
1247
+ toc_doc: str | None = None
1248
+ toc_subject: str | None = None
1249
+ correction_of: str | None = None
1250
+ corrections: list[str] | None = None
1251
+ disposition_notes: str | None = None
1252
+ executive_order_notes: str | None = None
1253
+ executive_order_number: str | None = None
1254
+ presidential_document_number: str | None = None
1255
+ proclamation_number: str | None = None
1256
+ not_received_for_publication: str | None = None
1257
+ comment_url: str | None = None
1258
+ regulations_dot_gov_url: str | None = None
1259
+ html_url: str | None = None
1260
+ body_html_url: str | None = None
1261
+ pdf_url: str | None = None
1262
+ public_inspection_pdf_url: str | None = None
1263
+ raw_text_url: str | None = None
1264
+ agencies: list[dict[str, Any]] | None = None
1265
+ cfr_references: list[dict[str, Any]] | None = None
1266
+ dockets: list[dict[str, Any]] | None = None
1267
+ docket_ids: list[str] | None = None
1268
+ topics: list[str] | None = None
1269
+ regulation_id_numbers: list[str] | None = None
1270
+ regulation_id_number_info: dict[str, Any] | None = None
1271
+ regulations_dot_gov_info: dict[str, Any] | None = None
1272
+ full_text: str | None = None
1273
+
1274
+
1275
+ class EbuyAttachment:
1276
+ """Schema definition for one entry in an eBuy request's ``attachments`` (not used for instances).
1277
+
1278
+ ``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.
1279
+ """
1280
+
1281
+ doc_seq_num: int | None = None
1282
+ doc_name: str | None = None
1283
+ doc_type: int | None = None
1284
+ doc_path: str | None = None
1285
+ is_link: bool | None = None
1286
+ doc_session_date: datetime | None = None
1287
+
1288
+
1289
+ class EbuyRequest:
1290
+ """Schema definition for EbuyRequest (not used for instances).
1291
+
1292
+ GSA eBuy requests (RFQs, RFPs, RFIs) at ``/api/ebuy/requests/``, keyed by ``rfq_id``.
1293
+ Shape-on-demand, so every field is optional.
1294
+
1295
+ The resource is scoped to your account: you see only requests posted under the GSA schedule contracts linked to it.
1296
+
1297
+ ``status`` is frozen at the last state the request was seen in.
1298
+ 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".
1299
+ Read ``last_seen`` for staleness.
1300
+
1301
+ ``buyer_agency_code`` and some other buyer and contact fields are sparse on older requests.
1302
+ The contract number a request was posted under is never returned.
1303
+ """
1304
+
1305
+ rfq_id: str | None = None
1306
+ request_type: str | None = None
1307
+ title: str | None = None
1308
+ description: str | None = None
1309
+ schedule: str | None = None
1310
+ sin: str | None = None
1311
+ status: str | None = None
1312
+ buyer_name: str | None = None
1313
+ buyer_agency: str | None = None
1314
+ buyer_agency_code: str | None = None
1315
+ buyer_email: str | None = None
1316
+ buyer_user_id: str | None = None
1317
+ reference_number: str | None = None
1318
+ award_method: str | None = None
1319
+ contract_type: str | None = None
1320
+ commercial_type: str | None = None
1321
+ follow_on: bool | None = None
1322
+ source_sought: bool | None = None
1323
+ issue_date: datetime | None = None
1324
+ close_date: datetime | None = None
1325
+ cancel_date: datetime | None = None
1326
+ last_mod_date: datetime | None = None
1327
+ pop_start_date: datetime | None = None
1328
+ pop_end_date: datetime | None = None
1329
+ oco_name: str | None = None
1330
+ oco_title: str | None = None
1331
+ oco_agency: str | None = None
1332
+ oco_phone: str | None = None
1333
+ oco_aac: str | None = None
1334
+ ocs_name: str | None = None
1335
+ ocs_title: str | None = None
1336
+ ocs_agency: str | None = None
1337
+ ocs_phone: str | None = None
1338
+ ocs_aac: str | None = None
1339
+ amendment_count: int | None = None
1340
+ mod_version: int | None = None
1341
+ qa_document_count: int | None = None
1342
+ attachment_count: int | None = None
1343
+ link_count: int | None = None
1344
+ amendments: list[dict[str, Any]] | None = None
1345
+ line_items: list[dict[str, Any]] | None = None
1346
+ addresses: list[dict[str, Any]] | None = None
1347
+ detail_fetched: bool | None = None
1348
+ first_seen: datetime | None = None
1349
+ last_seen: datetime | None = None
1350
+ organization: dict[str, Any] | None = None
1351
+ attachments: list[EbuyAttachment] | None = None
1352
+
1353
+
1354
+ @dataclass
1355
+ class EbuyAccess:
1356
+ """Result of ``GET /api/ebuy/access/``: whether your account can read eBuy requests.
1357
+
1358
+ ``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".
1359
+ """
1360
+
1361
+ enabled: bool
1362
+ reason: Literal["tier_required", "no_contract_grant"] | None
1363
+ contracts: list[str]
1364
+
1365
+
1216
1366
  @dataclass
1217
1367
  class PaginatedResponse[T]:
1218
1368
  """Paginated API response
@@ -1394,6 +1544,44 @@ class ShapeConfig:
1394
1544
  "listing_year,first_listed_at,listed,text_status,text_char_count"
1395
1545
  )
1396
1546
 
1547
+ # Default for list_federal_register_documents(). Mirrors the API's own list default.
1548
+ FEDERAL_REGISTER_MINIMAL: Final = (
1549
+ "uuid,document_number,publication_date,type,subtype,title,abstract,action,"
1550
+ "agencies,cfr_references,citation,significant,comments_close_on,effective_on,"
1551
+ "html_url,pdf_url"
1552
+ )
1553
+
1554
+ # Default for get_federal_register_document(). Mirrors the API's own retrieve default.
1555
+ # `full_text` is left out on purpose: it can run to several MB.
1556
+ FEDERAL_REGISTER_COMPREHENSIVE: Final = (
1557
+ "uuid,document_number,publication_date,type,subtype,title,abstract,action,"
1558
+ "agencies,cfr_references,citation,significant,comments_close_on,effective_on,"
1559
+ "html_url,pdf_url,dates,signing_date,start_page,end_page,volume,docket_ids,"
1560
+ "dockets,regulation_id_numbers,topics,correction_of,corrections,"
1561
+ "executive_order_number,presidential_document_number,proclamation_number,"
1562
+ "comment_url,regulations_dot_gov_url,raw_text_url,body_html_url"
1563
+ )
1564
+
1565
+ # Default for list_ebuy_requests(). Mirrors the API's own list default.
1566
+ EBUY_REQUESTS_MINIMAL: Final = (
1567
+ "rfq_id,request_type,title,schedule,sin,status,buyer_name,buyer_agency,"
1568
+ "buyer_agency_code,reference_number,issue_date,close_date,attachment_count,"
1569
+ "link_count,last_seen"
1570
+ )
1571
+
1572
+ # Default for get_ebuy_request(). Mirrors the API's own retrieve default; the attachment fields are named rather than `attachments(*)` so their dates parse.
1573
+ EBUY_REQUESTS_COMPREHENSIVE: Final = (
1574
+ "rfq_id,request_type,title,description,schedule,sin,status,buyer_name,"
1575
+ "buyer_agency,buyer_agency_code,buyer_email,buyer_user_id,reference_number,"
1576
+ "award_method,contract_type,commercial_type,follow_on,source_sought,issue_date,"
1577
+ "close_date,cancel_date,last_mod_date,pop_start_date,pop_end_date,oco_name,"
1578
+ "oco_title,oco_agency,oco_phone,oco_aac,ocs_name,ocs_title,ocs_agency,ocs_phone,"
1579
+ "ocs_aac,amendment_count,mod_version,qa_document_count,attachment_count,"
1580
+ "link_count,amendments,line_items,addresses,detail_fetched,first_seen,last_seen,"
1581
+ "organization(*),"
1582
+ "attachments(doc_seq_num,doc_name,doc_type,doc_path,is_link,doc_session_date)"
1583
+ )
1584
+
1397
1585
  # Default for list_dibbs_rfqs()
1398
1586
  DIBBS_RFQS_MINIMAL: Final = (
1399
1587
  "uuid,solicitation,nsn,part_number,nomenclature,quantity,issue_date,return_by_date,is_open"
@@ -800,6 +800,174 @@ CONTRACT_APPEAL_SCHEMA: dict[str, FieldSchema] = {
800
800
  }
801
801
 
802
802
 
803
+ FEDERAL_REGISTER_DOCUMENT_SCHEMA: dict[str, FieldSchema] = {
804
+ "uuid": FieldSchema(name="uuid", type=str, is_optional=True, is_list=False),
805
+ "document_number": FieldSchema(
806
+ name="document_number", type=str, is_optional=True, is_list=False
807
+ ),
808
+ "publication_date": FieldSchema(
809
+ name="publication_date", type=date, is_optional=True, is_list=False
810
+ ),
811
+ "citation": FieldSchema(name="citation", type=str, is_optional=True, is_list=False),
812
+ "title": FieldSchema(name="title", type=str, is_optional=True, is_list=False),
813
+ "type": FieldSchema(name="type", type=str, is_optional=True, is_list=False),
814
+ "subtype": FieldSchema(name="subtype", type=str, is_optional=True, is_list=False),
815
+ "abstract": FieldSchema(name="abstract", type=str, is_optional=True, is_list=False),
816
+ "action": FieldSchema(name="action", type=str, is_optional=True, is_list=False),
817
+ "dates": FieldSchema(name="dates", type=str, is_optional=True, is_list=False),
818
+ "comments_close_on": FieldSchema(
819
+ name="comments_close_on", type=date, is_optional=True, is_list=False
820
+ ),
821
+ "effective_on": FieldSchema(name="effective_on", type=date, is_optional=True, is_list=False),
822
+ "signing_date": FieldSchema(name="signing_date", type=date, is_optional=True, is_list=False),
823
+ "start_page": FieldSchema(name="start_page", type=int, is_optional=True, is_list=False),
824
+ "end_page": FieldSchema(name="end_page", type=int, is_optional=True, is_list=False),
825
+ "page_length": FieldSchema(name="page_length", type=int, is_optional=True, is_list=False),
826
+ "volume": FieldSchema(name="volume", type=int, is_optional=True, is_list=False),
827
+ "significant": FieldSchema(name="significant", type=bool, is_optional=True, is_list=False),
828
+ "toc_doc": FieldSchema(name="toc_doc", type=str, is_optional=True, is_list=False),
829
+ "toc_subject": FieldSchema(name="toc_subject", type=str, is_optional=True, is_list=False),
830
+ "correction_of": FieldSchema(name="correction_of", type=str, is_optional=True, is_list=False),
831
+ "corrections": FieldSchema(name="corrections", type=str, is_optional=True, is_list=True),
832
+ "disposition_notes": FieldSchema(
833
+ name="disposition_notes", type=str, is_optional=True, is_list=False
834
+ ),
835
+ "executive_order_notes": FieldSchema(
836
+ name="executive_order_notes", type=str, is_optional=True, is_list=False
837
+ ),
838
+ "executive_order_number": FieldSchema(
839
+ name="executive_order_number", type=str, is_optional=True, is_list=False
840
+ ),
841
+ "presidential_document_number": FieldSchema(
842
+ name="presidential_document_number", type=str, is_optional=True, is_list=False
843
+ ),
844
+ "proclamation_number": FieldSchema(
845
+ name="proclamation_number", type=str, is_optional=True, is_list=False
846
+ ),
847
+ "not_received_for_publication": FieldSchema(
848
+ name="not_received_for_publication", type=str, is_optional=True, is_list=False
849
+ ),
850
+ "comment_url": FieldSchema(name="comment_url", type=str, is_optional=True, is_list=False),
851
+ "regulations_dot_gov_url": FieldSchema(
852
+ name="regulations_dot_gov_url", type=str, is_optional=True, is_list=False
853
+ ),
854
+ "html_url": FieldSchema(name="html_url", type=str, is_optional=True, is_list=False),
855
+ "body_html_url": FieldSchema(name="body_html_url", type=str, is_optional=True, is_list=False),
856
+ "pdf_url": FieldSchema(name="pdf_url", type=str, is_optional=True, is_list=False),
857
+ "public_inspection_pdf_url": FieldSchema(
858
+ name="public_inspection_pdf_url", type=str, is_optional=True, is_list=False
859
+ ),
860
+ "raw_text_url": FieldSchema(name="raw_text_url", type=str, is_optional=True, is_list=False),
861
+ "agencies": FieldSchema(name="agencies", type=dict, is_optional=True, is_list=True),
862
+ "cfr_references": FieldSchema(name="cfr_references", type=dict, is_optional=True, is_list=True),
863
+ "dockets": FieldSchema(name="dockets", type=dict, is_optional=True, is_list=True),
864
+ "docket_ids": FieldSchema(name="docket_ids", type=str, is_optional=True, is_list=True),
865
+ "topics": FieldSchema(name="topics", type=str, is_optional=True, is_list=True),
866
+ "regulation_id_numbers": FieldSchema(
867
+ name="regulation_id_numbers", type=str, is_optional=True, is_list=True
868
+ ),
869
+ "regulation_id_number_info": FieldSchema(
870
+ name="regulation_id_number_info", type=dict, is_optional=True, is_list=False
871
+ ),
872
+ "regulations_dot_gov_info": FieldSchema(
873
+ name="regulations_dot_gov_info", type=dict, is_optional=True, is_list=False
874
+ ),
875
+ # Detail endpoint only, and only when named in the shape.
876
+ "full_text": FieldSchema(name="full_text", type=str, is_optional=True, is_list=False),
877
+ }
878
+
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
+
803
971
  AGENCY_SCHEMA: dict[str, FieldSchema] = {
804
972
  "abbreviation": FieldSchema(name="abbreviation", type=str, is_optional=True, is_list=False),
805
973
  "code": FieldSchema(name="code", type=str, is_optional=False, is_list=False),
@@ -1545,6 +1713,9 @@ EXPLICIT_SCHEMAS: dict[str, dict[str, FieldSchema]] = {
1545
1713
  "Protest": PROTEST_SCHEMA,
1546
1714
  "ProtestDocket": PROTEST_DOCKET_SCHEMA,
1547
1715
  "ContractAppeal": CONTRACT_APPEAL_SCHEMA,
1716
+ "FederalRegisterDocument": FEDERAL_REGISTER_DOCUMENT_SCHEMA,
1717
+ "EbuyRequest": EBUY_REQUEST_SCHEMA,
1718
+ "EbuyAttachment": EBUY_ATTACHMENT_SCHEMA,
1548
1719
  "Agency": AGENCY_SCHEMA,
1549
1720
  "Grant": GRANT_SCHEMA,
1550
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,
@@ -1382,6 +1370,16 @@ GENERATED_OVERLAY: dict[str, dict[str, FieldSchema]] = {
1382
1370
  "on_off_budget": FieldSchema(
1383
1371
  name="on_off_budget", type=str, is_optional=True, is_list=False
1384
1372
  ),
1373
+ "organization": FieldSchema(
1374
+ name="organization",
1375
+ type=dict,
1376
+ is_optional=True,
1377
+ is_list=False,
1378
+ nested_model="AwardingOffice",
1379
+ ),
1380
+ "organization_id": FieldSchema(
1381
+ name="organization_id", type=str, is_optional=True, is_list=False
1382
+ ),
1385
1383
  "outlayed_to_obligated_pct": FieldSchema(
1386
1384
  name="outlayed_to_obligated_pct", type=str, is_optional=True, is_list=False
1387
1385
  ),
@@ -1911,6 +1909,16 @@ GENERATED_OVERLAY: dict[str, dict[str, FieldSchema]] = {
1911
1909
  name="more_locations", type=str, is_optional=True, is_list=False
1912
1910
  ),
1913
1911
  "npi": FieldSchema(name="npi", type=str, is_optional=True, is_list=False),
1912
+ "organization": FieldSchema(
1913
+ name="organization",
1914
+ type=dict,
1915
+ is_optional=True,
1916
+ is_list=False,
1917
+ nested_model="AwardingOffice",
1918
+ ),
1919
+ "organization_id": FieldSchema(
1920
+ name="organization_id", type=str, is_optional=True, is_list=False
1921
+ ),
1914
1922
  "prefix": FieldSchema(name="prefix", type=str, is_optional=True, is_list=False),
1915
1923
  "primary_address": FieldSchema(
1916
1924
  name="primary_address", type=str, is_optional=True, is_list=False
@@ -2218,7 +2226,7 @@ GENERATED_OVERLAY: dict[str, dict[str, FieldSchema]] = {
2218
2226
  type=dict,
2219
2227
  is_optional=True,
2220
2228
  is_list=True,
2221
- nested_model="Attachments2",
2229
+ nested_model="Attachments",
2222
2230
  ),
2223
2231
  "meta": FieldSchema(
2224
2232
  name="meta", type=dict, is_optional=True, is_list=False, nested_model="Meta"
@@ -2466,7 +2474,7 @@ GENERATED_OVERLAY: dict[str, dict[str, FieldSchema]] = {
2466
2474
  type=dict,
2467
2475
  is_optional=True,
2468
2476
  is_list=False,
2469
- nested_model="Attachments6",
2477
+ nested_model="Attachments5",
2470
2478
  ),
2471
2479
  "department": FieldSchema(
2472
2480
  name="department",
@@ -2683,6 +2691,13 @@ GENERATED_OVERLAY: dict[str, dict[str, FieldSchema]] = {
2683
2691
  name="documents", type=dict, is_optional=True, is_list=True, nested_model="Documents"
2684
2692
  ),
2685
2693
  "end_date": FieldSchema(name="end_date", type=date, is_optional=True, is_list=False),
2694
+ "organization": FieldSchema(
2695
+ name="organization",
2696
+ type=dict,
2697
+ is_optional=True,
2698
+ is_list=False,
2699
+ nested_model="AwardingOffice",
2700
+ ),
2686
2701
  "out_of_cycle": FieldSchema(
2687
2702
  name="out_of_cycle", type=bool, is_optional=True, is_list=False
2688
2703
  ),
@@ -2736,6 +2751,13 @@ GENERATED_OVERLAY: dict[str, dict[str, FieldSchema]] = {
2736
2751
  is_list=False,
2737
2752
  nested_model="Opportunity2",
2738
2753
  ),
2754
+ "organization": FieldSchema(
2755
+ name="organization",
2756
+ type=dict,
2757
+ is_optional=True,
2758
+ is_list=False,
2759
+ nested_model="AwardingOffice",
2760
+ ),
2739
2761
  "release_date": FieldSchema(
2740
2762
  name="release_date", type=date, is_optional=True, is_list=False
2741
2763
  ),
@@ -2839,7 +2861,7 @@ GENERATED_OVERLAY: dict[str, dict[str, FieldSchema]] = {
2839
2861
  type=dict,
2840
2862
  is_optional=True,
2841
2863
  is_list=True,
2842
- nested_model="Attachments4",
2864
+ nested_model="Attachments3",
2843
2865
  ),
2844
2866
  "bid_opening_date": FieldSchema(
2845
2867
  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.7.0
3
+ Version: 1.9.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=P7q3qdG_74A9OKteDHRNyH1pD_VuPOoBFGSFZECbVA0,2300
2
- tango/client.py,sha256=R69lCC6XTo42lVbuWXqEJNyO0-tro0IthgwyWiuJs6Y,220172
3
- tango/exceptions.py,sha256=B1t27BBxHcPglAfJQyan5khNEYNAyF6Wkp2EmPMdRxM,3858
4
- tango/models.py,sha256=X5ijWFnz05kgobqoPdtxRxWWW_hVfFZRtCGksbgQW-o,58377
1
+ tango/__init__.py,sha256=jdqZItlsYrPAUN5SNr0jxMzKta8MVebhkafeJkatzyI,2534
2
+ tango/client.py,sha256=_QCR5rwYPHs1YHsuf9TCEJXQoNIcHshF5nRrArmuf7s,237760
3
+ tango/exceptions.py,sha256=oPiLCccBoWLK5SjHkzP-oI6mUD9twEeKSvEuLV5h01I,4224
4
+ tango/models.py,sha256=Ujvt-cUxfFA2VZoAfRZs77WPcwUdyb-Hw-EWkoa7mrw,66613
5
5
  tango/shapes/__init__.py,sha256=7ea1WU74jp4znhNw-gXruag6m6eyPZtbVgbDFmFUWro,1072
6
- tango/shapes/explicit_schemas.py,sha256=WvkKaEkW01HgO56In-8vNlxZeoEkJJEYjZOUyrGItU4,73775
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=wv3Z4MjsU7xyk8EKzUNG1BgfAxgukGcsQ_5QkPPJyvA,137957
8
+ tango/shapes/generated_overlay.py,sha256=X6VLGbO2sgh4X_u8hJmIqnB30Hct8XZDQfnSbIV7tBU,138368
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.7.0.dist-info/METADATA,sha256=1Jf7m5SbI4g54SfWLy1LQxFVz-W2LA1uLbYA3MBkBlE,21779
20
- tango_python-1.7.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
21
- tango_python-1.7.0.dist-info/entry_points.txt,sha256=kGLUbglUjuaAqEFvOZ1QuSW0vWb6VeSpCIFKaOFkKoQ,50
22
- tango_python-1.7.0.dist-info/licenses/LICENSE,sha256=j2kYVHMwTkoGn3ZNScnrdIueG0k2XzB_LCPFoyBc2wk,1064
23
- tango_python-1.7.0.dist-info/RECORD,,
19
+ tango_python-1.9.0.dist-info/METADATA,sha256=FFL14hmyg_t__WWg6lSxVA3ydOSA8GwLoaIdysJsuog,22117
20
+ tango_python-1.9.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
21
+ tango_python-1.9.0.dist-info/entry_points.txt,sha256=kGLUbglUjuaAqEFvOZ1QuSW0vWb6VeSpCIFKaOFkKoQ,50
22
+ tango_python-1.9.0.dist-info/licenses/LICENSE,sha256=j2kYVHMwTkoGn3ZNScnrdIueG0k2XzB_LCPFoyBc2wk,1064
23
+ tango_python-1.9.0.dist-info/RECORD,,