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,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