usaspending-orm 0.6.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.
- usaspending/__init__.py +144 -0
- usaspending/cli/__init__.py +2 -0
- usaspending/cli/download_award.py +113 -0
- usaspending/client.py +550 -0
- usaspending/config.py +119 -0
- usaspending/download/__init__.py +8 -0
- usaspending/download/job.py +219 -0
- usaspending/download/manager.py +140 -0
- usaspending/exceptions.py +71 -0
- usaspending/logging_config.py +310 -0
- usaspending/models/__init__.py +86 -0
- usaspending/models/agency.py +631 -0
- usaspending/models/award.py +933 -0
- usaspending/models/award_factory.py +72 -0
- usaspending/models/award_types.py +114 -0
- usaspending/models/base_model.py +214 -0
- usaspending/models/contract.py +147 -0
- usaspending/models/district_spending.py +70 -0
- usaspending/models/download.py +74 -0
- usaspending/models/funding.py +182 -0
- usaspending/models/grant.py +147 -0
- usaspending/models/idv.py +165 -0
- usaspending/models/lazy_record.py +82 -0
- usaspending/models/loan.py +85 -0
- usaspending/models/location.py +125 -0
- usaspending/models/period_of_performance.py +43 -0
- usaspending/models/recipient.py +204 -0
- usaspending/models/recipient_business_categories.py +364 -0
- usaspending/models/recipient_spending.py +58 -0
- usaspending/models/spending.py +70 -0
- usaspending/models/state_spending.py +43 -0
- usaspending/models/subaward.py +270 -0
- usaspending/models/subtier_agency.py +98 -0
- usaspending/models/transaction.py +89 -0
- usaspending/queries/__init__.py +87 -0
- usaspending/queries/agencies_search.py +196 -0
- usaspending/queries/agency_award_summary.py +134 -0
- usaspending/queries/agency_query.py +102 -0
- usaspending/queries/award_query.py +40 -0
- usaspending/queries/awarding_agencies_search.py +16 -0
- usaspending/queries/awards_search.py +602 -0
- usaspending/queries/filters.py +437 -0
- usaspending/queries/funding_agencies_search.py +20 -0
- usaspending/queries/funding_search.py +180 -0
- usaspending/queries/query_builder.py +1430 -0
- usaspending/queries/recipient_query.py +66 -0
- usaspending/queries/recipients_search.py +167 -0
- usaspending/queries/single_resource_base.py +83 -0
- usaspending/queries/spending_search.py +261 -0
- usaspending/queries/sub_agency_query.py +177 -0
- usaspending/queries/subawards_search.py +196 -0
- usaspending/queries/transactions_search.py +213 -0
- usaspending/resources/__init__.py +17 -0
- usaspending/resources/agency_resource.py +105 -0
- usaspending/resources/award_resource.py +119 -0
- usaspending/resources/base_resource.py +31 -0
- usaspending/resources/download_resource.py +102 -0
- usaspending/resources/funding_resource.py +41 -0
- usaspending/resources/recipients_resource.py +104 -0
- usaspending/resources/spending_resource.py +45 -0
- usaspending/resources/subawards_resource.py +68 -0
- usaspending/resources/transactions_resource.py +39 -0
- usaspending/utils/__init__.py +6 -0
- usaspending/utils/formatter.py +550 -0
- usaspending/utils/rate_limit.py +135 -0
- usaspending/utils/retry.py +257 -0
- usaspending/utils/special_cases.yaml +155 -0
- usaspending_orm-0.6.0.dist-info/LICENSE +18 -0
- usaspending_orm-0.6.0.dist-info/METADATA +324 -0
- usaspending_orm-0.6.0.dist-info/RECORD +71 -0
- usaspending_orm-0.6.0.dist-info/WHEEL +4 -0
usaspending/client.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"""Main USASpending client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import time
|
|
5
|
+
from typing import Optional, Dict, Any, TYPE_CHECKING
|
|
6
|
+
from urllib.parse import urljoin
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
import cachier
|
|
10
|
+
|
|
11
|
+
from .config import config
|
|
12
|
+
from .exceptions import HTTPError, APIError, ValidationError, RateLimitError
|
|
13
|
+
from .logging_config import USASpendingLogger, log_api_request, log_api_response
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .resources.base_resource import BaseResource
|
|
17
|
+
from .resources.award_resource import AwardResource
|
|
18
|
+
from .resources.transactions_resource import TransactionsResource
|
|
19
|
+
from .resources.recipients_resource import RecipientsResource
|
|
20
|
+
from .resources.spending_resource import SpendingResource
|
|
21
|
+
from .resources.funding_resource import FundingResource
|
|
22
|
+
from .resources.download_resource import DownloadResource
|
|
23
|
+
from .resources.subawards_resource import SubAwardsResource
|
|
24
|
+
from .resources.agency_resource import AgencyResource
|
|
25
|
+
from .utils.rate_limit import RateLimiter
|
|
26
|
+
from .utils.retry import RetryHandler
|
|
27
|
+
|
|
28
|
+
logger = USASpendingLogger.get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class USASpendingClient:
|
|
32
|
+
"""Main client for USASpending API.
|
|
33
|
+
|
|
34
|
+
This client provides a centralized interface to the USASpending.gov API
|
|
35
|
+
with automatic retry, rate limiting, and caching capabilities.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
>>> client = USASpendingClient()
|
|
39
|
+
>>> awards = client.awards.search().agency("NASA").limit(10)
|
|
40
|
+
>>> for award in awards:
|
|
41
|
+
... print(f"{award.recipient_name}: ${award.amount:,.2f}")
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self):
|
|
45
|
+
"""Initialize USASpendingClient."""
|
|
46
|
+
|
|
47
|
+
logger.debug(f"Initializing USASpendingClient with base URL: {config.base_url}")
|
|
48
|
+
|
|
49
|
+
# Initialize HTTP session
|
|
50
|
+
self._session = self._create_session()
|
|
51
|
+
self._closed = False
|
|
52
|
+
self._request_count = 0
|
|
53
|
+
|
|
54
|
+
# Lazy-loaded components
|
|
55
|
+
self._rate_limiter: Optional[RateLimiter] = None
|
|
56
|
+
self._retry_handler: Optional[RetryHandler] = None
|
|
57
|
+
|
|
58
|
+
# Resource cache
|
|
59
|
+
self._resources: Dict[str, BaseResource] = {}
|
|
60
|
+
|
|
61
|
+
logger.debug("USASpending client initialized successfully")
|
|
62
|
+
|
|
63
|
+
def _create_session(self) -> requests.Session:
|
|
64
|
+
"""Create configured requests session."""
|
|
65
|
+
session = requests.Session()
|
|
66
|
+
session.headers.update(
|
|
67
|
+
{
|
|
68
|
+
"User-Agent": config.user_agent,
|
|
69
|
+
"Accept": "application/json",
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
return session
|
|
74
|
+
|
|
75
|
+
def _format_error_with_context(self, error_msg: str) -> str:
|
|
76
|
+
"""Format error message with request count and session limit context."""
|
|
77
|
+
context = f"(Request #{self._request_count} in session"
|
|
78
|
+
|
|
79
|
+
# Add warning if approaching session limit
|
|
80
|
+
remaining = config.session_request_limit - self._request_count
|
|
81
|
+
if remaining <= 10:
|
|
82
|
+
context += f", {remaining} remaining before reset"
|
|
83
|
+
|
|
84
|
+
context += ")"
|
|
85
|
+
return f"{error_msg} {context}"
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def rate_limiter(self) -> RateLimiter:
|
|
89
|
+
"""Get rate limiter (lazy-loaded)."""
|
|
90
|
+
if self._rate_limiter is None:
|
|
91
|
+
from .utils.rate_limit import RateLimiter
|
|
92
|
+
|
|
93
|
+
self._rate_limiter = RateLimiter(
|
|
94
|
+
config.rate_limit_calls, config.rate_limit_period
|
|
95
|
+
)
|
|
96
|
+
return self._rate_limiter
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def retry_handler(self) -> RetryHandler:
|
|
100
|
+
"""Get retry handler (lazy-loaded)."""
|
|
101
|
+
if self._retry_handler is None:
|
|
102
|
+
from .utils.retry import RetryHandler
|
|
103
|
+
|
|
104
|
+
self._retry_handler = RetryHandler(
|
|
105
|
+
session_reset_callback=self.reset_session
|
|
106
|
+
)
|
|
107
|
+
return self._retry_handler
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def awards(self) -> "AwardResource":
|
|
111
|
+
"""Access award endpoints."""
|
|
112
|
+
if "awards" not in self._resources:
|
|
113
|
+
from .resources.award_resource import AwardResource
|
|
114
|
+
|
|
115
|
+
self._resources["awards"] = AwardResource(self)
|
|
116
|
+
return self._resources["awards"]
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def downloads(self) -> "DownloadResource":
|
|
120
|
+
"""
|
|
121
|
+
Access download operations for detailed award data.
|
|
122
|
+
|
|
123
|
+
Allows queuing, monitoring, and retrieval of bulk award files.
|
|
124
|
+
"""
|
|
125
|
+
if "downloads" not in self._resources:
|
|
126
|
+
from .resources.download_resource import DownloadResource
|
|
127
|
+
|
|
128
|
+
self._resources["downloads"] = DownloadResource(self)
|
|
129
|
+
return self._resources["downloads"]
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def recipients(self) -> "RecipientsResource":
|
|
133
|
+
"""Access recipient endpoints."""
|
|
134
|
+
if "recipients" not in self._resources:
|
|
135
|
+
from .resources.recipients_resource import RecipientsResource
|
|
136
|
+
|
|
137
|
+
self._resources["recipients"] = RecipientsResource(self)
|
|
138
|
+
return self._resources["recipients"]
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def transactions(self) -> "TransactionsResource":
|
|
142
|
+
"""Access transaction endpoints."""
|
|
143
|
+
if "transactions" not in self._resources:
|
|
144
|
+
from .resources.transactions_resource import TransactionsResource
|
|
145
|
+
|
|
146
|
+
self._resources["transactions"] = TransactionsResource(self)
|
|
147
|
+
return self._resources["transactions"]
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def spending(self) -> "SpendingResource":
|
|
151
|
+
"""Access spending by category endpoints."""
|
|
152
|
+
if "spending" not in self._resources:
|
|
153
|
+
from .resources.spending_resource import SpendingResource
|
|
154
|
+
|
|
155
|
+
self._resources["spending"] = SpendingResource(self)
|
|
156
|
+
return self._resources["spending"]
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def funding(self) -> "FundingResource":
|
|
160
|
+
"""Access funding endpoints."""
|
|
161
|
+
if "funding" not in self._resources:
|
|
162
|
+
from .resources.funding_resource import FundingResource
|
|
163
|
+
|
|
164
|
+
self._resources["funding"] = FundingResource(self)
|
|
165
|
+
return self._resources["funding"]
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def subawards(self) -> "SubAwardsResource":
|
|
169
|
+
"""Access subaward endpoints."""
|
|
170
|
+
if "subawards" not in self._resources:
|
|
171
|
+
from .resources.subawards_resource import SubAwardsResource
|
|
172
|
+
|
|
173
|
+
self._resources["subawards"] = SubAwardsResource(self)
|
|
174
|
+
return self._resources["subawards"]
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def agencies(self) -> "AgencyResource":
|
|
178
|
+
"""Access agency endpoints."""
|
|
179
|
+
if "agencies" not in self._resources:
|
|
180
|
+
from .resources.agency_resource import AgencyResource
|
|
181
|
+
|
|
182
|
+
self._resources["agencies"] = AgencyResource(self)
|
|
183
|
+
return self._resources["agencies"]
|
|
184
|
+
|
|
185
|
+
def _make_request(
|
|
186
|
+
self,
|
|
187
|
+
method: str,
|
|
188
|
+
endpoint: str,
|
|
189
|
+
params: Optional[Dict[str, Any]] = None,
|
|
190
|
+
json: Optional[Dict[str, Any]] = None,
|
|
191
|
+
**kwargs,
|
|
192
|
+
) -> Dict[str, Any]:
|
|
193
|
+
"""A cacheable HTTP request method with fallback for cache failures."""
|
|
194
|
+
try:
|
|
195
|
+
# Try cached version first if caching is enabled
|
|
196
|
+
if config.cache_enabled:
|
|
197
|
+
cached_result = self._make_cached_request(
|
|
198
|
+
method, endpoint, params=params, json=json, **kwargs
|
|
199
|
+
)
|
|
200
|
+
# Validate that cache returned proper data
|
|
201
|
+
if cached_result is not None and isinstance(cached_result, dict):
|
|
202
|
+
logger.debug(f"Cache hit for {method} {endpoint}")
|
|
203
|
+
return cached_result
|
|
204
|
+
else:
|
|
205
|
+
logger.warning(
|
|
206
|
+
f"Cache returned invalid data for {method} {endpoint}, falling back to uncached request"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Fallback to uncached request if cache disabled or returned invalid data
|
|
210
|
+
return self._make_uncached_request(
|
|
211
|
+
method, endpoint, params=params, json=json, **kwargs
|
|
212
|
+
)
|
|
213
|
+
except (APIError, HTTPError, ValidationError, RateLimitError):
|
|
214
|
+
# These are normal API errors - let them propagate without fallback
|
|
215
|
+
# Cachier will re-execute on next call (doesn't cache exceptions)
|
|
216
|
+
raise
|
|
217
|
+
except Exception as cache_error:
|
|
218
|
+
# Only catch serious cache failures (file corruption, lock issues, etc.)
|
|
219
|
+
logger.warning(
|
|
220
|
+
f"Cache operation failed for {method} {endpoint}: {cache_error}. Using uncached request."
|
|
221
|
+
)
|
|
222
|
+
return self._make_uncached_request(
|
|
223
|
+
method, endpoint, params=params, json=json, **kwargs
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
@cachier.cachier(wait_for_calc_timeout=config.cache_timeout)
|
|
227
|
+
def _make_cached_request(
|
|
228
|
+
self,
|
|
229
|
+
method: str,
|
|
230
|
+
endpoint: str,
|
|
231
|
+
params: Optional[Dict[str, Any]] = None,
|
|
232
|
+
json: Optional[Dict[str, Any]] = None,
|
|
233
|
+
**kwargs,
|
|
234
|
+
) -> Dict[str, Any]:
|
|
235
|
+
"""Internal cached request method."""
|
|
236
|
+
return self._make_uncached_request(
|
|
237
|
+
method, endpoint, params=params, json=json, **kwargs
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _make_uncached_request(
|
|
241
|
+
self,
|
|
242
|
+
method: str,
|
|
243
|
+
endpoint: str,
|
|
244
|
+
params: Optional[Dict[str, Any]] = None,
|
|
245
|
+
json: Optional[Dict[str, Any]] = None,
|
|
246
|
+
**kwargs,
|
|
247
|
+
) -> Dict[str, Any]:
|
|
248
|
+
"""Make HTTP request with retry and rate limiting.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
method: HTTP method (GET, POST, etc.)
|
|
252
|
+
endpoint: API endpoint (without base URL)
|
|
253
|
+
params: Query parameters
|
|
254
|
+
json: JSON body for POST requests
|
|
255
|
+
**kwargs: Additional arguments for requests
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Response data as dictionary
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
HTTPError: For HTTP errors
|
|
262
|
+
APIError: For API-reported errors
|
|
263
|
+
RateLimitError: When rate limited
|
|
264
|
+
"""
|
|
265
|
+
# Apply rate limiting
|
|
266
|
+
self.rate_limiter.wait_if_needed()
|
|
267
|
+
|
|
268
|
+
# Increment request counter and check for proactive session reset
|
|
269
|
+
self._request_count += 1
|
|
270
|
+
if self._request_count >= config.session_request_limit:
|
|
271
|
+
logger.info(
|
|
272
|
+
f"Proactively resetting session after {self._request_count} requests"
|
|
273
|
+
)
|
|
274
|
+
self.reset_session()
|
|
275
|
+
|
|
276
|
+
# Build full URL
|
|
277
|
+
url = urljoin(config.base_url, endpoint.lstrip("/"))
|
|
278
|
+
|
|
279
|
+
# Log API request
|
|
280
|
+
log_api_request(logger, method, url, params, json)
|
|
281
|
+
|
|
282
|
+
# Prepare request
|
|
283
|
+
request_kwargs = {
|
|
284
|
+
"method": method,
|
|
285
|
+
"url": url,
|
|
286
|
+
"params": params,
|
|
287
|
+
"json": json,
|
|
288
|
+
"timeout": config.timeout,
|
|
289
|
+
**kwargs,
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
# Track request timing
|
|
293
|
+
start_time = time.time()
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
# Make request with retry
|
|
297
|
+
response = self.retry_handler.execute(
|
|
298
|
+
self._session.request, **request_kwargs
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Calculate duration
|
|
302
|
+
duration = time.time() - start_time
|
|
303
|
+
|
|
304
|
+
# Handle specific 400 Bad Request responses first
|
|
305
|
+
if response.status_code == 400:
|
|
306
|
+
try:
|
|
307
|
+
data = response.json()
|
|
308
|
+
# Use "detail" property if available, otherwise fall back to generic message
|
|
309
|
+
error_msg = (
|
|
310
|
+
data.get("detail")
|
|
311
|
+
or data.get("error")
|
|
312
|
+
or data.get("message")
|
|
313
|
+
or "Bad Request"
|
|
314
|
+
)
|
|
315
|
+
error_msg_with_context = self._format_error_with_context(error_msg)
|
|
316
|
+
log_api_response(
|
|
317
|
+
logger,
|
|
318
|
+
response.status_code,
|
|
319
|
+
len(response.content) if response.content else None,
|
|
320
|
+
duration,
|
|
321
|
+
error_msg_with_context,
|
|
322
|
+
)
|
|
323
|
+
raise APIError(error_msg, status_code=400, response_body=data)
|
|
324
|
+
except ValueError:
|
|
325
|
+
# If JSON parsing fails, use generic 400 error
|
|
326
|
+
error_msg = "Bad Request - Invalid JSON response"
|
|
327
|
+
error_msg_with_context = self._format_error_with_context(error_msg)
|
|
328
|
+
log_api_response(
|
|
329
|
+
logger,
|
|
330
|
+
response.status_code,
|
|
331
|
+
len(response.content) if response.content else None,
|
|
332
|
+
duration,
|
|
333
|
+
error_msg_with_context,
|
|
334
|
+
)
|
|
335
|
+
raise APIError(error_msg, status_code=400)
|
|
336
|
+
|
|
337
|
+
# Handle other HTTP errors
|
|
338
|
+
try:
|
|
339
|
+
response.raise_for_status()
|
|
340
|
+
except requests.HTTPError as e:
|
|
341
|
+
error_msg_with_context = self._format_error_with_context(str(e))
|
|
342
|
+
log_api_response(
|
|
343
|
+
logger,
|
|
344
|
+
response.status_code,
|
|
345
|
+
len(response.content) if response.content else None,
|
|
346
|
+
duration,
|
|
347
|
+
error_msg_with_context,
|
|
348
|
+
)
|
|
349
|
+
raise HTTPError(
|
|
350
|
+
f"HTTP {response.status_code}: {e}",
|
|
351
|
+
status_code=response.status_code,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Parse JSON response
|
|
355
|
+
try:
|
|
356
|
+
data = response.json()
|
|
357
|
+
except ValueError as e:
|
|
358
|
+
error_msg = f"Invalid JSON: {e}"
|
|
359
|
+
error_msg_with_context = self._format_error_with_context(error_msg)
|
|
360
|
+
log_api_response(
|
|
361
|
+
logger,
|
|
362
|
+
response.status_code,
|
|
363
|
+
len(response.content) if response.content else None,
|
|
364
|
+
duration,
|
|
365
|
+
error_msg_with_context,
|
|
366
|
+
)
|
|
367
|
+
raise APIError(f"Invalid JSON response: {e}")
|
|
368
|
+
|
|
369
|
+
# Check for API errors (fallback for other error patterns)
|
|
370
|
+
# Note: Don't treat "message" alone as an error indicator since some endpoints
|
|
371
|
+
# (like download/status) include message as a normal response field
|
|
372
|
+
if "error" in data:
|
|
373
|
+
error_msg = (
|
|
374
|
+
data.get("error") or data.get("message") or "Unknown API error"
|
|
375
|
+
)
|
|
376
|
+
error_msg_with_context = self._format_error_with_context(error_msg)
|
|
377
|
+
log_api_response(
|
|
378
|
+
logger,
|
|
379
|
+
response.status_code,
|
|
380
|
+
len(response.content) if response.content else None,
|
|
381
|
+
duration,
|
|
382
|
+
error_msg_with_context,
|
|
383
|
+
)
|
|
384
|
+
raise APIError(
|
|
385
|
+
error_msg, status_code=response.status_code, response_body=data
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Log messages from successful responses (200 status code)
|
|
389
|
+
if response.status_code == 200 and "messages" in data:
|
|
390
|
+
messages = data["messages"]
|
|
391
|
+
if isinstance(messages, list):
|
|
392
|
+
for msg in messages:
|
|
393
|
+
logger.debug(f"API Message: {msg}")
|
|
394
|
+
else:
|
|
395
|
+
logger.debug(f"API Message: {messages}")
|
|
396
|
+
|
|
397
|
+
# Log successful response
|
|
398
|
+
log_api_response(
|
|
399
|
+
logger,
|
|
400
|
+
response.status_code,
|
|
401
|
+
len(response.content) if response.content else None,
|
|
402
|
+
duration,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
return data
|
|
406
|
+
|
|
407
|
+
except Exception as e:
|
|
408
|
+
# Log any unexpected errors
|
|
409
|
+
if "response" in locals():
|
|
410
|
+
error_msg_with_context = self._format_error_with_context(str(e))
|
|
411
|
+
log_api_response(
|
|
412
|
+
logger,
|
|
413
|
+
getattr(response, "status_code", 0),
|
|
414
|
+
None,
|
|
415
|
+
time.time() - start_time,
|
|
416
|
+
error_msg_with_context,
|
|
417
|
+
)
|
|
418
|
+
else:
|
|
419
|
+
error_msg_with_context = self._format_error_with_context(
|
|
420
|
+
f"Request failed before response: {e}"
|
|
421
|
+
)
|
|
422
|
+
logger.error(error_msg_with_context)
|
|
423
|
+
raise
|
|
424
|
+
|
|
425
|
+
def _download_binary_file(self, file_url: str, destination_path: str) -> None:
|
|
426
|
+
"""Download binary file using client session with streaming support.
|
|
427
|
+
|
|
428
|
+
This method is used internally for downloading large binary files
|
|
429
|
+
like the ZIP archives from the download endpoints.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
file_url: Relative or absolute URL to download
|
|
433
|
+
destination_path: Local path where file will be saved
|
|
434
|
+
|
|
435
|
+
Raises:
|
|
436
|
+
DownloadError: If download fails
|
|
437
|
+
"""
|
|
438
|
+
import os
|
|
439
|
+
from .exceptions import DownloadError
|
|
440
|
+
|
|
441
|
+
# Construct full URL
|
|
442
|
+
if file_url.startswith("http"):
|
|
443
|
+
download_url = file_url
|
|
444
|
+
else:
|
|
445
|
+
download_url = urljoin(config.base_url, file_url.lstrip("/"))
|
|
446
|
+
|
|
447
|
+
logger.info(f"Downloading binary file from {download_url}")
|
|
448
|
+
|
|
449
|
+
# Use a longer timeout for file downloads
|
|
450
|
+
timeout = 600 # 10 minutes
|
|
451
|
+
|
|
452
|
+
def download_operation():
|
|
453
|
+
"""Inner function for retry handler."""
|
|
454
|
+
response = self._session.get(download_url, stream=True, timeout=timeout)
|
|
455
|
+
response.raise_for_status()
|
|
456
|
+
|
|
457
|
+
try:
|
|
458
|
+
with open(destination_path, "wb") as f:
|
|
459
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
460
|
+
if chunk:
|
|
461
|
+
f.write(chunk)
|
|
462
|
+
except IOError as e:
|
|
463
|
+
raise DownloadError(
|
|
464
|
+
f"Error writing file to disk: {e}",
|
|
465
|
+
file_name=os.path.basename(destination_path),
|
|
466
|
+
) from e
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
# Execute with retry handling
|
|
470
|
+
self.retry_handler.execute(download_operation)
|
|
471
|
+
logger.info(f"Successfully downloaded to {destination_path}")
|
|
472
|
+
|
|
473
|
+
except Exception as e:
|
|
474
|
+
logger.error(f"Failed to download file: {e}")
|
|
475
|
+
# Clean up partial file if it exists
|
|
476
|
+
if os.path.exists(destination_path):
|
|
477
|
+
try:
|
|
478
|
+
os.remove(destination_path)
|
|
479
|
+
logger.debug(f"Cleaned up partial file: {destination_path}")
|
|
480
|
+
except OSError:
|
|
481
|
+
pass
|
|
482
|
+
|
|
483
|
+
# Re-raise as DownloadError if not already
|
|
484
|
+
if not isinstance(e, DownloadError):
|
|
485
|
+
raise DownloadError(
|
|
486
|
+
f"Failed to download file from {download_url}",
|
|
487
|
+
file_name=os.path.basename(destination_path),
|
|
488
|
+
) from e
|
|
489
|
+
raise
|
|
490
|
+
|
|
491
|
+
def close(self) -> None:
|
|
492
|
+
"""Close client and cleanup resources.
|
|
493
|
+
|
|
494
|
+
This method is idempotent and safe to call multiple times.
|
|
495
|
+
"""
|
|
496
|
+
if not self._closed and self._session:
|
|
497
|
+
self._session.close()
|
|
498
|
+
self._closed = True
|
|
499
|
+
logger.info(
|
|
500
|
+
f"USASpending client closed after {self._request_count} requests"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
def reset_session(self) -> None:
|
|
504
|
+
"""Reset the HTTP session to handle server-side session limits.
|
|
505
|
+
|
|
506
|
+
This creates a new session with fresh connection pools, which can
|
|
507
|
+
resolve issues where the server limits requests per session.
|
|
508
|
+
The request counter is also reset.
|
|
509
|
+
"""
|
|
510
|
+
if not self._closed and self._session:
|
|
511
|
+
old_count = self._request_count
|
|
512
|
+
self._session.close()
|
|
513
|
+
self._session = self._create_session()
|
|
514
|
+
self._request_count = 0
|
|
515
|
+
logger.info(f"Session reset after {old_count} requests")
|
|
516
|
+
|
|
517
|
+
def __enter__(self) -> "USASpendingClient":
|
|
518
|
+
"""Enter context manager.
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
Self for use in with statement
|
|
522
|
+
|
|
523
|
+
Example:
|
|
524
|
+
>>> with USASpendingClient() as client:
|
|
525
|
+
... awards = client.awards.search().all()
|
|
526
|
+
"""
|
|
527
|
+
return self
|
|
528
|
+
|
|
529
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
530
|
+
"""Exit context manager and cleanup resources.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
exc_type: Exception type if an exception occurred
|
|
534
|
+
exc_val: Exception value if an exception occurred
|
|
535
|
+
exc_tb: Exception traceback if an exception occurred
|
|
536
|
+
"""
|
|
537
|
+
self.close()
|
|
538
|
+
|
|
539
|
+
def __del__(self) -> None:
|
|
540
|
+
"""Destructor to cleanup resources if not already closed.
|
|
541
|
+
|
|
542
|
+
Note: It's better to use the context manager or call close() explicitly.
|
|
543
|
+
This is a safety net for cases where proper cleanup wasn't done.
|
|
544
|
+
"""
|
|
545
|
+
if not self._closed and hasattr(self, "_session") and self._session:
|
|
546
|
+
logger.warning(
|
|
547
|
+
f"USASpendingClient session was not explicitly closed after {self._request_count} requests. "
|
|
548
|
+
"Consider using 'with USASpendingClient() as client:' or calling client.close()"
|
|
549
|
+
)
|
|
550
|
+
self.close()
|
usaspending/config.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from usaspending.logging_config import USASpendingLogger
|
|
4
|
+
from usaspending.exceptions import ConfigurationError
|
|
5
|
+
import os
|
|
6
|
+
import cachier
|
|
7
|
+
|
|
8
|
+
logger = USASpendingLogger.get_logger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Config:
|
|
12
|
+
"""
|
|
13
|
+
A container for all library configuration settings.
|
|
14
|
+
Do not instantiate this class directly. Instead, import and use the global `config` object.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
# Default settings are defined here as instance attributes
|
|
19
|
+
self.base_url: str = "https://api.usaspending.gov/api/v2/"
|
|
20
|
+
self.user_agent: str = "usaspending-orm-python/0.6.0"
|
|
21
|
+
self.timeout: int = 30
|
|
22
|
+
self.max_retries: int = 3
|
|
23
|
+
self.retry_delay: float = 10.0
|
|
24
|
+
self.retry_backoff: float = 2.0
|
|
25
|
+
|
|
26
|
+
# Global rate limit is 1000 calls per 300 seconds
|
|
27
|
+
self.rate_limit_calls: int = 1000
|
|
28
|
+
self.rate_limit_period: int = 300
|
|
29
|
+
|
|
30
|
+
# Session management for handling server-side session limits
|
|
31
|
+
self.session_request_limit: int = 250 # Max requests per session before renewal
|
|
32
|
+
self.session_reset_on_5xx_threshold: int = (
|
|
33
|
+
1 # Reset session after N consecutive 5XX errors
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Caching via cachier
|
|
37
|
+
self.cache_enabled: bool = False
|
|
38
|
+
self.cache_backend: str = "file" # Default file-based backend for cachier
|
|
39
|
+
self.cache_dir: str = os.path.join(
|
|
40
|
+
os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")),
|
|
41
|
+
"usaspending",
|
|
42
|
+
)
|
|
43
|
+
self.cache_ttl: timedelta = timedelta(weeks=1)
|
|
44
|
+
self.cache_timeout: int = 60 # Seconds to wait for processing cache entries
|
|
45
|
+
|
|
46
|
+
# Apply the initial default settings when the object is created
|
|
47
|
+
self._apply_cachier_settings()
|
|
48
|
+
|
|
49
|
+
def configure(self, **kwargs):
|
|
50
|
+
"""
|
|
51
|
+
Updates configuration settings and applies them across the library.
|
|
52
|
+
|
|
53
|
+
This is the primary method for users to modify the library's behavior.
|
|
54
|
+
Any keyword argument passed will overwrite the existing configuration value.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
**kwargs: Configuration keys and their new values.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ConfigurationError: If any provided configuration value is invalid.
|
|
61
|
+
"""
|
|
62
|
+
for key, value in kwargs.items():
|
|
63
|
+
if hasattr(self, key):
|
|
64
|
+
if key == "cache_ttl" and isinstance(value, (int, float)):
|
|
65
|
+
self.cache_ttl = timedelta(seconds=value)
|
|
66
|
+
else:
|
|
67
|
+
setattr(self, key, value)
|
|
68
|
+
else:
|
|
69
|
+
logger.warning(
|
|
70
|
+
f"Warning: Unknown configuration key '{key}' was ignored."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
self.validate()
|
|
74
|
+
self._apply_cachier_settings()
|
|
75
|
+
|
|
76
|
+
def _apply_cachier_settings(self):
|
|
77
|
+
"""Applies the current caching settings to the cachier library."""
|
|
78
|
+
if self.cache_enabled:
|
|
79
|
+
if self.cache_backend == "file":
|
|
80
|
+
cache_backend = "pickle" # cachier uses 'pickle' for file caching
|
|
81
|
+
cachier.set_global_params(
|
|
82
|
+
stale_after=self.cache_ttl,
|
|
83
|
+
cache_dir=self.cache_dir,
|
|
84
|
+
backend=cache_backend,
|
|
85
|
+
)
|
|
86
|
+
else: # memory backend
|
|
87
|
+
cachier.set_global_params(
|
|
88
|
+
stale_after=self.cache_ttl,
|
|
89
|
+
backend=self.cache_backend,
|
|
90
|
+
)
|
|
91
|
+
cachier.enable_caching()
|
|
92
|
+
else:
|
|
93
|
+
cachier.disable_caching()
|
|
94
|
+
|
|
95
|
+
def validate(self) -> None:
|
|
96
|
+
"""Validate the current configuration values."""
|
|
97
|
+
if self.timeout <= 0:
|
|
98
|
+
raise ConfigurationError("timeout must be positive")
|
|
99
|
+
if self.max_retries < 0:
|
|
100
|
+
raise ConfigurationError("max_retries must be non-negative")
|
|
101
|
+
if self.rate_limit_calls <= 0:
|
|
102
|
+
raise ConfigurationError("rate_limit_calls must be positive")
|
|
103
|
+
if self.session_request_limit <= 0:
|
|
104
|
+
raise ConfigurationError("session_request_limit must be positive")
|
|
105
|
+
if self.session_reset_on_5xx_threshold < 0:
|
|
106
|
+
raise ConfigurationError(
|
|
107
|
+
"session_reset_on_5xx_threshold must be non-negative"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
valid_backends = {"file", "memory"}
|
|
111
|
+
if self.cache_enabled and (self.cache_backend not in valid_backends):
|
|
112
|
+
raise ConfigurationError(f"cache_backend must be one of: {valid_backends}")
|
|
113
|
+
if self.cache_timeout <= 0:
|
|
114
|
+
raise ConfigurationError("cache_timeout must be positive")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Global configuration object
|
|
118
|
+
# This is the single instance that should be used throughout the library
|
|
119
|
+
config = _Config()
|