tango-python 0.4.2__py3-none-any.whl → 0.4.4__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
@@ -11,6 +11,7 @@ from .exceptions import (
11
11
  from .models import (
12
12
  GsaElibraryContract,
13
13
  PaginatedResponse,
14
+ RateLimitInfo,
14
15
  SearchFilters,
15
16
  ShapeConfig,
16
17
  WebhookEndpoint,
@@ -27,7 +28,7 @@ from .shapes import (
27
28
  TypeGenerator,
28
29
  )
29
30
 
30
- __version__ = "0.4.1"
31
+ __version__ = "0.4.3"
31
32
  __all__ = [
32
33
  "TangoClient",
33
34
  "TangoAPIError",
@@ -35,6 +36,7 @@ __all__ = [
35
36
  "TangoNotFoundError",
36
37
  "TangoValidationError",
37
38
  "TangoRateLimitError",
39
+ "RateLimitInfo",
38
40
  "GsaElibraryContract",
39
41
  "PaginatedResponse",
40
42
  "SearchFilters",
tango/client.py CHANGED
@@ -32,6 +32,7 @@ from tango.models import (
32
32
  Organization,
33
33
  PaginatedResponse,
34
34
  Protest,
35
+ RateLimitInfo,
35
36
  SearchFilters,
36
37
  ShapeConfig,
37
38
  Subaward,
@@ -58,6 +59,8 @@ class TangoClient:
58
59
  self,
59
60
  api_key: str | None = None,
60
61
  base_url: str = "https://tango.makegov.com",
62
+ user_agent: str | None = None,
63
+ extra_headers: dict[str, str] | None = None,
61
64
  ):
62
65
  """
63
66
  Initialize the Tango API client
@@ -66,6 +69,8 @@ class TangoClient:
66
69
  api_key: API key for authentication. If not provided, will attempt to load from
67
70
  TANGO_API_KEY environment variable.
68
71
  base_url: Base URL for the API
72
+ user_agent: Custom User-Agent header value.
73
+ extra_headers: Additional headers to include in every request.
69
74
  """
70
75
  # Load API key from environment if not provided
71
76
  self.api_key = api_key or os.getenv("TANGO_API_KEY")
@@ -75,8 +80,14 @@ class TangoClient:
75
80
  headers = {}
76
81
  if self.api_key:
77
82
  headers["X-API-KEY"] = self.api_key
83
+ if user_agent:
84
+ headers["User-Agent"] = user_agent
85
+ if extra_headers:
86
+ headers.update(extra_headers)
78
87
 
79
88
  self.client = httpx.Client(headers=headers, timeout=30.0)
89
+ self._last_rate_limit_info: RateLimitInfo | None = None
90
+ self._last_response_headers: httpx.Headers | None = None
80
91
 
81
92
  # Use hardcoded sensible defaults
82
93
  cache_size = 100
@@ -98,6 +109,39 @@ class TangoClient:
98
109
  # Core HTTP Request Utilities
99
110
  # ============================================================================
100
111
 
112
+ @property
113
+ def rate_limit_info(self) -> RateLimitInfo | None:
114
+ """Rate limit info from the most recent API response."""
115
+ return self._last_rate_limit_info
116
+
117
+ @property
118
+ def last_response_headers(self) -> httpx.Headers | None:
119
+ """Full HTTP headers from the most recent API response."""
120
+ return self._last_response_headers
121
+
122
+ @staticmethod
123
+ def _parse_rate_limit_headers(headers: httpx.Headers) -> RateLimitInfo:
124
+ """Extract rate limit info from response headers."""
125
+ def _int_or_none(val: str | None) -> int | None:
126
+ if val is None:
127
+ return None
128
+ try:
129
+ return int(val)
130
+ except (ValueError, TypeError):
131
+ return None
132
+
133
+ return RateLimitInfo(
134
+ limit=_int_or_none(headers.get("X-RateLimit-Limit")),
135
+ remaining=_int_or_none(headers.get("X-RateLimit-Remaining")),
136
+ reset=_int_or_none(headers.get("X-RateLimit-Reset")),
137
+ daily_limit=_int_or_none(headers.get("X-RateLimit-Daily-Limit")),
138
+ daily_remaining=_int_or_none(headers.get("X-RateLimit-Daily-Remaining")),
139
+ daily_reset=_int_or_none(headers.get("X-RateLimit-Daily-Reset")),
140
+ burst_limit=_int_or_none(headers.get("X-RateLimit-Burst-Limit")),
141
+ burst_remaining=_int_or_none(headers.get("X-RateLimit-Burst-Remaining")),
142
+ burst_reset=_int_or_none(headers.get("X-RateLimit-Burst-Reset")),
143
+ )
144
+
101
145
  def _request(
102
146
  self,
103
147
  method: str,
@@ -110,6 +154,8 @@ class TangoClient:
110
154
 
111
155
  try:
112
156
  response = self.client.request(method=method, url=url, params=params, json=json_data)
157
+ self._last_response_headers = response.headers
158
+ self._last_rate_limit_info = self._parse_rate_limit_headers(response.headers)
113
159
 
114
160
  if response.status_code == 401:
115
161
  raise TangoAuthError(
@@ -136,7 +182,9 @@ class TangoClient:
136
182
  error_data,
137
183
  )
138
184
  elif response.status_code == 429:
139
- raise TangoRateLimitError("Rate limit exceeded", response.status_code)
185
+ error_data = response.json() if response.content else {}
186
+ detail = error_data.get("detail", "Rate limit exceeded")
187
+ raise TangoRateLimitError(detail, response.status_code, error_data)
140
188
  elif not response.is_success:
141
189
  raise TangoAPIError(
142
190
  f"API request failed with status {response.status_code}", response.status_code
tango/exceptions.py CHANGED
@@ -39,7 +39,34 @@ class TangoValidationError(TangoAPIError):
39
39
  class TangoRateLimitError(TangoAPIError):
40
40
  """Rate limit exceeded error"""
41
41
 
42
- pass
42
+ @property
43
+ def wait_in_seconds(self) -> int | None:
44
+ """Seconds to wait before retrying, from API response."""
45
+ val = self.response_data.get("wait_in_seconds")
46
+ if val is not None:
47
+ try:
48
+ return int(val)
49
+ except (ValueError, TypeError):
50
+ return None
51
+ return None
52
+
53
+ @property
54
+ def detail(self) -> str | None:
55
+ """Human-readable detail from API response."""
56
+ return self.response_data.get("detail")
57
+
58
+ @property
59
+ def limit_type(self) -> str | None:
60
+ """Which limit was hit: 'burst' or 'daily', parsed from detail."""
61
+ d = self.detail
62
+ if not d:
63
+ return None
64
+ lower = d.lower()
65
+ if "burst" in lower or "minute" in lower:
66
+ return "burst"
67
+ if "daily" in lower or "day" in lower:
68
+ return "daily"
69
+ return None
43
70
 
44
71
 
45
72
  class ShapeError(TangoAPIError):
tango/models.py CHANGED
@@ -23,6 +23,21 @@ T = TypeVar("T")
23
23
  # ============================================================================
24
24
 
25
25
 
26
+ @dataclass
27
+ class RateLimitInfo:
28
+ """Rate limit information from API response headers."""
29
+
30
+ limit: int | None = None
31
+ remaining: int | None = None
32
+ reset: int | None = None
33
+ daily_limit: int | None = None
34
+ daily_remaining: int | None = None
35
+ daily_reset: int | None = None
36
+ burst_limit: int | None = None
37
+ burst_remaining: int | None = None
38
+ burst_reset: int | None = None
39
+
40
+
26
41
  @dataclass
27
42
  class SearchFilters:
28
43
  """Search filter parameters for contract search
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tango-python
3
- Version: 0.4.2
3
+ Version: 0.4.4
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
@@ -1,7 +1,7 @@
1
- tango/__init__.py,sha256=7EdEUgCcK0lDNJWWDgdSWxx8xxryO-fHL-iml9PaYLw,1102
2
- tango/client.py,sha256=HvS-ss9QOeajLOkP2IsX9gTF_KvOb9_NOlLUphd8gSc,86111
3
- tango/exceptions.py,sha256=JmtbOY0ofBnX24pUErh2XFlTj9dim2ngyboserEGRFw,2226
4
- tango/models.py,sha256=4sLmWjyqsCU7-b8lr9ZRRG_ElBKz-5txibcOFcScEDY,20555
1
+ tango/__init__.py,sha256=MkZ4VcgIB5WFfCaxK6XXrK25icagQ2o9ZNta64xsqgw,1142
2
+ tango/client.py,sha256=Dm8v3xMO3uX-GGM1ioLySzjLavPmlxhkrqZw4q6wTWw,88396
3
+ tango/exceptions.py,sha256=aRvDm0dUCEtNDfRVYCX7SEDdd1WlIVVY6sN78Tzo-a0,3114
4
+ tango/models.py,sha256=EDKsZ4fsxkAbDhX5roOfiKYyPjZaTV-5QKrPaQCsb0Y,20959
5
5
  tango/shapes/__init__.py,sha256=7ea1WU74jp4znhNw-gXruag6m6eyPZtbVgbDFmFUWro,1072
6
6
  tango/shapes/explicit_schemas.py,sha256=H4pYs0LCTSV5msRCxftmgiM_-3sc4LsqpDPgj36DkPY,55202
7
7
  tango/shapes/factory.py,sha256=ytpMi5Uw72XZ8MimhuSsLDVXF3zO_Zt3_tAL6NF7LnU,34318
@@ -10,7 +10,7 @@ tango/shapes/models.py,sha256=h3pIhOqrrdlN953Y6r0oney5HFbKPOD-frRndRWimJ0,3018
10
10
  tango/shapes/parser.py,sha256=k6OsI2w3GH6-IBbc-XTLgL1mWH7bMf7A_dA6pr1xKfw,24619
11
11
  tango/shapes/schema.py,sha256=VRPOB1sBdjFyimNchrZKIpTHn83CyX4RfU9077aQtIU,14136
12
12
  tango/shapes/types.py,sha256=27jrAE0VIdrKaLjR_FK71hfIIGX2Tg3ex7REEBV1TFE,1301
13
- tango_python-0.4.2.dist-info/METADATA,sha256=h3QpwadNjsoHnMuXXpFj6BScFCvx61NGGOM43gG6GLQ,17595
14
- tango_python-0.4.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
- tango_python-0.4.2.dist-info/licenses/LICENSE,sha256=j2kYVHMwTkoGn3ZNScnrdIueG0k2XzB_LCPFoyBc2wk,1064
16
- tango_python-0.4.2.dist-info/RECORD,,
13
+ tango_python-0.4.4.dist-info/METADATA,sha256=QGTVP5M9kTipieVrII_ocUU061ukbG61bgDfscHIpgg,17595
14
+ tango_python-0.4.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ tango_python-0.4.4.dist-info/licenses/LICENSE,sha256=j2kYVHMwTkoGn3ZNScnrdIueG0k2XzB_LCPFoyBc2wk,1064
16
+ tango_python-0.4.4.dist-info/RECORD,,