tango-python 0.4.2__py3-none-any.whl → 0.4.3__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,
@@ -77,6 +78,7 @@ class TangoClient:
77
78
  headers["X-API-KEY"] = self.api_key
78
79
 
79
80
  self.client = httpx.Client(headers=headers, timeout=30.0)
81
+ self._last_rate_limit_info: RateLimitInfo | None = None
80
82
 
81
83
  # Use hardcoded sensible defaults
82
84
  cache_size = 100
@@ -98,6 +100,34 @@ class TangoClient:
98
100
  # Core HTTP Request Utilities
99
101
  # ============================================================================
100
102
 
103
+ @property
104
+ def rate_limit_info(self) -> RateLimitInfo | None:
105
+ """Rate limit info from the most recent API response."""
106
+ return self._last_rate_limit_info
107
+
108
+ @staticmethod
109
+ def _parse_rate_limit_headers(headers: httpx.Headers) -> RateLimitInfo:
110
+ """Extract rate limit info from response headers."""
111
+ def _int_or_none(val: str | None) -> int | None:
112
+ if val is None:
113
+ return None
114
+ try:
115
+ return int(val)
116
+ except (ValueError, TypeError):
117
+ return None
118
+
119
+ return RateLimitInfo(
120
+ limit=_int_or_none(headers.get("X-RateLimit-Limit")),
121
+ remaining=_int_or_none(headers.get("X-RateLimit-Remaining")),
122
+ reset=_int_or_none(headers.get("X-RateLimit-Reset")),
123
+ daily_limit=_int_or_none(headers.get("X-RateLimit-Daily-Limit")),
124
+ daily_remaining=_int_or_none(headers.get("X-RateLimit-Daily-Remaining")),
125
+ daily_reset=_int_or_none(headers.get("X-RateLimit-Daily-Reset")),
126
+ burst_limit=_int_or_none(headers.get("X-RateLimit-Burst-Limit")),
127
+ burst_remaining=_int_or_none(headers.get("X-RateLimit-Burst-Remaining")),
128
+ burst_reset=_int_or_none(headers.get("X-RateLimit-Burst-Reset")),
129
+ )
130
+
101
131
  def _request(
102
132
  self,
103
133
  method: str,
@@ -110,6 +140,7 @@ class TangoClient:
110
140
 
111
141
  try:
112
142
  response = self.client.request(method=method, url=url, params=params, json=json_data)
143
+ self._last_rate_limit_info = self._parse_rate_limit_headers(response.headers)
113
144
 
114
145
  if response.status_code == 401:
115
146
  raise TangoAuthError(
@@ -136,7 +167,9 @@ class TangoClient:
136
167
  error_data,
137
168
  )
138
169
  elif response.status_code == 429:
139
- raise TangoRateLimitError("Rate limit exceeded", response.status_code)
170
+ error_data = response.json() if response.content else {}
171
+ detail = error_data.get("detail", "Rate limit exceeded")
172
+ raise TangoRateLimitError(detail, response.status_code, error_data)
140
173
  elif not response.is_success:
141
174
  raise TangoAPIError(
142
175
  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.3
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=kzUVPPOur45QobKBrRzPXGOOYZHXg-cBEWlSIB8vvAo,87725
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.3.dist-info/METADATA,sha256=OhV1f_Hifu1hUd2c0zfGc91rIIe07ddJ3Nw3MDnqFl8,17595
14
+ tango_python-0.4.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ tango_python-0.4.3.dist-info/licenses/LICENSE,sha256=j2kYVHMwTkoGn3ZNScnrdIueG0k2XzB_LCPFoyBc2wk,1064
16
+ tango_python-0.4.3.dist-info/RECORD,,