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 +3 -1
- tango/client.py +49 -1
- tango/exceptions.py +28 -1
- tango/models.py +15 -0
- {tango_python-0.4.2.dist-info → tango_python-0.4.4.dist-info}/METADATA +1 -1
- {tango_python-0.4.2.dist-info → tango_python-0.4.4.dist-info}/RECORD +8 -8
- {tango_python-0.4.2.dist-info → tango_python-0.4.4.dist-info}/WHEEL +0 -0
- {tango_python-0.4.2.dist-info → tango_python-0.4.4.dist-info}/licenses/LICENSE +0 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,7 +1,7 @@
|
|
|
1
|
-
tango/__init__.py,sha256=
|
|
2
|
-
tango/client.py,sha256=
|
|
3
|
-
tango/exceptions.py,sha256=
|
|
4
|
-
tango/models.py,sha256=
|
|
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.
|
|
14
|
-
tango_python-0.4.
|
|
15
|
-
tango_python-0.4.
|
|
16
|
-
tango_python-0.4.
|
|
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,,
|
|
File without changes
|
|
File without changes
|