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
@@ -0,0 +1,257 @@
1
+ """Retry logic implementation for USASpending API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import time
7
+ from typing import Any, Callable, Optional
8
+
9
+ import requests
10
+
11
+ from ..config import config
12
+ from ..exceptions import HTTPError, RateLimitError
13
+ from ..logging_config import USASpendingLogger
14
+
15
+ logger = USASpendingLogger.get_logger(__name__)
16
+
17
+
18
+ class RetryHandler:
19
+ """
20
+ Handles retry logic with exponential backoff for API requests.
21
+
22
+ This implementation retries requests that fail due to transient errors
23
+ like network issues, server errors (5xx), and rate limiting (429).
24
+ """
25
+
26
+ # HTTP status codes that should be retried
27
+ RETRYABLE_STATUS_CODES = {
28
+ 429, # Too Many Requests (rate limit)
29
+ 500, # Internal Server Error
30
+ 502, # Bad Gateway
31
+ 503, # Service Unavailable
32
+ 504, # Gateway Timeout
33
+ 520, # Unknown Error (Cloudflare)
34
+ 521, # Web Server Is Down
35
+ 522, # Connection Timed Out
36
+ 523, # Origin Is Unreachable
37
+ 524, # A Timeout Occurred
38
+ }
39
+
40
+ # Exception types that should be retried
41
+ RETRYABLE_EXCEPTIONS = (
42
+ requests.exceptions.ConnectionError,
43
+ requests.exceptions.Timeout,
44
+ requests.exceptions.ConnectTimeout,
45
+ requests.exceptions.ReadTimeout,
46
+ )
47
+
48
+ def __init__(self, session_reset_callback: Optional[Callable] = None):
49
+ """
50
+ Initialize the retry handler.
51
+
52
+ Args:
53
+ session_reset_callback: Optional callback to reset session on persistent errors
54
+ """
55
+ self.max_retries = config.max_retries
56
+ self.base_delay = config.retry_delay
57
+ self.backoff_factor = config.retry_backoff
58
+ self.session_reset_callback = session_reset_callback
59
+
60
+ # Track consecutive 5XX errors for session reset logic
61
+ self._consecutive_5xx_errors = 0
62
+
63
+ logger.debug(
64
+ f"Initialized RetryHandler: max_retries={self.max_retries}, "
65
+ f"base_delay={self.base_delay}s, backoff_factor={self.backoff_factor}, "
66
+ f"session_reset={'enabled' if session_reset_callback else 'disabled'}"
67
+ )
68
+
69
+ def execute(self, func: Callable, *args, **kwargs) -> Any:
70
+ """
71
+ Execute a function with retry logic.
72
+
73
+ Args:
74
+ func: Function to execute (typically session.request)
75
+ *args: Positional arguments to pass to the function
76
+ **kwargs: Keyword arguments to pass to the function
77
+
78
+ Returns:
79
+ The result of the successful function call
80
+
81
+ Raises:
82
+ The last exception encountered if all retries are exhausted
83
+ """
84
+ last_exception = None
85
+
86
+ for attempt in range(self.max_retries + 1): # +1 for the initial attempt
87
+ try:
88
+ result = func(*args, **kwargs)
89
+
90
+ # Check if the result is a response object with a status code
91
+ if hasattr(result, "status_code"):
92
+ self._check_response_for_retry(result, attempt)
93
+
94
+ # Reset consecutive 5XX error counter on successful response
95
+ self._consecutive_5xx_errors = 0
96
+ return result
97
+
98
+ except Exception as e:
99
+ last_exception = e
100
+
101
+ # Track consecutive 5XX errors for session reset logic
102
+ if isinstance(e, HTTPError) and e.status_code >= 500:
103
+ self._consecutive_5xx_errors += 1
104
+ logger.debug(
105
+ f"Consecutive 5XX errors: {self._consecutive_5xx_errors}"
106
+ )
107
+
108
+ # Check if we should reset session due to persistent 5XX errors
109
+ if (
110
+ self.session_reset_callback
111
+ and self._consecutive_5xx_errors
112
+ >= config.session_reset_on_5xx_threshold
113
+ and attempt < self.max_retries
114
+ ): # Don't reset on final attempt
115
+ logger.warning(
116
+ f"Resetting session due to {self._consecutive_5xx_errors} consecutive 5XX errors "
117
+ f"(suggests server-side session exhaustion)"
118
+ )
119
+ self.session_reset_callback()
120
+ self._consecutive_5xx_errors = 0
121
+ # Reduce delay after session reset since we may have fixed the issue
122
+ delay = self.base_delay
123
+ logger.info(f"Retrying with fresh session after {delay:.2f}s")
124
+ time.sleep(delay)
125
+ continue
126
+ else:
127
+ # Reset counter for non-5XX errors
128
+ if self._consecutive_5xx_errors > 0:
129
+ logger.debug(
130
+ f"5XX error counter reset by {type(e).__name__} (was {self._consecutive_5xx_errors})"
131
+ )
132
+ self._consecutive_5xx_errors = 0
133
+
134
+ # Don't retry on the last attempt
135
+ if attempt == self.max_retries:
136
+ logger.warning(
137
+ f"Max retries ({self.max_retries}) exhausted. Final error: {e}"
138
+ )
139
+ break
140
+
141
+ # Check if this exception should be retried
142
+ if not self._should_retry_exception(e):
143
+ logger.debug(f"Exception {type(e).__name__} is not retryable")
144
+ break
145
+
146
+ # Calculate delay and wait before retrying
147
+ delay = self._calculate_delay(attempt, e)
148
+ if delay > 0:
149
+ logger.info(
150
+ f"Retry attempt {attempt + 1}/{self.max_retries} after {delay:.2f}s "
151
+ f"due to {type(e).__name__}: {e}"
152
+ )
153
+ time.sleep(delay)
154
+
155
+ # If we get here, all retries were exhausted
156
+ raise last_exception
157
+
158
+ def _check_response_for_retry(
159
+ self, response: requests.Response, attempt: int
160
+ ) -> None:
161
+ """
162
+ Check if a response should trigger a retry.
163
+
164
+ Args:
165
+ response: The HTTP response object
166
+ attempt: Current attempt number (0-based)
167
+
168
+ Raises:
169
+ Various exceptions if retry should occur
170
+ """
171
+ if response.status_code in self.RETRYABLE_STATUS_CODES:
172
+ if response.status_code == 429:
173
+ # Rate limit exceeded
174
+ retry_after = self._get_retry_after_header(response)
175
+ logger.warning(
176
+ f"Rate limit hit (HTTP 429). Retry-After: {retry_after}s"
177
+ )
178
+ raise RateLimitError("Rate limit exceeded", retry_after=retry_after)
179
+ elif response.status_code >= 500:
180
+ # Server error
181
+ logger.warning(f"Server error (HTTP {response.status_code})")
182
+ raise HTTPError(
183
+ f"Server error: HTTP {response.status_code}",
184
+ status_code=response.status_code,
185
+ )
186
+
187
+ def _should_retry_exception(self, exception: Exception) -> bool:
188
+ """
189
+ Determine if an exception should trigger a retry.
190
+
191
+ Args:
192
+ exception: The exception that occurred
193
+
194
+ Returns:
195
+ True if the exception should be retried, False otherwise
196
+ """
197
+ # Always retry these network-related exceptions
198
+ if isinstance(exception, self.RETRYABLE_EXCEPTIONS):
199
+ return True
200
+
201
+ # Retry rate limit errors
202
+ if isinstance(exception, RateLimitError):
203
+ return True
204
+
205
+ # Retry HTTP errors with retryable status codes
206
+ if isinstance(exception, HTTPError):
207
+ return exception.status_code in self.RETRYABLE_STATUS_CODES
208
+
209
+ # Don't retry other exceptions (like validation errors, auth errors, etc.)
210
+ return False
211
+
212
+ def _calculate_delay(self, attempt: int, exception: Exception) -> float:
213
+ """
214
+ Calculate the delay before the next retry attempt.
215
+
216
+ Args:
217
+ attempt: Current attempt number (0-based)
218
+ exception: The exception that triggered this retry
219
+
220
+ Returns:
221
+ Delay in seconds before the next attempt
222
+ """
223
+ # Handle rate limit errors specially
224
+ if isinstance(exception, RateLimitError) and exception.retry_after:
225
+ # Use the server-provided retry-after value
226
+ return float(exception.retry_after)
227
+
228
+ # Calculate exponential backoff with jitter
229
+ delay = self.base_delay * (self.backoff_factor**attempt)
230
+
231
+ # Add jitter (randomness) to avoid thundering herd problem
232
+ # Use up to 25% jitter
233
+ jitter = delay * 0.25 * random.random()
234
+ delay += jitter
235
+
236
+ logger.debug(f"Calculated retry delay: {delay:.3f}s (attempt {attempt})")
237
+ return delay
238
+
239
+ def _get_retry_after_header(self, response: requests.Response) -> Optional[int]:
240
+ """
241
+ Extract the Retry-After header value from a rate limit response.
242
+
243
+ Args:
244
+ response: The HTTP response object
245
+
246
+ Returns:
247
+ Number of seconds to wait, or None if header not present
248
+ """
249
+ retry_after = response.headers.get("Retry-After")
250
+ if retry_after:
251
+ try:
252
+ return int(retry_after)
253
+ except ValueError:
254
+ # Header might be in HTTP-date format, but we'll just ignore it
255
+ # and use exponential backoff instead
256
+ pass
257
+ return None
@@ -0,0 +1,155 @@
1
+ # Updatable list of special cases for title casing when converting USASpending field data
2
+ # from uppercase to title or sentence case.
3
+ # These words will display exactly as they are listed here, regardless of their position in a sentence.
4
+
5
+ # NASA and technical acronyms
6
+ - NASA
7
+ - ESA
8
+ - USA
9
+ - UK
10
+ - SBIR
11
+ - STTR
12
+ - TDM
13
+ - TDRS
14
+ - FY
15
+ - EPSCoR
16
+ - STEM
17
+ - JAXA
18
+ - JPL
19
+ - JSC
20
+ - KSC
21
+ - GSFC
22
+ - MSFC
23
+ - ARC
24
+ - GRC
25
+ - LARC
26
+ - AFRC
27
+ - SSC
28
+ - JWST
29
+ - CSOS
30
+ - CL
31
+ - FL
32
+ - FPRW
33
+ - PADF
34
+ - ICAT
35
+ - ICATEQ
36
+ - AC
37
+ - RFQ
38
+ - RFP
39
+ - SOW
40
+ - CDR
41
+ - PDR
42
+ - QA
43
+ - PI
44
+ - COTS
45
+ - SETI
46
+ - SLS
47
+ - ISS
48
+
49
+ # Business suffixes
50
+ - LLC
51
+ - LLP
52
+ - L.L.C.
53
+ - I.N.C.
54
+ - Inc
55
+ - L.L.P.
56
+ - L.T.D.
57
+ - LTD
58
+ - P.C.
59
+ - P.A.
60
+ - PLLC
61
+ - P.L.L.C.
62
+ - PC
63
+ - PA
64
+ - S.A.
65
+ - S.C.
66
+ - GmbH
67
+ - B.V.
68
+ - N.V.
69
+
70
+ # NASA programs and projects
71
+ - PDS
72
+ - PDART
73
+ - MDAP
74
+ - MEP
75
+ - MEPAG
76
+
77
+ # Mission names and acronyms
78
+ - Artemis
79
+ - Orion
80
+ - ISS
81
+ - SpaceX
82
+ - Falcon 9
83
+ - OSIRIS-REx
84
+ - OSIRIS-APEX
85
+ - Perseverance
86
+ - Mars 2020
87
+ - Curiosity
88
+ - MSL
89
+ - MER
90
+ - MAVEN
91
+ - Mars Reconnaissance Orbiter
92
+ - MRO
93
+ - Mars Odyssey
94
+ - Mars Express
95
+ - ODY
96
+ - VERITAS
97
+ - DAVINCI
98
+ - Psyche
99
+ - Europa Clipper
100
+ - Dragonfly
101
+ - DART
102
+ - Hubble
103
+ - Chandra
104
+ - Spitzer
105
+ - Kepler
106
+ - TESS
107
+ - WFIRST
108
+ - LRO
109
+ - SOFIA
110
+ - Juno
111
+ - Cassini
112
+ - New Horizons
113
+ - Voyager
114
+ - Parker Solar Probe
115
+ - PSP
116
+ - STS
117
+
118
+ # Names and proper nouns
119
+ - Pluto
120
+ - Neptune
121
+ - Triton
122
+ - Saturn
123
+ - Titan
124
+ - Enceladus
125
+ - Jupiter
126
+ - Europa
127
+ - Ganymede
128
+ - Callisto
129
+ - Io
130
+ - Eris
131
+ - Ceres
132
+ - Vesta
133
+ - Mars
134
+ - Phobos
135
+ - Deimos
136
+ - Earth
137
+ - the Moon
138
+ - Venus
139
+ - Mercury
140
+ - the Sun
141
+
142
+ # Directional abbreviations
143
+ - Dr
144
+ - N
145
+ - S
146
+ - E
147
+ - W
148
+ - NE
149
+ - NW
150
+ - SE
151
+ - SW
152
+ - N.E.
153
+ - N.W.
154
+ - S.E.
155
+ - S.W.
@@ -0,0 +1,18 @@
1
+ Copyright 2025 Casey Dreier
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the “Software”), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is furnished
8
+ to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
14
+ INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
15
+ PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
16
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
17
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
18
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.