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