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.
Files changed (71) hide show
  1. usaspending/__init__.py +144 -0
  2. usaspending/cli/__init__.py +2 -0
  3. usaspending/cli/download_award.py +113 -0
  4. usaspending/client.py +550 -0
  5. usaspending/config.py +119 -0
  6. usaspending/download/__init__.py +8 -0
  7. usaspending/download/job.py +219 -0
  8. usaspending/download/manager.py +140 -0
  9. usaspending/exceptions.py +71 -0
  10. usaspending/logging_config.py +310 -0
  11. usaspending/models/__init__.py +86 -0
  12. usaspending/models/agency.py +631 -0
  13. usaspending/models/award.py +933 -0
  14. usaspending/models/award_factory.py +72 -0
  15. usaspending/models/award_types.py +114 -0
  16. usaspending/models/base_model.py +214 -0
  17. usaspending/models/contract.py +147 -0
  18. usaspending/models/district_spending.py +70 -0
  19. usaspending/models/download.py +74 -0
  20. usaspending/models/funding.py +182 -0
  21. usaspending/models/grant.py +147 -0
  22. usaspending/models/idv.py +165 -0
  23. usaspending/models/lazy_record.py +82 -0
  24. usaspending/models/loan.py +85 -0
  25. usaspending/models/location.py +125 -0
  26. usaspending/models/period_of_performance.py +43 -0
  27. usaspending/models/recipient.py +204 -0
  28. usaspending/models/recipient_business_categories.py +364 -0
  29. usaspending/models/recipient_spending.py +58 -0
  30. usaspending/models/spending.py +70 -0
  31. usaspending/models/state_spending.py +43 -0
  32. usaspending/models/subaward.py +270 -0
  33. usaspending/models/subtier_agency.py +98 -0
  34. usaspending/models/transaction.py +89 -0
  35. usaspending/queries/__init__.py +87 -0
  36. usaspending/queries/agencies_search.py +196 -0
  37. usaspending/queries/agency_award_summary.py +134 -0
  38. usaspending/queries/agency_query.py +102 -0
  39. usaspending/queries/award_query.py +40 -0
  40. usaspending/queries/awarding_agencies_search.py +16 -0
  41. usaspending/queries/awards_search.py +602 -0
  42. usaspending/queries/filters.py +437 -0
  43. usaspending/queries/funding_agencies_search.py +20 -0
  44. usaspending/queries/funding_search.py +180 -0
  45. usaspending/queries/query_builder.py +1430 -0
  46. usaspending/queries/recipient_query.py +66 -0
  47. usaspending/queries/recipients_search.py +167 -0
  48. usaspending/queries/single_resource_base.py +83 -0
  49. usaspending/queries/spending_search.py +261 -0
  50. usaspending/queries/sub_agency_query.py +177 -0
  51. usaspending/queries/subawards_search.py +196 -0
  52. usaspending/queries/transactions_search.py +213 -0
  53. usaspending/resources/__init__.py +17 -0
  54. usaspending/resources/agency_resource.py +105 -0
  55. usaspending/resources/award_resource.py +119 -0
  56. usaspending/resources/base_resource.py +31 -0
  57. usaspending/resources/download_resource.py +102 -0
  58. usaspending/resources/funding_resource.py +41 -0
  59. usaspending/resources/recipients_resource.py +104 -0
  60. usaspending/resources/spending_resource.py +45 -0
  61. usaspending/resources/subawards_resource.py +68 -0
  62. usaspending/resources/transactions_resource.py +39 -0
  63. usaspending/utils/__init__.py +6 -0
  64. usaspending/utils/formatter.py +550 -0
  65. usaspending/utils/rate_limit.py +135 -0
  66. usaspending/utils/retry.py +257 -0
  67. usaspending/utils/special_cases.yaml +155 -0
  68. usaspending_orm-0.6.0.dist-info/LICENSE +18 -0
  69. usaspending_orm-0.6.0.dist-info/METADATA +324 -0
  70. usaspending_orm-0.6.0.dist-info/RECORD +71 -0
  71. 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()
@@ -0,0 +1,8 @@
1
+ # src/usaspending/download/__init__.py
2
+
3
+ """Download management utilities for USASpending API client."""
4
+
5
+ from .manager import DownloadManager
6
+ from .job import DownloadJob
7
+
8
+ __all__ = ["DownloadManager", "DownloadJob"]