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
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
from typing import List, Any, Optional, Set
|
|
2
|
+
from datetime import datetime, date
|
|
3
|
+
import re
|
|
4
|
+
import yaml
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import decimal
|
|
7
|
+
from decimal import Decimal, ROUND_HALF_UP
|
|
8
|
+
|
|
9
|
+
from titlecase import titlecase
|
|
10
|
+
|
|
11
|
+
from ..logging_config import USASpendingLogger
|
|
12
|
+
|
|
13
|
+
logger = USASpendingLogger.get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def to_date(date_string: str) -> Optional[date]:
|
|
17
|
+
"""Convert date string to date object.
|
|
18
|
+
|
|
19
|
+
Supports multiple date formats:
|
|
20
|
+
- YYYY-MM-DD (date only)
|
|
21
|
+
- YYYY-MM-DDTHH:MM:SS (ISO datetime)
|
|
22
|
+
- YYYY-MM-DDTHH:MM:SS.ffffff (ISO datetime with microseconds)
|
|
23
|
+
- YYYY-MM-DDTHH:MM:SSZ (ISO datetime with UTC indicator)
|
|
24
|
+
- YYYY-MM-DDTHH:MM:SS+/-HH:MM (ISO datetime with timezone offset)
|
|
25
|
+
|
|
26
|
+
Note: For formats with time components, only the date portion is returned.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
date_string: Date string in any supported format
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
date object or None if parsing fails
|
|
33
|
+
"""
|
|
34
|
+
if not date_string:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
# Define formats to try, in order of likelihood
|
|
38
|
+
formats = [
|
|
39
|
+
"%Y-%m-%d", # Date only (original format)
|
|
40
|
+
"%Y-%m-%dT%H:%M:%S", # ISO datetime without timezone
|
|
41
|
+
"%Y-%m-%dT%H:%M:%S.%f", # ISO datetime with microseconds
|
|
42
|
+
"%Y-%m-%dT%H:%M:%SZ", # ISO datetime with UTC indicator
|
|
43
|
+
"%Y-%m-%dT%H:%M:%S%z", # ISO datetime with timezone offset
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
for fmt in formats:
|
|
47
|
+
try:
|
|
48
|
+
parsed_datetime = datetime.strptime(date_string, fmt)
|
|
49
|
+
# Return only the date portion
|
|
50
|
+
return parsed_datetime.date()
|
|
51
|
+
except ValueError:
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
# If no format matched, log warning and return None
|
|
55
|
+
logger.warning(f"Could not parse date string: {date_string}")
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def round_to_millions(amount: int | float | Decimal) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Formats a monetary amount with commas and two decimal places, displaying as millions or billions when appropriate.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
amount (Any): The monetary value to format.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
str: The formatted string representing the amount in dollars, millions, or billions.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
amount = to_decimal(amount)
|
|
71
|
+
|
|
72
|
+
if amount is None:
|
|
73
|
+
return "$0.00"
|
|
74
|
+
elif amount >= 1_000_000_000:
|
|
75
|
+
return "${:,.1f} billion".format(amount / 1_000_000_000)
|
|
76
|
+
elif amount >= 10_000_000:
|
|
77
|
+
return "${:,.1f} million".format(amount / 1_000_000)
|
|
78
|
+
elif amount >= 1_000_000:
|
|
79
|
+
return "${:,.1f} million".format(amount / 1_000_000)
|
|
80
|
+
else:
|
|
81
|
+
return "${:,.2f}".format(amount)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def current_fiscal_year() -> int:
|
|
85
|
+
"""
|
|
86
|
+
Returns the current fiscal year based on the current date.
|
|
87
|
+
|
|
88
|
+
The fiscal year starts in October. If the current month is October or later,
|
|
89
|
+
the fiscal year is considered to be the next calendar year.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
int: The current fiscal year.
|
|
93
|
+
"""
|
|
94
|
+
current_date = datetime.now()
|
|
95
|
+
current_month = datetime.now().month
|
|
96
|
+
if current_month < 10:
|
|
97
|
+
return current_date.year
|
|
98
|
+
else:
|
|
99
|
+
return current_date.year + 1
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_past_fiscal_years(num_years: int = 3) -> List[int]:
|
|
103
|
+
"""
|
|
104
|
+
Get the past N fiscal years.
|
|
105
|
+
In the US, the federal fiscal year starts on October 1.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
num_years: Number of past fiscal years to return
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
List: List of fiscal years, starting with the most recent
|
|
112
|
+
"""
|
|
113
|
+
current_date = datetime.now()
|
|
114
|
+
current_year = current_date.year
|
|
115
|
+
|
|
116
|
+
# We always want the last completed fiscal year
|
|
117
|
+
if current_date.month < 10:
|
|
118
|
+
current_fiscal_year = current_year - 1
|
|
119
|
+
else:
|
|
120
|
+
current_fiscal_year = current_year
|
|
121
|
+
|
|
122
|
+
return [current_fiscal_year - i for i in range(num_years)]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def to_decimal(x: Any) -> Optional[Decimal]:
|
|
126
|
+
"""Convert input to a Decimal with 2 decimal places using banker's rounding.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
x: Value to convert to Decimal (number, string, etc.)
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Optional[Decimal]: Decimal object quantized to 2 decimal places, or None if input is None or conversion fails
|
|
133
|
+
"""
|
|
134
|
+
if x is None:
|
|
135
|
+
return None
|
|
136
|
+
try:
|
|
137
|
+
return Decimal(str(x)).quantize(Decimal("0.00"), rounding=ROUND_HALF_UP)
|
|
138
|
+
except (TypeError, ValueError, decimal.InvalidOperation):
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def to_float(x: Any) -> Optional[float]:
|
|
143
|
+
"""
|
|
144
|
+
Converts the input value to a float if possible.
|
|
145
|
+
Attempts to cast the provided value to a float. If the conversion fails due to a TypeError or ValueError,
|
|
146
|
+
returns None instead.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
x (Any): The value to convert to float.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Optional[float]: The converted float value, or None if conversion is not possible.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
return float(x)
|
|
157
|
+
except (TypeError, ValueError):
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def to_int(x: Any) -> Optional[int]:
|
|
162
|
+
"""
|
|
163
|
+
Converts the input value to an integer if possible.
|
|
164
|
+
Attempts to cast the provided value to an integer. If the conversion fails due to a TypeError or ValueError,
|
|
165
|
+
returns None instead.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
x (Any): The value to convert to an integer.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Optional[int]: The integer representation of `x` if conversion is successful; otherwise, None.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
return int(x)
|
|
176
|
+
except (TypeError, ValueError):
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --- Configuration ---
|
|
181
|
+
# Set of acronyms and initialisms to always keep uppercase.
|
|
182
|
+
# This could be loaded from a config file or environment variables in a larger application.
|
|
183
|
+
DEFAULT_KEEP_UPPERCASE: Set[str] = {
|
|
184
|
+
# Common Business / Legal
|
|
185
|
+
"LLC",
|
|
186
|
+
"INC",
|
|
187
|
+
"LLP",
|
|
188
|
+
"LTD",
|
|
189
|
+
"L.L.C.",
|
|
190
|
+
"I.N.C.",
|
|
191
|
+
"L.L.P.",
|
|
192
|
+
"L.T.D.",
|
|
193
|
+
# Geographical / Governmental
|
|
194
|
+
"USA",
|
|
195
|
+
"US",
|
|
196
|
+
"UK",
|
|
197
|
+
# Organizations / Agencies
|
|
198
|
+
"NASA",
|
|
199
|
+
"ESA",
|
|
200
|
+
"JAXA",
|
|
201
|
+
# NASA Facilities & Major Programs (add more as needed)
|
|
202
|
+
"JPL", # Jet Propulsion Laboratory
|
|
203
|
+
"JSC", # Johnson Space Center
|
|
204
|
+
"KSC", # Kennedy Space Center
|
|
205
|
+
"GSFC", # Goddard Space Flight Center
|
|
206
|
+
"MSFC", # Marshall Space Flight Center
|
|
207
|
+
"ARC", # Ames Research Center
|
|
208
|
+
"GRC", # Glenn Research Center
|
|
209
|
+
"LARC", # Langley Research Center (or LaRC - handled by case-insensitive check)
|
|
210
|
+
"AFRC", # Armstrong Flight Research Center
|
|
211
|
+
"SSC", # Stennis Space Center
|
|
212
|
+
"ISS", # International Space Station
|
|
213
|
+
"JWST", # James Webb Space Telescope
|
|
214
|
+
# Specific examples from user input
|
|
215
|
+
"CSOS",
|
|
216
|
+
"CL",
|
|
217
|
+
"FL",
|
|
218
|
+
"FPRW",
|
|
219
|
+
"PADF",
|
|
220
|
+
"ICAT",
|
|
221
|
+
"ICATEQ",
|
|
222
|
+
"AC" # For A.C. style
|
|
223
|
+
# Add other common contract/technical acronyms as needed
|
|
224
|
+
"RFQ",
|
|
225
|
+
"RFP",
|
|
226
|
+
"SOW",
|
|
227
|
+
"CDR",
|
|
228
|
+
"PDR",
|
|
229
|
+
"QA",
|
|
230
|
+
"PI",
|
|
231
|
+
"COTS",
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
# Maximum length for parenthesized text to be uppercased
|
|
235
|
+
PAREN_UPPERCASE_MAX_LEN: int = 9 # Fewer than 10 characters
|
|
236
|
+
|
|
237
|
+
# --- Helper Function ---
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def smart_sentence_case(
|
|
241
|
+
text: Optional[str],
|
|
242
|
+
paren_max_len: int = PAREN_UPPERCASE_MAX_LEN,
|
|
243
|
+
) -> str:
|
|
244
|
+
"""
|
|
245
|
+
Converts an uppercase string to sentence case, preserving specified acronyms
|
|
246
|
+
and short parenthesized text in uppercase.
|
|
247
|
+
|
|
248
|
+
Rules:
|
|
249
|
+
1. Converts the text to lowercase as a base.
|
|
250
|
+
2. Capitalizes the first letter of the resulting string.
|
|
251
|
+
3. Keeps special cases from YAML configuration in proper case.
|
|
252
|
+
4. Keeps text within parentheses uppercase if its length is less than
|
|
253
|
+
paren_max_len + 1 characters.
|
|
254
|
+
5. Handles standard punctuation like apostrophes correctly.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
text: The input string, expected to be mostly uppercase.
|
|
258
|
+
Can be None or empty.
|
|
259
|
+
paren_max_len: The maximum character length of text inside parentheses
|
|
260
|
+
to be kept uppercase. Defaults to PAREN_UPPERCASE_MAX_LEN.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
The processed string in smart sentence case, or an empty string if
|
|
264
|
+
the input was None or empty.
|
|
265
|
+
"""
|
|
266
|
+
# Use the new TextFormatter class
|
|
267
|
+
return TextFormatter.to_sentence_case(text, paren_max_len)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class TextFormatter:
|
|
271
|
+
"""Unified text formatting utility class for sentence and title case conversions."""
|
|
272
|
+
|
|
273
|
+
_special_cases_cache = None
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
def _load_special_cases(cls):
|
|
277
|
+
"""Load and cache special cases from YAML file."""
|
|
278
|
+
if cls._special_cases_cache is None:
|
|
279
|
+
yaml_path = Path(__file__).parent / "special_cases.yaml"
|
|
280
|
+
try:
|
|
281
|
+
with open(yaml_path, "r") as f:
|
|
282
|
+
cls._special_cases_cache = yaml.safe_load(f) or []
|
|
283
|
+
except FileNotFoundError:
|
|
284
|
+
logger.warning(f"Special cases file not found: {yaml_path}")
|
|
285
|
+
cls._special_cases_cache = []
|
|
286
|
+
except Exception as e:
|
|
287
|
+
logger.error(f"Error loading special cases: {e}")
|
|
288
|
+
cls._special_cases_cache = []
|
|
289
|
+
return cls._special_cases_cache
|
|
290
|
+
|
|
291
|
+
@classmethod
|
|
292
|
+
def _get_special_cases_set(cls):
|
|
293
|
+
"""Get special cases as a set for fast lookups."""
|
|
294
|
+
special_cases = cls._load_special_cases()
|
|
295
|
+
return {case.upper() for case in special_cases if isinstance(case, str)}
|
|
296
|
+
|
|
297
|
+
@classmethod
|
|
298
|
+
def _split_word_punctuation(cls, word):
|
|
299
|
+
"""Split a word into clean word and trailing punctuation."""
|
|
300
|
+
if not word:
|
|
301
|
+
return word, ""
|
|
302
|
+
|
|
303
|
+
trailing_punct = ""
|
|
304
|
+
clean_word = word
|
|
305
|
+
|
|
306
|
+
# Handle contractions separately
|
|
307
|
+
if "'" in word:
|
|
308
|
+
# For words like "NASA's", split at apostrophe
|
|
309
|
+
parts = word.split("'", 1)
|
|
310
|
+
if len(parts) == 2:
|
|
311
|
+
clean_word = parts[0]
|
|
312
|
+
trailing_punct = "'" + parts[1]
|
|
313
|
+
else:
|
|
314
|
+
clean_word = word
|
|
315
|
+
trailing_punct = ""
|
|
316
|
+
else:
|
|
317
|
+
# Find where the alphanumeric part ends
|
|
318
|
+
i = len(word) - 1
|
|
319
|
+
while i >= 0 and not word[i].isalnum():
|
|
320
|
+
i -= 1
|
|
321
|
+
if i >= 0:
|
|
322
|
+
clean_word = word[: i + 1]
|
|
323
|
+
trailing_punct = word[i + 1 :]
|
|
324
|
+
else:
|
|
325
|
+
# All punctuation, no alphanumeric chars
|
|
326
|
+
clean_word = word
|
|
327
|
+
trailing_punct = ""
|
|
328
|
+
|
|
329
|
+
return clean_word, trailing_punct
|
|
330
|
+
|
|
331
|
+
@classmethod
|
|
332
|
+
def _preserve_special_case(cls, word):
|
|
333
|
+
"""Check if word should be preserved as special case, return preserved version or None."""
|
|
334
|
+
if not isinstance(word, str):
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
# If the word is enclosed in parentheses, preserve the case inside
|
|
338
|
+
if word.startswith("(") and word.endswith(")"):
|
|
339
|
+
return word
|
|
340
|
+
|
|
341
|
+
# Load special cases YAML file
|
|
342
|
+
special_cases = cls._load_special_cases()
|
|
343
|
+
clean_word, trailing_punct = cls._split_word_punctuation(word)
|
|
344
|
+
|
|
345
|
+
# Check for case-insensitive match
|
|
346
|
+
for special_word in special_cases:
|
|
347
|
+
# Ensure special_word is a string
|
|
348
|
+
if not isinstance(special_word, str):
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
# First try exact match with full word (including punctuation)
|
|
352
|
+
if word.lower() == special_word.lower():
|
|
353
|
+
return special_word
|
|
354
|
+
|
|
355
|
+
# Then try match with clean word
|
|
356
|
+
if clean_word.lower() == special_word.lower():
|
|
357
|
+
return special_word + trailing_punct
|
|
358
|
+
|
|
359
|
+
# Also try if special_word has punctuation that matches
|
|
360
|
+
if (
|
|
361
|
+
special_word.endswith(".")
|
|
362
|
+
and clean_word.lower() == special_word[:-1].lower()
|
|
363
|
+
):
|
|
364
|
+
# Word like "inc" matching "Inc."
|
|
365
|
+
return special_word + trailing_punct
|
|
366
|
+
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
@classmethod
|
|
370
|
+
def to_sentence_case(cls, text: Optional[str], paren_max_len: int = 9) -> str:
|
|
371
|
+
"""
|
|
372
|
+
Convert text to sentence case, preserving special cases from YAML.
|
|
373
|
+
|
|
374
|
+
True sentence case: only capitalize first word of sentences and special cases.
|
|
375
|
+
Includes progressive acronym expansion for parenthetical content.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
text: Input text to convert
|
|
379
|
+
paren_max_len: Max length for parenthesized text to keep uppercase
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
str: Text in sentence case with special cases preserved
|
|
383
|
+
"""
|
|
384
|
+
if not text:
|
|
385
|
+
return ""
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
# Start with lowercase
|
|
389
|
+
processed_text = text.lower()
|
|
390
|
+
special_cases_set = cls._get_special_cases_set()
|
|
391
|
+
|
|
392
|
+
# Small words to ignore in acronym expansion
|
|
393
|
+
SMALL_WORDS = r"\b(a|an|and|as|at|but|by|en|for|if|in|of|on|or|the|to|v\.?|via|vs\.?)\b"
|
|
394
|
+
|
|
395
|
+
# First, handle acronym expansion for parenthetical content
|
|
396
|
+
def expand_acronyms(match):
|
|
397
|
+
full_match = match.group(0)
|
|
398
|
+
paren_content = match.group(1)
|
|
399
|
+
|
|
400
|
+
# If content is too long, handle normally
|
|
401
|
+
if len(paren_content) > paren_max_len:
|
|
402
|
+
return full_match
|
|
403
|
+
|
|
404
|
+
# Always try acronym expansion first, even for known acronyms
|
|
405
|
+
# This allows us to capitalize the expanded form
|
|
406
|
+
|
|
407
|
+
# Try progressive acronym expansion
|
|
408
|
+
start_pos = match.start()
|
|
409
|
+
text_before = processed_text[:start_pos].strip()
|
|
410
|
+
|
|
411
|
+
if text_before:
|
|
412
|
+
# Split into words
|
|
413
|
+
words_before = re.findall(r"\b\w+\b", text_before)
|
|
414
|
+
acronym_letters = [c.lower() for c in paren_content if c.isalpha()]
|
|
415
|
+
|
|
416
|
+
if len(acronym_letters) > 0:
|
|
417
|
+
# Try direct match first
|
|
418
|
+
if len(words_before) >= len(acronym_letters):
|
|
419
|
+
last_n_words = words_before[-len(acronym_letters) :]
|
|
420
|
+
if [w[0].lower() for w in last_n_words] == acronym_letters:
|
|
421
|
+
# Mark these words for capitalization
|
|
422
|
+
cls._acronym_expansion_words = getattr(
|
|
423
|
+
cls, "_acronym_expansion_words", set()
|
|
424
|
+
)
|
|
425
|
+
cls._acronym_expansion_words.update(last_n_words)
|
|
426
|
+
else:
|
|
427
|
+
# If direct match failed, try skipping small words
|
|
428
|
+
# Filter out small words
|
|
429
|
+
content_words = []
|
|
430
|
+
for word in words_before:
|
|
431
|
+
if not re.match(SMALL_WORDS, word, re.IGNORECASE):
|
|
432
|
+
content_words.append(word)
|
|
433
|
+
|
|
434
|
+
if len(content_words) >= len(acronym_letters):
|
|
435
|
+
last_n_content = content_words[
|
|
436
|
+
-len(acronym_letters) :
|
|
437
|
+
]
|
|
438
|
+
if [
|
|
439
|
+
w[0].lower() for w in last_n_content
|
|
440
|
+
] == acronym_letters:
|
|
441
|
+
# Mark these words for capitalization
|
|
442
|
+
cls._acronym_expansion_words = getattr(
|
|
443
|
+
cls, "_acronym_expansion_words", set()
|
|
444
|
+
)
|
|
445
|
+
cls._acronym_expansion_words.update(
|
|
446
|
+
last_n_content
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# Return uppercase parenthetical if short enough
|
|
450
|
+
if len(paren_content) <= paren_max_len:
|
|
451
|
+
return f"({paren_content.upper()})"
|
|
452
|
+
else:
|
|
453
|
+
return full_match
|
|
454
|
+
|
|
455
|
+
# Initialize acronym expansion tracking
|
|
456
|
+
cls._acronym_expansion_words = set()
|
|
457
|
+
|
|
458
|
+
# Apply acronym expansion
|
|
459
|
+
processed_text = re.sub(r"\(([^)]+)\)", expand_acronyms, processed_text)
|
|
460
|
+
|
|
461
|
+
# Handle special cases and sentence boundaries
|
|
462
|
+
def word_replacer(match):
|
|
463
|
+
word = match.group(1)
|
|
464
|
+
word_start = match.start()
|
|
465
|
+
|
|
466
|
+
# Check if word should be capitalized due to acronym expansion
|
|
467
|
+
if (
|
|
468
|
+
hasattr(cls, "_acronym_expansion_words")
|
|
469
|
+
and word in cls._acronym_expansion_words
|
|
470
|
+
):
|
|
471
|
+
return word.capitalize()
|
|
472
|
+
|
|
473
|
+
# Check if word is a special case from YAML
|
|
474
|
+
if word.upper() in special_cases_set:
|
|
475
|
+
for special_case in cls._load_special_cases():
|
|
476
|
+
if (
|
|
477
|
+
isinstance(special_case, str)
|
|
478
|
+
and word.upper() == special_case.upper()
|
|
479
|
+
):
|
|
480
|
+
return special_case
|
|
481
|
+
|
|
482
|
+
# Check if this is the start of a sentence (beginning or after . ! ? + space)
|
|
483
|
+
if word_start == 0:
|
|
484
|
+
return word.capitalize()
|
|
485
|
+
|
|
486
|
+
# Look for sentence boundaries (punctuation + one or more spaces)
|
|
487
|
+
text_before = processed_text[:word_start]
|
|
488
|
+
if re.search(r"[.!?]\s+$", text_before):
|
|
489
|
+
return word.capitalize()
|
|
490
|
+
|
|
491
|
+
return word
|
|
492
|
+
|
|
493
|
+
processed_text = re.sub(
|
|
494
|
+
r"\b([a-zA-Z]+(?:-[a-zA-Z]+)*)\b", word_replacer, processed_text
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# Clean up acronym expansion tracking
|
|
498
|
+
if hasattr(cls, "_acronym_expansion_words"):
|
|
499
|
+
delattr(cls, "_acronym_expansion_words")
|
|
500
|
+
|
|
501
|
+
return processed_text
|
|
502
|
+
|
|
503
|
+
except Exception as e:
|
|
504
|
+
logger.error(
|
|
505
|
+
f"Error processing text: '{text[:50]}...' - {e}", exc_info=True
|
|
506
|
+
)
|
|
507
|
+
return text # Fallback to original text on error
|
|
508
|
+
|
|
509
|
+
@classmethod
|
|
510
|
+
def titlecase_callback(cls, word, **kwargs):
|
|
511
|
+
"""Custom titlecase callback using YAML configuration."""
|
|
512
|
+
if not isinstance(word, str):
|
|
513
|
+
return word
|
|
514
|
+
|
|
515
|
+
# normalizations for common business suffixes
|
|
516
|
+
normalized_words = {
|
|
517
|
+
"L.L.C.": "LLC",
|
|
518
|
+
"I.N.C.": "Inc",
|
|
519
|
+
"L.L.P.": "LLP",
|
|
520
|
+
"L.T.D.": "LTD",
|
|
521
|
+
"P.L.L.C.": "PLLC",
|
|
522
|
+
"P.A.": "PA",
|
|
523
|
+
"P.C.": "PC",
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if word.upper() in normalized_words.keys():
|
|
527
|
+
word = normalized_words[word.upper()]
|
|
528
|
+
|
|
529
|
+
return cls._preserve_special_case(word)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# Define a callback function for custom word handling
|
|
533
|
+
def custom_titlecase_callback(word, **kwargs):
|
|
534
|
+
"""Custom titlecase callback using YAML configuration."""
|
|
535
|
+
return TextFormatter.titlecase_callback(word, **kwargs)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def contracts_titlecase(text):
|
|
539
|
+
"""
|
|
540
|
+
Applies NASA-relevant title casing rules to the given text.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
text (str or None): The input text to be title-cased. If None, returns None.
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
str or None: The title-cased text according to NASA-specific rules, or None if input is None.
|
|
547
|
+
"""
|
|
548
|
+
if text is None:
|
|
549
|
+
return None
|
|
550
|
+
return titlecase(text, callback=TextFormatter.titlecase_callback)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Rate limiting implementation for USASpending API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
from typing import Deque, Optional
|
|
9
|
+
|
|
10
|
+
from ..logging_config import USASpendingLogger
|
|
11
|
+
|
|
12
|
+
logger = USASpendingLogger.get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RateLimiter:
|
|
16
|
+
"""
|
|
17
|
+
Rate limiter using a sliding window algorithm.
|
|
18
|
+
|
|
19
|
+
This implementation tracks the timestamps of API calls and ensures
|
|
20
|
+
that no more than `max_calls` are made within any `period` second window.
|
|
21
|
+
|
|
22
|
+
Thread-safe implementation using locks.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, max_calls: int, period: float):
|
|
26
|
+
"""
|
|
27
|
+
Initialize the rate limiter.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
max_calls: Maximum number of calls allowed in the period
|
|
31
|
+
period: Time period in seconds
|
|
32
|
+
"""
|
|
33
|
+
if max_calls <= 0:
|
|
34
|
+
raise ValueError("max_calls must be positive")
|
|
35
|
+
if period <= 0:
|
|
36
|
+
raise ValueError("period must be positive")
|
|
37
|
+
|
|
38
|
+
self.max_calls = max_calls
|
|
39
|
+
self.period = period
|
|
40
|
+
self._call_times: Deque[float] = deque()
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
|
|
43
|
+
logger.debug(f"Initialized RateLimiter: {max_calls} calls per {period}s")
|
|
44
|
+
|
|
45
|
+
def wait_if_needed(self) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Wait if necessary to avoid exceeding the rate limit.
|
|
48
|
+
|
|
49
|
+
This method will block until it's safe to make another API call
|
|
50
|
+
without exceeding the configured rate limit.
|
|
51
|
+
"""
|
|
52
|
+
with self._lock:
|
|
53
|
+
now = time.time()
|
|
54
|
+
|
|
55
|
+
# Remove timestamps outside the current window
|
|
56
|
+
cutoff_time = now - self.period
|
|
57
|
+
while self._call_times and self._call_times[0] <= cutoff_time:
|
|
58
|
+
self._call_times.popleft()
|
|
59
|
+
|
|
60
|
+
# If we're at the limit, calculate how long to wait
|
|
61
|
+
if len(self._call_times) >= self.max_calls:
|
|
62
|
+
# Wait until the oldest call exits the window
|
|
63
|
+
oldest_call = self._call_times[0]
|
|
64
|
+
wait_time = (oldest_call + self.period) - now
|
|
65
|
+
|
|
66
|
+
if wait_time > 0:
|
|
67
|
+
logger.info(
|
|
68
|
+
f"Rate limit reached. Waiting {wait_time:.2f}s before next request"
|
|
69
|
+
)
|
|
70
|
+
# Release lock while sleeping to allow other threads
|
|
71
|
+
self._lock.release()
|
|
72
|
+
try:
|
|
73
|
+
time.sleep(wait_time)
|
|
74
|
+
finally:
|
|
75
|
+
self._lock.acquire()
|
|
76
|
+
|
|
77
|
+
# Re-check and clean up after sleeping
|
|
78
|
+
now = time.time()
|
|
79
|
+
cutoff_time = now - self.period
|
|
80
|
+
while self._call_times and self._call_times[0] <= cutoff_time:
|
|
81
|
+
self._call_times.popleft()
|
|
82
|
+
|
|
83
|
+
# Record this call
|
|
84
|
+
self._call_times.append(now)
|
|
85
|
+
|
|
86
|
+
logger.debug(
|
|
87
|
+
f"Recorded API call at {now:.3f}. "
|
|
88
|
+
f"Current window has {len(self._call_times)} calls"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def reset(self) -> None:
|
|
92
|
+
"""Reset the rate limiter, clearing all recorded calls."""
|
|
93
|
+
with self._lock:
|
|
94
|
+
self._call_times.clear()
|
|
95
|
+
logger.debug("Rate limiter reset")
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def available_calls(self) -> int:
|
|
99
|
+
"""
|
|
100
|
+
Get the number of calls that can be made immediately.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Number of available calls without waiting
|
|
104
|
+
"""
|
|
105
|
+
with self._lock:
|
|
106
|
+
now = time.time()
|
|
107
|
+
cutoff_time = now - self.period
|
|
108
|
+
|
|
109
|
+
# Remove outdated timestamps
|
|
110
|
+
while self._call_times and self._call_times[0] <= cutoff_time:
|
|
111
|
+
self._call_times.popleft()
|
|
112
|
+
|
|
113
|
+
return max(0, self.max_calls - len(self._call_times))
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def next_available_time(self) -> Optional[float]:
|
|
117
|
+
"""
|
|
118
|
+
Get the timestamp when the next call will be available.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Unix timestamp when next call can be made, or None if calls are available now
|
|
122
|
+
"""
|
|
123
|
+
with self._lock:
|
|
124
|
+
now = time.time()
|
|
125
|
+
cutoff_time = now - self.period
|
|
126
|
+
|
|
127
|
+
# Remove outdated timestamps
|
|
128
|
+
while self._call_times and self._call_times[0] <= cutoff_time:
|
|
129
|
+
self._call_times.popleft()
|
|
130
|
+
|
|
131
|
+
if len(self._call_times) < self.max_calls:
|
|
132
|
+
return None # Calls available now
|
|
133
|
+
|
|
134
|
+
# Return when the oldest call will exit the window
|
|
135
|
+
return self._call_times[0] + self.period
|