tango-python 0.4.1__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 +3 -1
- tango/client.py +158 -68
- tango/exceptions.py +28 -1
- tango/models.py +46 -1
- tango/shapes/explicit_schemas.py +53 -0
- {tango_python-0.4.1.dist-info → tango_python-0.4.3.dist-info}/METADATA +57 -29
- tango_python-0.4.3.dist-info/RECORD +16 -0
- tango_python-0.4.1.dist-info/RECORD +0 -16
- {tango_python-0.4.1.dist-info → tango_python-0.4.3.dist-info}/WHEEL +0 -0
- {tango_python-0.4.1.dist-info → tango_python-0.4.3.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
|
@@ -31,6 +31,8 @@ from tango.models import (
|
|
|
31
31
|
Opportunity,
|
|
32
32
|
Organization,
|
|
33
33
|
PaginatedResponse,
|
|
34
|
+
Protest,
|
|
35
|
+
RateLimitInfo,
|
|
34
36
|
SearchFilters,
|
|
35
37
|
ShapeConfig,
|
|
36
38
|
Subaward,
|
|
@@ -76,6 +78,7 @@ class TangoClient:
|
|
|
76
78
|
headers["X-API-KEY"] = self.api_key
|
|
77
79
|
|
|
78
80
|
self.client = httpx.Client(headers=headers, timeout=30.0)
|
|
81
|
+
self._last_rate_limit_info: RateLimitInfo | None = None
|
|
79
82
|
|
|
80
83
|
# Use hardcoded sensible defaults
|
|
81
84
|
cache_size = 100
|
|
@@ -97,6 +100,34 @@ class TangoClient:
|
|
|
97
100
|
# Core HTTP Request Utilities
|
|
98
101
|
# ============================================================================
|
|
99
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
|
+
|
|
100
131
|
def _request(
|
|
101
132
|
self,
|
|
102
133
|
method: str,
|
|
@@ -109,6 +140,7 @@ class TangoClient:
|
|
|
109
140
|
|
|
110
141
|
try:
|
|
111
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)
|
|
112
144
|
|
|
113
145
|
if response.status_code == 401:
|
|
114
146
|
raise TangoAuthError(
|
|
@@ -135,7 +167,9 @@ class TangoClient:
|
|
|
135
167
|
error_data,
|
|
136
168
|
)
|
|
137
169
|
elif response.status_code == 429:
|
|
138
|
-
|
|
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)
|
|
139
173
|
elif not response.is_success:
|
|
140
174
|
raise TangoAPIError(
|
|
141
175
|
f"API request failed with status {response.status_code}", response.status_code
|
|
@@ -943,32 +977,6 @@ class TangoClient:
|
|
|
943
977
|
page_metadata=data.get("page_metadata"),
|
|
944
978
|
)
|
|
945
979
|
|
|
946
|
-
def get_idv_summary(self, identifier: str) -> dict[str, Any]:
|
|
947
|
-
"""Get a summary for an IDV solicitation identifier (`/api/idvs/{identifier}/summary/`)."""
|
|
948
|
-
return self._get(f"/api/idvs/{identifier}/summary/")
|
|
949
|
-
|
|
950
|
-
def list_idv_summary_awards(
|
|
951
|
-
self,
|
|
952
|
-
identifier: str,
|
|
953
|
-
limit: int = 25,
|
|
954
|
-
cursor: str | None = None,
|
|
955
|
-
ordering: str | None = None,
|
|
956
|
-
) -> PaginatedResponse:
|
|
957
|
-
"""List awards under an IDV summary (`/api/idvs/{identifier}/summary/awards/`)."""
|
|
958
|
-
params: dict[str, Any] = {"limit": min(limit, 100)}
|
|
959
|
-
if cursor:
|
|
960
|
-
params["cursor"] = cursor
|
|
961
|
-
if ordering:
|
|
962
|
-
params["ordering"] = ordering
|
|
963
|
-
data = self._get(f"/api/idvs/{identifier}/summary/awards/", params)
|
|
964
|
-
return PaginatedResponse(
|
|
965
|
-
count=int(data.get("count") or len(data.get("results") or [])),
|
|
966
|
-
next=data.get("next"),
|
|
967
|
-
previous=data.get("previous"),
|
|
968
|
-
results=data.get("results") or [],
|
|
969
|
-
page_metadata=data.get("page_metadata"),
|
|
970
|
-
)
|
|
971
|
-
|
|
972
980
|
def list_otas(
|
|
973
981
|
self,
|
|
974
982
|
limit: int = 25,
|
|
@@ -1859,6 +1867,129 @@ class TangoClient:
|
|
|
1859
1867
|
results=results,
|
|
1860
1868
|
)
|
|
1861
1869
|
|
|
1870
|
+
# Protest endpoints
|
|
1871
|
+
# See https://tango.makegov.com/docs/api-reference/protests.md
|
|
1872
|
+
# Note: Protests API does not support ordering (returns 400 if provided).
|
|
1873
|
+
# Use shape=...,dockets(...) to request nested dockets.
|
|
1874
|
+
def list_protests(
|
|
1875
|
+
self,
|
|
1876
|
+
page: int = 1,
|
|
1877
|
+
limit: int = 25,
|
|
1878
|
+
shape: str | None = None,
|
|
1879
|
+
flat: bool = False,
|
|
1880
|
+
flat_lists: bool = False,
|
|
1881
|
+
source_system: str | None = None,
|
|
1882
|
+
outcome: str | None = None,
|
|
1883
|
+
case_type: str | None = None,
|
|
1884
|
+
agency: str | None = None,
|
|
1885
|
+
case_number: str | None = None,
|
|
1886
|
+
solicitation_number: str | None = None,
|
|
1887
|
+
protester: str | None = None,
|
|
1888
|
+
filed_date_after: str | None = None,
|
|
1889
|
+
filed_date_before: str | None = None,
|
|
1890
|
+
decision_date_after: str | None = None,
|
|
1891
|
+
decision_date_before: str | None = None,
|
|
1892
|
+
search: str | None = None,
|
|
1893
|
+
) -> PaginatedResponse:
|
|
1894
|
+
"""
|
|
1895
|
+
List bid protests.
|
|
1896
|
+
|
|
1897
|
+
Returns case-level protest records. Use shape=...,dockets(...) to include
|
|
1898
|
+
nested dockets. API reference: https://tango.makegov.com/docs/api-reference/protests.md
|
|
1899
|
+
|
|
1900
|
+
Args:
|
|
1901
|
+
page: Page number
|
|
1902
|
+
limit: Results per page (max 100)
|
|
1903
|
+
shape: Response shape string (defaults to minimal shape)
|
|
1904
|
+
flat: If True, flatten nested objects in shaped response
|
|
1905
|
+
flat_lists: If True, flatten arrays using indexed keys
|
|
1906
|
+
source_system: Filter by source system (e.g. gao)
|
|
1907
|
+
outcome: Filter by outcome (e.g. Denied, Dismissed, Withdrawn, Sustained)
|
|
1908
|
+
case_type: Filter by case type
|
|
1909
|
+
agency: Filter by protested agency text
|
|
1910
|
+
case_number: Filter by case number (e.g. b-423274)
|
|
1911
|
+
solicitation_number: Filter by exact solicitation number
|
|
1912
|
+
protester: Filter by protester name text
|
|
1913
|
+
filed_date_after: Filed date on or after
|
|
1914
|
+
filed_date_before: Filed date on or before
|
|
1915
|
+
decision_date_after: Decision date on or after
|
|
1916
|
+
decision_date_before: Decision date on or before
|
|
1917
|
+
search: Full-text search over protest searchable fields
|
|
1918
|
+
"""
|
|
1919
|
+
params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
|
|
1920
|
+
|
|
1921
|
+
if shape is None:
|
|
1922
|
+
shape = ShapeConfig.PROTESTS_MINIMAL
|
|
1923
|
+
if shape:
|
|
1924
|
+
params["shape"] = shape
|
|
1925
|
+
if flat:
|
|
1926
|
+
params["flat"] = "true"
|
|
1927
|
+
if flat_lists:
|
|
1928
|
+
params["flat_lists"] = "true"
|
|
1929
|
+
|
|
1930
|
+
for key, val in (
|
|
1931
|
+
("source_system", source_system),
|
|
1932
|
+
("outcome", outcome),
|
|
1933
|
+
("case_type", case_type),
|
|
1934
|
+
("agency", agency),
|
|
1935
|
+
("case_number", case_number),
|
|
1936
|
+
("solicitation_number", solicitation_number),
|
|
1937
|
+
("protester", protester),
|
|
1938
|
+
("filed_date_after", filed_date_after),
|
|
1939
|
+
("filed_date_before", filed_date_before),
|
|
1940
|
+
("decision_date_after", decision_date_after),
|
|
1941
|
+
("decision_date_before", decision_date_before),
|
|
1942
|
+
("search", search),
|
|
1943
|
+
):
|
|
1944
|
+
if val is not None:
|
|
1945
|
+
params[key] = val
|
|
1946
|
+
|
|
1947
|
+
data = self._get("/api/protests/", params)
|
|
1948
|
+
|
|
1949
|
+
results = [
|
|
1950
|
+
self._parse_response_with_shape(item, shape, Protest, flat, flat_lists)
|
|
1951
|
+
for item in data["results"]
|
|
1952
|
+
]
|
|
1953
|
+
|
|
1954
|
+
return PaginatedResponse(
|
|
1955
|
+
count=data["count"],
|
|
1956
|
+
next=data.get("next"),
|
|
1957
|
+
previous=data.get("previous"),
|
|
1958
|
+
results=results,
|
|
1959
|
+
)
|
|
1960
|
+
|
|
1961
|
+
def get_protest(
|
|
1962
|
+
self,
|
|
1963
|
+
case_id: str,
|
|
1964
|
+
shape: str | None = None,
|
|
1965
|
+
flat: bool = False,
|
|
1966
|
+
flat_lists: bool = False,
|
|
1967
|
+
) -> Any:
|
|
1968
|
+
"""
|
|
1969
|
+
Get a single protest by case_id (RFC 4122 UUID).
|
|
1970
|
+
|
|
1971
|
+
Use shape=...,dockets(...) to include nested dockets.
|
|
1972
|
+
API reference: https://tango.makegov.com/docs/api-reference/protests.md
|
|
1973
|
+
|
|
1974
|
+
Args:
|
|
1975
|
+
case_id: Deterministic case UUID (from source_system + base_case_number)
|
|
1976
|
+
shape: Response shape string (defaults to minimal shape)
|
|
1977
|
+
flat: If True, flatten nested objects in shaped response
|
|
1978
|
+
flat_lists: If True, flatten arrays using indexed keys
|
|
1979
|
+
"""
|
|
1980
|
+
params: dict[str, Any] = {}
|
|
1981
|
+
if shape is None:
|
|
1982
|
+
shape = ShapeConfig.PROTESTS_MINIMAL
|
|
1983
|
+
if shape:
|
|
1984
|
+
params["shape"] = shape
|
|
1985
|
+
if flat:
|
|
1986
|
+
params["flat"] = "true"
|
|
1987
|
+
if flat_lists:
|
|
1988
|
+
params["flat_lists"] = "true"
|
|
1989
|
+
|
|
1990
|
+
data = self._get(f"/api/protests/{case_id}/", params)
|
|
1991
|
+
return self._parse_response_with_shape(data, shape, Protest, flat, flat_lists)
|
|
1992
|
+
|
|
1862
1993
|
# Grant endpoints
|
|
1863
1994
|
def list_grants(
|
|
1864
1995
|
self,
|
|
@@ -1945,47 +2076,6 @@ class TangoClient:
|
|
|
1945
2076
|
results=results,
|
|
1946
2077
|
)
|
|
1947
2078
|
|
|
1948
|
-
def list_assistance(
|
|
1949
|
-
self,
|
|
1950
|
-
limit: int = 25,
|
|
1951
|
-
cursor: str | None = None,
|
|
1952
|
-
assistance_type: str | None = None,
|
|
1953
|
-
award_key: str | None = None,
|
|
1954
|
-
fiscal_year: int | None = None,
|
|
1955
|
-
fiscal_year_gte: int | None = None,
|
|
1956
|
-
fiscal_year_lte: int | None = None,
|
|
1957
|
-
highly_compensated_officers: str | None = None,
|
|
1958
|
-
recipient: str | None = None,
|
|
1959
|
-
recipient_address: str | None = None,
|
|
1960
|
-
search: str | None = None,
|
|
1961
|
-
) -> PaginatedResponse:
|
|
1962
|
-
"""List assistance (financial assistance) transactions (`/api/assistance/`). Keyset pagination."""
|
|
1963
|
-
params: dict[str, Any] = {"limit": min(limit, 100)}
|
|
1964
|
-
if cursor:
|
|
1965
|
-
params["cursor"] = cursor
|
|
1966
|
-
for key, val in (
|
|
1967
|
-
("assistance_type", assistance_type),
|
|
1968
|
-
("award_key", award_key),
|
|
1969
|
-
("fiscal_year", fiscal_year),
|
|
1970
|
-
("fiscal_year_gte", fiscal_year_gte),
|
|
1971
|
-
("fiscal_year_lte", fiscal_year_lte),
|
|
1972
|
-
("highly_compensated_officers", highly_compensated_officers),
|
|
1973
|
-
("recipient", recipient),
|
|
1974
|
-
("recipient_address", recipient_address),
|
|
1975
|
-
("search", search),
|
|
1976
|
-
):
|
|
1977
|
-
if val is not None:
|
|
1978
|
-
params[key] = val
|
|
1979
|
-
data = self._get("/api/assistance/", params)
|
|
1980
|
-
return PaginatedResponse(
|
|
1981
|
-
count=int(data.get("count") or len(data.get("results") or [])),
|
|
1982
|
-
next=data.get("next"),
|
|
1983
|
-
previous=data.get("previous"),
|
|
1984
|
-
results=data.get("results", []),
|
|
1985
|
-
cursor=data.get("cursor"),
|
|
1986
|
-
page_metadata=data.get("page_metadata"),
|
|
1987
|
-
)
|
|
1988
|
-
|
|
1989
2079
|
# ============================================================================
|
|
1990
2080
|
# Webhooks (v2)
|
|
1991
2081
|
# ============================================================================
|
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
|
|
@@ -433,6 +448,33 @@ class Notice:
|
|
|
433
448
|
naics_code: str | None = None
|
|
434
449
|
|
|
435
450
|
|
|
451
|
+
@dataclass
|
|
452
|
+
class Protest:
|
|
453
|
+
"""Schema definition for Protest (not used for instances)
|
|
454
|
+
|
|
455
|
+
Bid protest records at /api/protests/. Case-level object identified by case_id (UUID).
|
|
456
|
+
See https://tango.makegov.com/docs/api-reference/protests.md.
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
case_id: str
|
|
460
|
+
case_number: str | None = None
|
|
461
|
+
title: str | None = None
|
|
462
|
+
source_system: str | None = None
|
|
463
|
+
outcome: str | None = None
|
|
464
|
+
agency: str | None = None
|
|
465
|
+
protester: str | None = None
|
|
466
|
+
solicitation_number: str | None = None
|
|
467
|
+
case_type: str | None = None
|
|
468
|
+
filed_date: datetime | None = None
|
|
469
|
+
posted_date: datetime | None = None
|
|
470
|
+
decision_date: datetime | None = None
|
|
471
|
+
due_date: datetime | None = None
|
|
472
|
+
docket_url: str | None = None
|
|
473
|
+
decision_url: str | None = None
|
|
474
|
+
digest: str | None = None
|
|
475
|
+
dockets: list[dict[str, Any]] | None = None
|
|
476
|
+
|
|
477
|
+
|
|
436
478
|
@dataclass
|
|
437
479
|
class AssistanceListing:
|
|
438
480
|
"""Schema definition for Assistance Listing (not used for instances)"""
|
|
@@ -575,7 +617,7 @@ class ShapeConfig:
|
|
|
575
617
|
"business_types,primary_naics,naics_codes,psc_codes,"
|
|
576
618
|
"email_address,entity_url,description,capabilities,keywords,"
|
|
577
619
|
"physical_address,mailing_address,"
|
|
578
|
-
"federal_obligations,congressional_district"
|
|
620
|
+
"federal_obligations(*),congressional_district"
|
|
579
621
|
)
|
|
580
622
|
|
|
581
623
|
# Default for list_forecasts()
|
|
@@ -589,6 +631,9 @@ class ShapeConfig:
|
|
|
589
631
|
# Default for list_notices()
|
|
590
632
|
NOTICES_MINIMAL: Final = "notice_id,title,solicitation_number,posted_date"
|
|
591
633
|
|
|
634
|
+
# Default for list_protests()
|
|
635
|
+
PROTESTS_MINIMAL: Final = "case_id,case_number,title,source_system,outcome,filed_date"
|
|
636
|
+
|
|
592
637
|
# Default for list_grants()
|
|
593
638
|
GRANTS_MINIMAL: Final = "grant_id,opportunity_number,title,status(*),agency_code"
|
|
594
639
|
|
tango/shapes/explicit_schemas.py
CHANGED
|
@@ -637,6 +637,57 @@ NOTICE_SCHEMA: dict[str, FieldSchema] = {
|
|
|
637
637
|
}
|
|
638
638
|
|
|
639
639
|
|
|
640
|
+
# Docket-level fields for Protest dockets expansion: dockets(docket_number, filed_date, ...)
|
|
641
|
+
PROTEST_DOCKET_SCHEMA: dict[str, FieldSchema] = {
|
|
642
|
+
"source_system": FieldSchema(name="source_system", type=str, is_optional=True, is_list=False),
|
|
643
|
+
"case_number": FieldSchema(name="case_number", type=str, is_optional=True, is_list=False),
|
|
644
|
+
"docket_number": FieldSchema(name="docket_number", type=str, is_optional=True, is_list=False),
|
|
645
|
+
"title": FieldSchema(name="title", type=str, is_optional=True, is_list=False),
|
|
646
|
+
"protester": FieldSchema(name="protester", type=str, is_optional=True, is_list=False),
|
|
647
|
+
"agency": FieldSchema(name="agency", type=str, is_optional=True, is_list=False),
|
|
648
|
+
"solicitation_number": FieldSchema(
|
|
649
|
+
name="solicitation_number", type=str, is_optional=True, is_list=False
|
|
650
|
+
),
|
|
651
|
+
"case_type": FieldSchema(name="case_type", type=str, is_optional=True, is_list=False),
|
|
652
|
+
"outcome": FieldSchema(name="outcome", type=str, is_optional=True, is_list=False),
|
|
653
|
+
"filed_date": FieldSchema(name="filed_date", type=datetime, is_optional=True, is_list=False),
|
|
654
|
+
"posted_date": FieldSchema(name="posted_date", type=datetime, is_optional=True, is_list=False),
|
|
655
|
+
"decision_date": FieldSchema(
|
|
656
|
+
name="decision_date", type=datetime, is_optional=True, is_list=False
|
|
657
|
+
),
|
|
658
|
+
"due_date": FieldSchema(name="due_date", type=datetime, is_optional=True, is_list=False),
|
|
659
|
+
"docket_url": FieldSchema(name="docket_url", type=str, is_optional=True, is_list=False),
|
|
660
|
+
"decision_url": FieldSchema(name="decision_url", type=str, is_optional=True, is_list=False),
|
|
661
|
+
"digest": FieldSchema(name="digest", type=str, is_optional=True, is_list=False),
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
PROTEST_SCHEMA: dict[str, FieldSchema] = {
|
|
665
|
+
"case_id": FieldSchema(name="case_id", type=str, is_optional=False, is_list=False),
|
|
666
|
+
"case_number": FieldSchema(name="case_number", type=str, is_optional=True, is_list=False),
|
|
667
|
+
"title": FieldSchema(name="title", type=str, is_optional=True, is_list=False),
|
|
668
|
+
"source_system": FieldSchema(name="source_system", type=str, is_optional=True, is_list=False),
|
|
669
|
+
"outcome": FieldSchema(name="outcome", type=str, is_optional=True, is_list=False),
|
|
670
|
+
"agency": FieldSchema(name="agency", type=str, is_optional=True, is_list=False),
|
|
671
|
+
"protester": FieldSchema(name="protester", type=str, is_optional=True, is_list=False),
|
|
672
|
+
"solicitation_number": FieldSchema(
|
|
673
|
+
name="solicitation_number", type=str, is_optional=True, is_list=False
|
|
674
|
+
),
|
|
675
|
+
"case_type": FieldSchema(name="case_type", type=str, is_optional=True, is_list=False),
|
|
676
|
+
"filed_date": FieldSchema(name="filed_date", type=datetime, is_optional=True, is_list=False),
|
|
677
|
+
"posted_date": FieldSchema(name="posted_date", type=datetime, is_optional=True, is_list=False),
|
|
678
|
+
"decision_date": FieldSchema(
|
|
679
|
+
name="decision_date", type=datetime, is_optional=True, is_list=False
|
|
680
|
+
),
|
|
681
|
+
"due_date": FieldSchema(name="due_date", type=datetime, is_optional=True, is_list=False),
|
|
682
|
+
"docket_url": FieldSchema(name="docket_url", type=str, is_optional=True, is_list=False),
|
|
683
|
+
"decision_url": FieldSchema(name="decision_url", type=str, is_optional=True, is_list=False),
|
|
684
|
+
"digest": FieldSchema(name="digest", type=str, is_optional=True, is_list=False),
|
|
685
|
+
"dockets": FieldSchema(
|
|
686
|
+
name="dockets", type=dict, is_optional=True, is_list=True, nested_model="ProtestDocket"
|
|
687
|
+
),
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
|
|
640
691
|
AGENCY_SCHEMA: dict[str, FieldSchema] = {
|
|
641
692
|
"abbreviation": FieldSchema(name="abbreviation", type=str, is_optional=True, is_list=False),
|
|
642
693
|
"code": FieldSchema(name="code", type=str, is_optional=False, is_list=False),
|
|
@@ -1105,6 +1156,8 @@ EXPLICIT_SCHEMAS: dict[str, dict[str, FieldSchema]] = {
|
|
|
1105
1156
|
"Forecast": FORECAST_SCHEMA,
|
|
1106
1157
|
"Opportunity": OPPORTUNITY_SCHEMA,
|
|
1107
1158
|
"Notice": NOTICE_SCHEMA,
|
|
1159
|
+
"Protest": PROTEST_SCHEMA,
|
|
1160
|
+
"ProtestDocket": PROTEST_DOCKET_SCHEMA,
|
|
1108
1161
|
"Agency": AGENCY_SCHEMA,
|
|
1109
1162
|
"Grant": GRANT_SCHEMA,
|
|
1110
1163
|
# Vehicles (Awards)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tango-python
|
|
3
|
-
Version: 0.4.
|
|
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
|
|
@@ -61,7 +61,7 @@ A modern Python SDK for the [Tango API](https://tango.makegov.com) by MakeGov, f
|
|
|
61
61
|
|
|
62
62
|
- **Dynamic Response Shaping** - Request only the fields you need, reducing payload sizes by 60-80%
|
|
63
63
|
- **Full Type Safety** - Runtime-generated TypedDict types with accurate type hints for IDE autocomplete
|
|
64
|
-
- **Comprehensive API Coverage** - All major Tango API endpoints (contracts, entities, forecasts, opportunities, notices, grants, webhooks
|
|
64
|
+
- **Comprehensive API Coverage** - All major Tango API endpoints (contracts, IDVs, OTAs, entities, forecasts, opportunities, notices, grants, protests, webhooks, and more)
|
|
65
65
|
- **Flexible Data Access** - Dictionary-based response objects with validation
|
|
66
66
|
- **Modern Python** - Built for Python 3.12+ using modern async-ready patterns
|
|
67
67
|
- **Production-Ready** - Comprehensive test suite with VCR.py-based integration tests
|
|
@@ -207,14 +207,33 @@ contracts = client.list_contracts(
|
|
|
207
207
|
**Response Options:**
|
|
208
208
|
- `shape`, `flat`, `flat_lists` - Response shaping options
|
|
209
209
|
|
|
210
|
+
### IDVs, OTAs, OTIDVs
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
# List IDVs (keyset pagination)
|
|
214
|
+
idvs = client.list_idvs(limit=25, awarding_agency="4700")
|
|
215
|
+
|
|
216
|
+
# Get single IDV with shaping
|
|
217
|
+
idv = client.get_idv("IDV_KEY", shape=ShapeConfig.IDVS_COMPREHENSIVE)
|
|
218
|
+
|
|
219
|
+
# OTAs and OTIDVs follow the same pattern
|
|
220
|
+
otas = client.list_otas(limit=25)
|
|
221
|
+
otidvs = client.list_otidvs(limit=25)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Vehicles
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
vehicles = client.list_vehicles(search="GSA schedule", shape=ShapeConfig.VEHICLES_MINIMAL)
|
|
228
|
+
vehicle = client.get_vehicle("UUID", shape=ShapeConfig.VEHICLES_COMPREHENSIVE)
|
|
229
|
+
awardees = client.list_vehicle_awardees("UUID")
|
|
230
|
+
```
|
|
231
|
+
|
|
210
232
|
### Entities (Vendors/Recipients)
|
|
211
233
|
|
|
212
234
|
```python
|
|
213
|
-
# List entities
|
|
214
|
-
entities = client.list_entities(
|
|
215
|
-
page=1,
|
|
216
|
-
limit=25
|
|
217
|
-
)
|
|
235
|
+
# List entities with filters
|
|
236
|
+
entities = client.list_entities(search="Booz Allen", state="VA", limit=25)
|
|
218
237
|
|
|
219
238
|
# Get specific entity by UEI or CAGE code
|
|
220
239
|
entity = client.get_entity("ZQGGHJH74DW7")
|
|
@@ -223,47 +242,49 @@ entity = client.get_entity("ZQGGHJH74DW7")
|
|
|
223
242
|
### Forecasts
|
|
224
243
|
|
|
225
244
|
```python
|
|
226
|
-
|
|
227
|
-
forecasts = client.list_forecasts(
|
|
228
|
-
agency="GSA",
|
|
229
|
-
limit=25
|
|
230
|
-
)
|
|
245
|
+
forecasts = client.list_forecasts(agency="GSA", fiscal_year=2025, limit=25)
|
|
231
246
|
```
|
|
232
247
|
|
|
233
248
|
### Opportunities
|
|
234
249
|
|
|
235
250
|
```python
|
|
236
|
-
|
|
237
|
-
opportunities = client.list_opportunities(
|
|
238
|
-
agency="DOD",
|
|
239
|
-
limit=25
|
|
240
|
-
)
|
|
251
|
+
opportunities = client.list_opportunities(agency="DOD", active=True, limit=25)
|
|
241
252
|
```
|
|
242
253
|
|
|
243
254
|
### Notices
|
|
244
255
|
|
|
245
256
|
```python
|
|
246
|
-
|
|
247
|
-
notices = client.list_notices(
|
|
248
|
-
agency="DOD",
|
|
249
|
-
limit=25
|
|
250
|
-
)
|
|
257
|
+
notices = client.list_notices(agency="DOD", notice_type="award", limit=25)
|
|
251
258
|
```
|
|
252
259
|
|
|
253
260
|
### Grants
|
|
254
261
|
|
|
255
262
|
```python
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
263
|
+
grants = client.list_grants(agency="HHS", status="forecasted", limit=25)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Protests
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
protests = client.list_protests(source_system="gao", outcome="Sustained", limit=25)
|
|
270
|
+
protest = client.get_protest("CASE_UUID")
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### GSA eLibrary Contracts
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
contracts = client.list_gsa_elibrary_contracts(schedule="MAS", limit=25)
|
|
277
|
+
contract = client.get_gsa_elibrary_contract("UUID")
|
|
261
278
|
```
|
|
262
279
|
|
|
263
|
-
###
|
|
280
|
+
### Reference Data
|
|
264
281
|
|
|
265
282
|
```python
|
|
266
|
-
#
|
|
283
|
+
# Offices, organizations, NAICS, subawards, business types
|
|
284
|
+
offices = client.list_offices(search="acquisitions")
|
|
285
|
+
organizations = client.list_organizations(level=1)
|
|
286
|
+
naics = client.list_naics(search="software")
|
|
287
|
+
subawards = client.list_subawards(prime_uei="UEI123")
|
|
267
288
|
business_types = client.list_business_types()
|
|
268
289
|
```
|
|
269
290
|
|
|
@@ -476,9 +497,16 @@ tango-python/
|
|
|
476
497
|
│ ├── test_entities_integration.py
|
|
477
498
|
│ ├── test_forecasts_integration.py
|
|
478
499
|
│ ├── test_grants_integration.py
|
|
500
|
+
│ ├── test_naics_integration.py
|
|
479
501
|
│ ├── test_notices_integration.py
|
|
502
|
+
│ ├── test_offices_integration.py
|
|
480
503
|
│ ├── test_opportunities_integration.py
|
|
504
|
+
│ ├── test_organizations_integration.py
|
|
505
|
+
│ ├── test_otas_otidvs_integration.py
|
|
506
|
+
│ ├── test_protests_integration.py
|
|
481
507
|
│ ├── test_reference_data_integration.py
|
|
508
|
+
│ ├── test_subawards_integration.py
|
|
509
|
+
│ ├── test_vehicles_idvs_integration.py
|
|
482
510
|
│ └── test_edge_cases_integration.py
|
|
483
511
|
├── docs/ # Documentation
|
|
484
512
|
│ ├── API_REFERENCE.md # Complete API reference
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
tango/shapes/__init__.py,sha256=7ea1WU74jp4znhNw-gXruag6m6eyPZtbVgbDFmFUWro,1072
|
|
6
|
+
tango/shapes/explicit_schemas.py,sha256=H4pYs0LCTSV5msRCxftmgiM_-3sc4LsqpDPgj36DkPY,55202
|
|
7
|
+
tango/shapes/factory.py,sha256=ytpMi5Uw72XZ8MimhuSsLDVXF3zO_Zt3_tAL6NF7LnU,34318
|
|
8
|
+
tango/shapes/generator.py,sha256=61V1T3lm8Ps_KSMJAezQJLQVFbNKt1jtoLyhiqNtFTs,23380
|
|
9
|
+
tango/shapes/models.py,sha256=h3pIhOqrrdlN953Y6r0oney5HFbKPOD-frRndRWimJ0,3018
|
|
10
|
+
tango/shapes/parser.py,sha256=k6OsI2w3GH6-IBbc-XTLgL1mWH7bMf7A_dA6pr1xKfw,24619
|
|
11
|
+
tango/shapes/schema.py,sha256=VRPOB1sBdjFyimNchrZKIpTHn83CyX4RfU9077aQtIU,14136
|
|
12
|
+
tango/shapes/types.py,sha256=27jrAE0VIdrKaLjR_FK71hfIIGX2Tg3ex7REEBV1TFE,1301
|
|
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,,
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
tango/__init__.py,sha256=7EdEUgCcK0lDNJWWDgdSWxx8xxryO-fHL-iml9PaYLw,1102
|
|
2
|
-
tango/client.py,sha256=ykeTSh4rI4P3LZFALLfGkl2Yr6jM7NWrq_E4V5vY0nc,84112
|
|
3
|
-
tango/exceptions.py,sha256=JmtbOY0ofBnX24pUErh2XFlTj9dim2ngyboserEGRFw,2226
|
|
4
|
-
tango/models.py,sha256=E5jbcQ1CHevTJBD1KOREAqql9bTkft91WaNsL8qbnuA,19575
|
|
5
|
-
tango/shapes/__init__.py,sha256=7ea1WU74jp4znhNw-gXruag6m6eyPZtbVgbDFmFUWro,1072
|
|
6
|
-
tango/shapes/explicit_schemas.py,sha256=iKvmpmeFU8pr4PpTenlpXDKxNbLkuyfUdDc8IV7p4zk,51734
|
|
7
|
-
tango/shapes/factory.py,sha256=ytpMi5Uw72XZ8MimhuSsLDVXF3zO_Zt3_tAL6NF7LnU,34318
|
|
8
|
-
tango/shapes/generator.py,sha256=61V1T3lm8Ps_KSMJAezQJLQVFbNKt1jtoLyhiqNtFTs,23380
|
|
9
|
-
tango/shapes/models.py,sha256=h3pIhOqrrdlN953Y6r0oney5HFbKPOD-frRndRWimJ0,3018
|
|
10
|
-
tango/shapes/parser.py,sha256=k6OsI2w3GH6-IBbc-XTLgL1mWH7bMf7A_dA6pr1xKfw,24619
|
|
11
|
-
tango/shapes/schema.py,sha256=VRPOB1sBdjFyimNchrZKIpTHn83CyX4RfU9077aQtIU,14136
|
|
12
|
-
tango/shapes/types.py,sha256=27jrAE0VIdrKaLjR_FK71hfIIGX2Tg3ex7REEBV1TFE,1301
|
|
13
|
-
tango_python-0.4.1.dist-info/METADATA,sha256=QTa_gFDGAc9jj23jv35_0Zy9Js3bXlzK6FV-9OnjLFg,16210
|
|
14
|
-
tango_python-0.4.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
15
|
-
tango_python-0.4.1.dist-info/licenses/LICENSE,sha256=j2kYVHMwTkoGn3ZNScnrdIueG0k2XzB_LCPFoyBc2wk,1064
|
|
16
|
-
tango_python-0.4.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|