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,437 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, ClassVar, Literal, Optional, Union
|
|
8
|
+
|
|
9
|
+
from ..exceptions import ValidationError
|
|
10
|
+
|
|
11
|
+
# ==============================================================================
|
|
12
|
+
# Helper Enums and Dataclasses
|
|
13
|
+
# ==============================================================================
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AgencyType(Enum):
|
|
17
|
+
"""Enumeration for agency types."""
|
|
18
|
+
|
|
19
|
+
AWARDING = "awarding"
|
|
20
|
+
FUNDING = "funding"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AgencyTier(Enum):
|
|
24
|
+
"""Enumeration for agency tiers."""
|
|
25
|
+
|
|
26
|
+
TOPTIER = "toptier"
|
|
27
|
+
SUBTIER = "subtier"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LocationScope(Enum):
|
|
31
|
+
"""Enumeration for location scopes."""
|
|
32
|
+
|
|
33
|
+
DOMESTIC = "domestic"
|
|
34
|
+
FOREIGN = "foreign"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AwardDateType(Enum):
|
|
38
|
+
"""Enumeration for award search date types."""
|
|
39
|
+
|
|
40
|
+
ACTION_DATE = "action_date"
|
|
41
|
+
DATE_SIGNED = "date_signed"
|
|
42
|
+
LAST_MODIFIED = "last_modified_date"
|
|
43
|
+
NEW_AWARDS_ONLY = "new_awards_only"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class LocationSpec:
|
|
48
|
+
"""Represents a standard location specification for Place of Performance or Recipient filters."""
|
|
49
|
+
|
|
50
|
+
country_code: str
|
|
51
|
+
state_code: Optional[str] = None
|
|
52
|
+
county_code: Optional[str] = None
|
|
53
|
+
city_name: Optional[str] = None
|
|
54
|
+
district_original: Optional[str] = (
|
|
55
|
+
None # Current congressional district (e.g. "IA-03")
|
|
56
|
+
)
|
|
57
|
+
district_current: Optional[str] = (
|
|
58
|
+
None # Congressional district when awarded (e.g. "WA-01")
|
|
59
|
+
)
|
|
60
|
+
zip_code: Optional[str] = None
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, str]:
|
|
63
|
+
"""Serializes the location to the dictionary format required by the API."""
|
|
64
|
+
data = {"country": self.country_code}
|
|
65
|
+
if self.state_code:
|
|
66
|
+
data["state"] = self.state_code
|
|
67
|
+
if self.county_code:
|
|
68
|
+
data["county"] = self.county_code
|
|
69
|
+
if self.city_name:
|
|
70
|
+
data["city"] = self.city_name
|
|
71
|
+
if self.district_original:
|
|
72
|
+
data["district_original"] = self.district_original
|
|
73
|
+
if self.district_current:
|
|
74
|
+
data["district_current"] = self.district_current
|
|
75
|
+
if self.zip_code:
|
|
76
|
+
data["zip"] = self.zip_code
|
|
77
|
+
return data
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class AgencySpec:
|
|
82
|
+
"""Represents a standard agency specification for awarding/funding agency filters."""
|
|
83
|
+
|
|
84
|
+
name: str
|
|
85
|
+
type: str # "awarding" or "funding"
|
|
86
|
+
tier: str # "toptier" or "subtier"
|
|
87
|
+
toptier_name: Optional[str] = None # For scoping subtiers to specific parent
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict[str, str]:
|
|
90
|
+
"""Serializes the agency spec to the dictionary format required by the API."""
|
|
91
|
+
data = {
|
|
92
|
+
"name": self.name,
|
|
93
|
+
"type": self.type,
|
|
94
|
+
"tier": self.tier,
|
|
95
|
+
}
|
|
96
|
+
if self.toptier_name:
|
|
97
|
+
data["toptier_name"] = self.toptier_name
|
|
98
|
+
return data
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ==============================================================================
|
|
102
|
+
# Base Filter Abstraction
|
|
103
|
+
# ==============================================================================
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class BaseFilter(ABC):
|
|
107
|
+
"""Abstract base class for all query filter types."""
|
|
108
|
+
|
|
109
|
+
key: ClassVar[str]
|
|
110
|
+
|
|
111
|
+
@abstractmethod
|
|
112
|
+
def to_dict(self) -> dict[str, Any]:
|
|
113
|
+
"""Converts the filter to its dictionary representation for the API."""
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ==============================================================================
|
|
118
|
+
# Individual Filter Implementations
|
|
119
|
+
# ==============================================================================
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class KeywordsFilter(BaseFilter):
|
|
124
|
+
"""Filter by a list of keywords."""
|
|
125
|
+
|
|
126
|
+
key: ClassVar[str] = "keywords"
|
|
127
|
+
values: list[str]
|
|
128
|
+
|
|
129
|
+
def to_dict(self) -> dict[str, list[str]]:
|
|
130
|
+
return {self.key: self.values}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class TimePeriodFilter(BaseFilter):
|
|
135
|
+
"""Filter by a date range."""
|
|
136
|
+
|
|
137
|
+
key: ClassVar[str] = "time_period"
|
|
138
|
+
start_date: datetime.date
|
|
139
|
+
end_date: datetime.date
|
|
140
|
+
date_type: Optional[AwardDateType] = None
|
|
141
|
+
|
|
142
|
+
def to_dict(self) -> dict[str, list[dict[str, str]]]:
|
|
143
|
+
period: dict[str, str] = {
|
|
144
|
+
"start_date": self.start_date.strftime("%Y-%m-%d"),
|
|
145
|
+
"end_date": self.end_date.strftime("%Y-%m-%d"),
|
|
146
|
+
}
|
|
147
|
+
if self.date_type:
|
|
148
|
+
period["date_type"] = self.date_type.value
|
|
149
|
+
return {self.key: [period]}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class LocationScopeFilter(BaseFilter):
|
|
154
|
+
"""Filter by domestic or foreign scope for location."""
|
|
155
|
+
|
|
156
|
+
key: Literal["place_of_performance_scope", "recipient_scope"]
|
|
157
|
+
scope: LocationScope
|
|
158
|
+
|
|
159
|
+
def to_dict(self) -> dict[str, str]:
|
|
160
|
+
return {self.key: self.scope.value}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class LocationFilter(BaseFilter):
|
|
165
|
+
"""Filter by one or more specific geographic locations."""
|
|
166
|
+
|
|
167
|
+
key: Literal["place_of_performance_locations", "recipient_locations"]
|
|
168
|
+
locations: list[LocationSpec]
|
|
169
|
+
|
|
170
|
+
def to_dict(self) -> dict[str, list[dict[str, str]]]:
|
|
171
|
+
return {self.key: [loc.to_dict() for loc in self.locations]}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class AgencyFilter(BaseFilter):
|
|
176
|
+
"""Filter by one or more awarding or funding agencies."""
|
|
177
|
+
|
|
178
|
+
key: ClassVar[str] = "agencies"
|
|
179
|
+
agencies: list[AgencySpec]
|
|
180
|
+
|
|
181
|
+
def to_dict(self) -> dict[str, list[dict[str, str]]]:
|
|
182
|
+
return {self.key: [agency.to_dict() for agency in self.agencies]}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class SimpleListFilter(BaseFilter):
|
|
187
|
+
"""A generic filter for API keys that accept a list of string values."""
|
|
188
|
+
|
|
189
|
+
key: str
|
|
190
|
+
values: list[str]
|
|
191
|
+
|
|
192
|
+
def to_dict(self) -> dict[str, list[str]]:
|
|
193
|
+
return {self.key: self.values}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True)
|
|
197
|
+
class AwardAmount:
|
|
198
|
+
"""Represents a single award amount range for filtering."""
|
|
199
|
+
|
|
200
|
+
lower_bound: Optional[float] = None
|
|
201
|
+
upper_bound: Optional[float] = None
|
|
202
|
+
|
|
203
|
+
def to_dict(self) -> dict[str, float]:
|
|
204
|
+
data = {}
|
|
205
|
+
if self.lower_bound is not None:
|
|
206
|
+
data["lower_bound"] = self.lower_bound
|
|
207
|
+
if self.upper_bound is not None:
|
|
208
|
+
data["upper_bound"] = self.upper_bound
|
|
209
|
+
return data
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True)
|
|
213
|
+
class AwardAmountFilter(BaseFilter):
|
|
214
|
+
"""Filter by one or more award amount ranges."""
|
|
215
|
+
|
|
216
|
+
key: ClassVar[str] = "award_amounts"
|
|
217
|
+
amounts: list[AwardAmount]
|
|
218
|
+
|
|
219
|
+
def to_dict(self) -> dict[str, list[dict[str, float]]]:
|
|
220
|
+
return {self.key: [amount.to_dict() for amount in self.amounts]}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True)
|
|
224
|
+
class TieredCodeFilter(BaseFilter):
|
|
225
|
+
"""Handles filters with a 'require' and 'exclude' structure like NAICS."""
|
|
226
|
+
|
|
227
|
+
key: Literal["naics_codes", "psc_codes", "tas_codes"]
|
|
228
|
+
require: list[list[str]] = field(default_factory=list)
|
|
229
|
+
exclude: list[list[str]] = field(default_factory=list)
|
|
230
|
+
|
|
231
|
+
def to_dict(self) -> dict[str, dict[str, list[list[str]]]]:
|
|
232
|
+
data = {}
|
|
233
|
+
if self.require:
|
|
234
|
+
data["require"] = self.require
|
|
235
|
+
if self.exclude:
|
|
236
|
+
data["exclude"] = self.exclude
|
|
237
|
+
return {self.key: data}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True)
|
|
241
|
+
class TreasuryAccountComponentsFilter(BaseFilter):
|
|
242
|
+
"""Filter by specific components of a Treasury Account."""
|
|
243
|
+
|
|
244
|
+
key: ClassVar[str] = "treasury_account_components"
|
|
245
|
+
components: list[dict[str, str]]
|
|
246
|
+
|
|
247
|
+
def to_dict(self) -> dict[str, list[dict[str, str]]]:
|
|
248
|
+
return {self.key: self.components}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ==============================================================================
|
|
252
|
+
# Conversion Utility Functions
|
|
253
|
+
# ==============================================================================
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def parse_location_scope(scope: str) -> LocationScope:
|
|
257
|
+
"""
|
|
258
|
+
Convert a string to a LocationScope enum value.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
scope: Either "domestic" or "foreign" (case-insensitive).
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
LocationScope: The corresponding enum value.
|
|
265
|
+
|
|
266
|
+
Raises:
|
|
267
|
+
ValidationError: If scope is not "domestic" or "foreign".
|
|
268
|
+
"""
|
|
269
|
+
scope_lower = scope.lower()
|
|
270
|
+
if scope_lower == "domestic":
|
|
271
|
+
return LocationScope.DOMESTIC
|
|
272
|
+
elif scope_lower == "foreign":
|
|
273
|
+
return LocationScope.FOREIGN
|
|
274
|
+
else:
|
|
275
|
+
raise ValidationError("scope must be 'domestic' or 'foreign'")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def parse_agency_type(agency_type: str) -> AgencyType:
|
|
279
|
+
"""
|
|
280
|
+
Convert a string to an AgencyType enum value.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
agency_type: Either "awarding" or "funding" (case-insensitive).
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
AgencyType: The corresponding enum value.
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
ValidationError: If agency_type is not "awarding" or "funding".
|
|
290
|
+
"""
|
|
291
|
+
agency_type_lower = agency_type.lower()
|
|
292
|
+
if agency_type_lower == "awarding":
|
|
293
|
+
return AgencyType.AWARDING
|
|
294
|
+
elif agency_type_lower == "funding":
|
|
295
|
+
return AgencyType.FUNDING
|
|
296
|
+
else:
|
|
297
|
+
raise ValidationError("agency_type must be 'awarding' or 'funding'")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def parse_agency_tier(tier: str) -> AgencyTier:
|
|
301
|
+
"""
|
|
302
|
+
Convert a string to an AgencyTier enum value.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
tier: Either "toptier" or "subtier" (case-insensitive).
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
AgencyTier: The corresponding enum value.
|
|
309
|
+
|
|
310
|
+
Raises:
|
|
311
|
+
ValidationError: If tier is not "toptier" or "subtier".
|
|
312
|
+
"""
|
|
313
|
+
tier_lower = tier.lower()
|
|
314
|
+
if tier_lower == "toptier":
|
|
315
|
+
return AgencyTier.TOPTIER
|
|
316
|
+
elif tier_lower == "subtier":
|
|
317
|
+
return AgencyTier.SUBTIER
|
|
318
|
+
else:
|
|
319
|
+
raise ValidationError("tier must be 'toptier' or 'subtier'")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def parse_award_date_type(date_type: str) -> AwardDateType:
|
|
323
|
+
"""
|
|
324
|
+
Convert a string to an AwardDateType enum value.
|
|
325
|
+
|
|
326
|
+
Handles flexible input formats including underscores and variations.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
date_type: One of "action_date", "date_signed", "last_modified_date",
|
|
330
|
+
or "new_awards_only" (case-insensitive, underscores optional).
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
AwardDateType: The corresponding enum value.
|
|
334
|
+
|
|
335
|
+
Raises:
|
|
336
|
+
ValidationError: If date_type is not a valid option.
|
|
337
|
+
"""
|
|
338
|
+
date_type_lower = date_type.lower().replace("_", "")
|
|
339
|
+
if date_type_lower == "actiondate":
|
|
340
|
+
return AwardDateType.ACTION_DATE
|
|
341
|
+
elif date_type_lower == "datesigned":
|
|
342
|
+
return AwardDateType.DATE_SIGNED
|
|
343
|
+
elif date_type_lower in ["lastmodified", "lastmodifieddate"]:
|
|
344
|
+
return AwardDateType.LAST_MODIFIED
|
|
345
|
+
elif date_type_lower == "newardsonly":
|
|
346
|
+
return AwardDateType.NEW_AWARDS_ONLY
|
|
347
|
+
else:
|
|
348
|
+
raise ValidationError(
|
|
349
|
+
f"Invalid date_type: '{date_type}'. Must be one of: "
|
|
350
|
+
"'action_date', 'date_signed', 'last_modified_date', 'new_awards_only'"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def parse_award_amount(
|
|
355
|
+
amount: Union[dict[str, float], tuple[Optional[float], Optional[float]]],
|
|
356
|
+
) -> AwardAmount:
|
|
357
|
+
"""
|
|
358
|
+
Convert a dictionary or tuple to an AwardAmount dataclass.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
amount: Either:
|
|
362
|
+
- A dictionary with 'lower_bound' and/or 'upper_bound' keys
|
|
363
|
+
- A tuple of (lower_bound, upper_bound) where None means unbounded
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
AwardAmount: The corresponding dataclass instance.
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
ValidationError: If amount is not a valid dict or tuple format.
|
|
370
|
+
"""
|
|
371
|
+
if isinstance(amount, dict):
|
|
372
|
+
return AwardAmount(**amount)
|
|
373
|
+
elif isinstance(amount, tuple):
|
|
374
|
+
if len(amount) != 2:
|
|
375
|
+
raise ValidationError(
|
|
376
|
+
"Award amount tuple must have exactly 2 elements (lower_bound, upper_bound)"
|
|
377
|
+
)
|
|
378
|
+
lower, upper = amount
|
|
379
|
+
return AwardAmount(lower_bound=lower, upper_bound=upper)
|
|
380
|
+
else:
|
|
381
|
+
raise ValidationError(
|
|
382
|
+
"Award amounts must be specified as a dictionary or tuple"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def parse_location_spec(location: dict[str, str]) -> LocationSpec:
|
|
387
|
+
"""
|
|
388
|
+
Convert a dictionary to a LocationSpec dataclass.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
location: A dictionary with location fields like country_code,
|
|
392
|
+
state_code, city_name, etc.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
LocationSpec: The corresponding dataclass instance.
|
|
396
|
+
"""
|
|
397
|
+
return LocationSpec(**location)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def parse_agency_spec(agency: dict[str, str]) -> AgencySpec:
|
|
401
|
+
"""
|
|
402
|
+
Convert a dictionary to an AgencySpec dataclass.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
agency: A dictionary with agency fields: name, type, tier,
|
|
406
|
+
and optionally toptier_name.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
AgencySpec: The corresponding dataclass instance.
|
|
410
|
+
|
|
411
|
+
Raises:
|
|
412
|
+
ValidationError: If required fields are missing or invalid.
|
|
413
|
+
"""
|
|
414
|
+
from ..exceptions import ValidationError
|
|
415
|
+
|
|
416
|
+
# Validate required fields
|
|
417
|
+
if "name" not in agency:
|
|
418
|
+
raise ValidationError("Agency specification must include 'name' field")
|
|
419
|
+
if "type" not in agency:
|
|
420
|
+
raise ValidationError("Agency specification must include 'type' field")
|
|
421
|
+
if "tier" not in agency:
|
|
422
|
+
raise ValidationError("Agency specification must include 'tier' field")
|
|
423
|
+
|
|
424
|
+
# Validate type and tier values
|
|
425
|
+
valid_types = {"awarding", "funding"}
|
|
426
|
+
valid_tiers = {"toptier", "subtier"}
|
|
427
|
+
|
|
428
|
+
if agency["type"] not in valid_types:
|
|
429
|
+
raise ValidationError(
|
|
430
|
+
f"Agency type must be 'awarding' or 'funding', got: {agency['type']}"
|
|
431
|
+
)
|
|
432
|
+
if agency["tier"] not in valid_tiers:
|
|
433
|
+
raise ValidationError(
|
|
434
|
+
f"Agency tier must be 'toptier' or 'subtier', got: {agency['tier']}"
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
return AgencySpec(**agency)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Agencies search query implementation for funding agency/office autocomplete."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
from .agencies_search import AgenciesSearch
|
|
6
|
+
from ..logging_config import USASpendingLogger
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
logger = USASpendingLogger.get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FundingAgenciesSearch(AgenciesSearch):
|
|
15
|
+
"""Search for funding agencies and offices by name."""
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def _endpoint(self) -> str:
|
|
19
|
+
"""API endpoint for agency autocomplete."""
|
|
20
|
+
return "/autocomplete/funding_agency_office/"
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Funding search query builder for USASpending data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from ..exceptions import ValidationError
|
|
8
|
+
from ..models.funding import Funding
|
|
9
|
+
from .query_builder import QueryBuilder
|
|
10
|
+
from ..logging_config import USASpendingLogger
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..client import USASpendingClient
|
|
14
|
+
|
|
15
|
+
logger = USASpendingLogger.get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FundingSearch(QueryBuilder["Funding"]):
|
|
19
|
+
"""
|
|
20
|
+
Builds and executes a funding search query, allowing for retrieval
|
|
21
|
+
of federal account funding data for a specific award.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
# Map user-friendly sort field names to API field names
|
|
25
|
+
SORT_FIELD_MAP = {
|
|
26
|
+
"account_title": "account_title",
|
|
27
|
+
"awarding_agency": "awarding_agency_name",
|
|
28
|
+
"disaster_code": "disaster_emergency_fund_code",
|
|
29
|
+
"federal_account": "federal_account",
|
|
30
|
+
"funding_agency": "funding_agency_name",
|
|
31
|
+
"gross_outlay": "gross_outlay_amount",
|
|
32
|
+
"object_class": "object_class",
|
|
33
|
+
"program_activity": "program_activity",
|
|
34
|
+
"reporting_date": "reporting_fiscal_date",
|
|
35
|
+
"fiscal_date": "reporting_fiscal_date",
|
|
36
|
+
"obligated_amount": "transaction_obligated_amount",
|
|
37
|
+
"obligation": "transaction_obligated_amount",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def __init__(self, client: "USASpendingClient"):
|
|
41
|
+
"""
|
|
42
|
+
Initializes the FundingSearch query builder.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
client: The USASpending client instance.
|
|
46
|
+
"""
|
|
47
|
+
super().__init__(client)
|
|
48
|
+
self._award_id: str = None
|
|
49
|
+
self._sort_field: str = "reporting_fiscal_date"
|
|
50
|
+
self._sort_order: str = "desc"
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def _endpoint(self) -> str:
|
|
54
|
+
"""The API endpoint for this query."""
|
|
55
|
+
return "/awards/funding/"
|
|
56
|
+
|
|
57
|
+
def _clone(self) -> FundingSearch:
|
|
58
|
+
"""Creates an immutable copy of the query builder."""
|
|
59
|
+
clone = super()._clone()
|
|
60
|
+
clone._award_id = self._award_id
|
|
61
|
+
clone._sort_field = self._sort_field
|
|
62
|
+
clone._sort_order = self._sort_order
|
|
63
|
+
return clone
|
|
64
|
+
|
|
65
|
+
def _build_payload(self, page: int) -> Dict[str, Any]:
|
|
66
|
+
"""Constructs the final API request payload."""
|
|
67
|
+
if not self._award_id:
|
|
68
|
+
raise ValidationError(
|
|
69
|
+
"An award_id is required. Use the .award_id() method."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
payload = {
|
|
73
|
+
"award_id": self._award_id,
|
|
74
|
+
"limit": self._get_effective_page_size(),
|
|
75
|
+
"page": page,
|
|
76
|
+
"sort": self._sort_field,
|
|
77
|
+
"order": self._sort_order,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return payload
|
|
81
|
+
|
|
82
|
+
def _transform_result(self, result: Dict[str, Any]) -> Funding:
|
|
83
|
+
"""Transforms a single API result item into a Funding model."""
|
|
84
|
+
return Funding(result)
|
|
85
|
+
|
|
86
|
+
def count(self) -> int:
|
|
87
|
+
"""
|
|
88
|
+
Counts the number of funding records for the award.
|
|
89
|
+
|
|
90
|
+
Since the funding endpoint doesn't provide a count API,
|
|
91
|
+
we need to iterate through all pages to get the count.
|
|
92
|
+
"""
|
|
93
|
+
logger.debug(f"{self.__class__.__name__}.count() called")
|
|
94
|
+
|
|
95
|
+
if not self._award_id:
|
|
96
|
+
raise ValidationError(
|
|
97
|
+
"An award_id is required. Use the .award_id() method."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Iterate through all results to count
|
|
101
|
+
count = 0
|
|
102
|
+
for _ in self:
|
|
103
|
+
count += 1
|
|
104
|
+
|
|
105
|
+
logger.info(
|
|
106
|
+
f"{self.__class__.__name__}.count() = {count} funding records "
|
|
107
|
+
f"for award {self._award_id}"
|
|
108
|
+
)
|
|
109
|
+
return count
|
|
110
|
+
|
|
111
|
+
# ==========================================================================
|
|
112
|
+
# Filter Methods
|
|
113
|
+
# ==========================================================================
|
|
114
|
+
|
|
115
|
+
def award_id(self, award_id: str) -> FundingSearch:
|
|
116
|
+
"""
|
|
117
|
+
Filter funding records for a specific award.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
award_id: The unique award identifier.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
A new FundingSearch instance with the award filter applied.
|
|
124
|
+
"""
|
|
125
|
+
if not award_id:
|
|
126
|
+
raise ValidationError("award_id cannot be empty")
|
|
127
|
+
|
|
128
|
+
clone = self._clone()
|
|
129
|
+
clone._award_id = str(award_id).strip()
|
|
130
|
+
return clone
|
|
131
|
+
|
|
132
|
+
def order_by(self, field: str, direction: str = "desc") -> FundingSearch:
|
|
133
|
+
"""
|
|
134
|
+
Set the sort order for results.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
field: The field to sort by. Can be a user-friendly name or API field name.
|
|
138
|
+
User-friendly names include:
|
|
139
|
+
- 'account_title', 'awarding_agency', 'disaster_code'
|
|
140
|
+
- 'federal_account', 'funding_agency', 'gross_outlay'
|
|
141
|
+
- 'object_class', 'program_activity', 'reporting_date'
|
|
142
|
+
- 'fiscal_date', 'obligated_amount', 'obligation'
|
|
143
|
+
direction: Sort direction - 'asc' or 'desc' (default: 'desc')
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
A new FundingSearch instance with the sort configuration applied.
|
|
147
|
+
"""
|
|
148
|
+
# Validate direction
|
|
149
|
+
if direction not in ["asc", "desc"]:
|
|
150
|
+
raise ValidationError(
|
|
151
|
+
f"Invalid sort direction: {direction}. Must be 'asc' or 'desc'."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Map user-friendly field names to API field names
|
|
155
|
+
api_field = self.SORT_FIELD_MAP.get(field.lower(), field)
|
|
156
|
+
|
|
157
|
+
# Validate that the field is supported by the API
|
|
158
|
+
valid_api_fields = [
|
|
159
|
+
"account_title",
|
|
160
|
+
"awarding_agency_name",
|
|
161
|
+
"disaster_emergency_fund_code",
|
|
162
|
+
"federal_account",
|
|
163
|
+
"funding_agency_name",
|
|
164
|
+
"gross_outlay_amount",
|
|
165
|
+
"object_class",
|
|
166
|
+
"program_activity",
|
|
167
|
+
"reporting_fiscal_date",
|
|
168
|
+
"transaction_obligated_amount",
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
if api_field not in valid_api_fields:
|
|
172
|
+
raise ValidationError(
|
|
173
|
+
f"Invalid sort field: {field}. "
|
|
174
|
+
f"Valid fields are: {', '.join(self.SORT_FIELD_MAP.keys())}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
clone = self._clone()
|
|
178
|
+
clone._sort_field = api_field
|
|
179
|
+
clone._sort_order = direction
|
|
180
|
+
return clone
|