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,933 @@
1
+ """Award model for USASpending data."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Dict, Any, Optional, List, TYPE_CHECKING
5
+ from functools import cached_property
6
+ from datetime import date
7
+ from decimal import Decimal
8
+
9
+ from .lazy_record import LazyRecord
10
+ from .recipient import Recipient
11
+ from .location import Location
12
+ from .period_of_performance import PeriodOfPerformance
13
+ from .agency import Agency
14
+ from .subtier_agency import SubTierAgency
15
+ from .download import AwardType, FileFormat
16
+
17
+ from ..exceptions import ValidationError
18
+ from ..logging_config import USASpendingLogger
19
+ from ..utils.formatter import smart_sentence_case, to_decimal, to_date
20
+
21
+ if TYPE_CHECKING:
22
+ from ..client import USASpendingClient
23
+ from ..queries.transactions_search import TransactionsSearch
24
+ from ..queries.funding_search import FundingSearch
25
+ from ..queries.subawards_search import SubAwardsSearch
26
+ from ..download.job import DownloadJob
27
+
28
+ logger = USASpendingLogger.get_logger(__name__)
29
+
30
+
31
+ class Award(LazyRecord):
32
+ """Rich wrapper around a USAspending award record."""
33
+
34
+ # Base fields common to all award types
35
+ SEARCH_FIELDS = [
36
+ "Award ID",
37
+ "recipient_id",
38
+ "Recipient Name",
39
+ "Recipient DUNS Number",
40
+ "Recipient UEI",
41
+ "Recipient Location",
42
+ "Awarding Agency",
43
+ "Awarding Agency Code",
44
+ "Awarding Sub Agency",
45
+ "Awarding Sub Agency Code",
46
+ "Funding Agency",
47
+ "Funding Agency Code",
48
+ "Funding Sub Agency",
49
+ "Funding Sub Agency Code",
50
+ "Place of Performance City Code",
51
+ "Place of Performance State Code",
52
+ "Place of Performance Country Code",
53
+ "Place of Performance Zip5",
54
+ "Description",
55
+ "Last Modified Date",
56
+ "Base Obligation Date",
57
+ "prime_award_recipient_id",
58
+ "generated_internal_id",
59
+ "def_codes",
60
+ "COVID-19 Obligations",
61
+ "COVID-19 Outlays",
62
+ "Infrastructure Obligations",
63
+ "Infrastructure Outlays",
64
+ "Primary Place of Performance",
65
+ ]
66
+
67
+ def __init__(self, data_or_id: Dict[str, Any] | str, client: USASpendingClient):
68
+ """
69
+ Initialize Award instance.
70
+
71
+ Args:
72
+ data_or_id (Dict[str, Any] | str): Either a dictionary containing award data (must include 'generated_unique_award_id'), or a string representing the unique award identifier. If a dictionary is provided with additional properties, those will be used to populate the instance.
73
+ client (USASpendingClient): USASpendingClient client instance.
74
+
75
+ Raises:
76
+ ValidationError: If data_or_id is not a dict or string, or if required keys are missing.
77
+ """
78
+ # Use the base validation method
79
+ raw = self.validate_init_data(
80
+ data_or_id,
81
+ "Award",
82
+ id_field="generated_unique_award_id",
83
+ allow_string_id=True,
84
+ )
85
+ super().__init__(raw, client)
86
+
87
+ def _fetch_details(self) -> Optional[Dict[str, Any]]:
88
+ """Fetch full award details from the awards resource.
89
+
90
+ Returns:
91
+ Optional[Dict[str, Any]]: Award data dictionary or None if fetch fails.
92
+ """
93
+ award_id = self.generated_unique_award_id
94
+ if not award_id:
95
+ raise ValidationError(
96
+ "Cannot lazy-load Award data. Property `generated_unique_award_id` is required to fetch details."
97
+ )
98
+ try:
99
+ # Use the awards resource to get full award data
100
+ full_award = self._client.awards.find_by_generated_id(award_id)
101
+ full_data = full_award.raw
102
+
103
+ # If we're a base Award class and now have type information,
104
+ # convert to appropriate subclass
105
+ if full_data and self.__class__ == Award:
106
+ from .award_factory import create_award
107
+
108
+ new_instance = create_award(full_data, self._client)
109
+ if new_instance.__class__ != Award:
110
+ # Copy state from new instance to self
111
+ self.__class__ = new_instance.__class__
112
+ # Merge the data
113
+ self._data.update(full_data)
114
+ return full_data
115
+
116
+ return full_data
117
+ except Exception:
118
+ logger.error(
119
+ f"Failed to fetch full details for Award ID {award_id}. "
120
+ "Check if the ID is valid and the client is configured correctly."
121
+ )
122
+ raise
123
+
124
+ # Core Award properties
125
+ @property
126
+ def id(self) -> Optional[int]:
127
+ """Internal USASpending database ID for this award.
128
+
129
+ Returns:
130
+ Optional[int]: Internal USASpending database ID.
131
+ """
132
+ return self._lazy_get("id", "internal_id")
133
+
134
+ @property
135
+ def generated_unique_award_id(self) -> Optional[str]:
136
+ """The award identifier used across USASpending and its Broker systems.
137
+
138
+ The code is created by combining various identifiers of award type, awarding
139
+ agency codes, and other standard identifiers:
140
+
141
+ Procurement Award ID Formats (FPDS):
142
+ `upper('CONT_AWD' + '_' + Coalesce(<piid>,'-NONE-') + '_' + Coalesce(<agency_id>,'-NONE-') + '_' + Coalesce(<parent_award_id>,'-NONE-') + '_' + Coalesce(<referenced_idv_agency_iden>,'-NONE-'))`
143
+ `upper('CONT_IDV' + '_' + Coalesce(<piid>,'-NONE-') + '_' + Coalesce(<agency_id>,'-NONE-'))`
144
+
145
+ Assistance Award ID Formats (FABS)
146
+ `upper('ASST_AGG' + '_' + Coalesce(<uri>,'-NONE-') + '_' + Coalesce(<awarding_sub_tier_agency_c>,'-NONE-'))`
147
+ `upper('ASST_NON' + '_' + Coalesce(<fain>,'-NONE-') + '_' + Coalesce(<awarding_sub_tier_agency_c>,'-NONE-'))`
148
+
149
+ Returns:
150
+ Optional[str]: The generated unique award identifier.
151
+ """
152
+ # This cannot be lazy-loaded since it's required to fetch details
153
+ return self.get_value(["generated_unique_award_id", "generated_internal_id"])
154
+
155
+ def _derived_award_identifier(self) -> Optional[str]:
156
+ """
157
+ Extract the award identifier (PIID, FAIN, or URI) from generated_unique_award_id.
158
+
159
+ Parses the generated ID format to extract the original identifier:
160
+ - CONT_AWD_<piid>_<agency>_<parent>_<ref> -> returns piid
161
+ - CONT_IDV_<piid>_<agency> -> returns piid
162
+ - ASST_NON_<fain>_<agency> -> returns fain
163
+ - ASST_AGG_<uri>_<agency> -> returns uri
164
+
165
+ Returns:
166
+ The extracted identifier or None if not found or is "-NONE-"
167
+ """
168
+ gen_id = self.generated_unique_award_id
169
+ if not gen_id:
170
+ return None
171
+
172
+ try:
173
+ parts = gen_id.split("_")
174
+
175
+ # Validate minimum parts based on format
176
+ if len(parts) < 3:
177
+ return None
178
+
179
+ prefix = "_".join(parts[:2]) # e.g., "CONT_AWD" or "ASST_NON"
180
+
181
+ # Validate expected number of parts for each format
182
+ if prefix == "CONT_AWD" and len(parts) != 6:
183
+ return None
184
+ elif prefix == "CONT_IDV" and len(parts) != 4:
185
+ return None
186
+ elif prefix in ("ASST_NON", "ASST_AGG") and len(parts) != 4:
187
+ return None
188
+ elif prefix not in ("CONT_AWD", "CONT_IDV", "ASST_NON", "ASST_AGG"):
189
+ return None
190
+
191
+ identifier = parts[2] # The actual ID is always the 3rd segment
192
+
193
+ # Don't return placeholder values
194
+ if identifier == "-NONE-" or not identifier:
195
+ return None
196
+
197
+ return identifier
198
+ except (IndexError, AttributeError):
199
+ return None
200
+
201
+ @property
202
+ def award_identifier(self) -> str:
203
+ """General-purpose award identifier, type-agnostic.
204
+
205
+ Specific property getters for PIID, FAIN, and URI are implemented in subclasses such as Contract, Grant, and IDV.
206
+ Use this property for a unified identifier, and refer to subclass documentation for type-specific identifiers.
207
+
208
+ Returns:
209
+ str: The award identifier (PIID, FAIN, or URI).
210
+
211
+ Example:
212
+ contract = Contract(...)
213
+ print(contract.award_identifier) # Returns PIID for contracts
214
+ """
215
+ # Derive from generated_unique_award_id
216
+ derived_award_id = self._derived_award_identifier()
217
+ return derived_award_id if derived_award_id else ""
218
+
219
+ @property
220
+ def category(self) -> str:
221
+ """Plain English description of the award type.
222
+
223
+ Returns:
224
+ str: One of "contract", "grant", "idv", "loan", or "other" if unknown.
225
+ """
226
+ return self._lazy_get("category", default="")
227
+
228
+ @property
229
+ def type(self) -> Optional[str]:
230
+ """Award's subtype code.
231
+
232
+ See `award_types.py` for all valid codes.
233
+
234
+ Returns:
235
+ Optional[str]: The award subtype code.
236
+ """
237
+ return self._lazy_get("type", default="")
238
+
239
+ @property
240
+ def award_type_code(self) -> Optional[str]:
241
+ """More expressive property name for `type` to avoid confusion with Python built-in.
242
+
243
+ Returns:
244
+ Optional[str]: The award subtype code.
245
+ """
246
+ return self.type
247
+
248
+ @property
249
+ def type_description(self) -> Optional[str]:
250
+ """Plain text description of the award type.
251
+
252
+ Retrieves a description for the award type using a lazy getter,
253
+ prioritizing "Contract Award Type" and "Award Type" keys.
254
+
255
+ Returns:
256
+ Optional[str]: The description of the award type, or empty string if not available.
257
+ """
258
+ return self._lazy_get(
259
+ "type_description", "Contract Award Type", "Award Type", default=""
260
+ )
261
+
262
+ @property
263
+ def description(self) -> str:
264
+ """Brief, plain English summary of the award.
265
+
266
+ Returns:
267
+ str: The award description in sentence case, or empty string if not available.
268
+ """
269
+ desc = self._lazy_get("description", "Description")
270
+ if isinstance(desc, str):
271
+ return smart_sentence_case(desc)
272
+ return ""
273
+
274
+ @property
275
+ def total_obligation(self) -> Decimal:
276
+ """The amount of money the government is obligated to pay for the award.
277
+
278
+ This is a system generated element providing the sum of all the amounts
279
+ entered in the "Action Obligation" field for a particular PIID and Agency.
280
+
281
+ Example: Contract has 9 Modifications under "Transaction Number" as '1'
282
+ and 9 modifications with the same PIID under "Transaction Number" as '2'.
283
+ The base contracts and all the modifications have "Action Obligation" as $10
284
+ each. The value for the field "Total Obligated Amount" when the either of
285
+ the bases or the modification is retrieved through atom feeds will be $200
286
+ ($100 under Transaction Number 1 + $100 under Transaction Number 2).
287
+ "Total Obligated Amount" is generated irrespective of the "Transaction Number"
288
+ on the Awards.
289
+
290
+ Returns:
291
+ Decimal: The total obligated amount for the award or 0.00.
292
+
293
+ """
294
+ return to_decimal(
295
+ self._lazy_get("total_obligation", "Award Amount")
296
+ ) or Decimal("0.00")
297
+
298
+ @property
299
+ def subaward_count(self) -> int:
300
+ """Number of subawards associated with this award.
301
+
302
+ Returns:
303
+ int: The count of subawards.
304
+ """
305
+ return int(self._lazy_get("subaward_count", default=0))
306
+
307
+ @property
308
+ def total_subaward_amount(self) -> Optional[Decimal]:
309
+ """Total amount of subawards for this award.
310
+
311
+ Returns:
312
+ Optional[Decimal]: The total subaward amount, or None if not available.
313
+ """
314
+ return to_decimal(self._lazy_get("total_subaward_amount", default=None))
315
+
316
+ @property
317
+ def date_signed(self) -> Optional[date]:
318
+ """Date the award was signed by the Government or a binding agreement was reached.
319
+
320
+ Returns:
321
+ Optional[date]: The date the award was signed, or None if not available.
322
+ """
323
+ return to_date(
324
+ self._lazy_get("date_signed", "Base Obligation Date", default=None)
325
+ )
326
+
327
+ @property
328
+ def base_obligation_date(self) -> Optional[date]:
329
+ """Base obligation date for the award (alias for date_signed).
330
+
331
+ Returns:
332
+ Optional[date]: The base obligation date, or None if not available.
333
+ """
334
+ return self.date_signed
335
+
336
+ @property
337
+ def total_account_outlay(self) -> Optional[Decimal]:
338
+ """Total amount of money paid out for the award from associated federal accounts.
339
+
340
+ Returns:
341
+ Optional[Decimal]: The total account outlay amount, or None if not available.
342
+ """
343
+ return to_decimal(self._lazy_get("total_account_outlay", default=None))
344
+
345
+ @property
346
+ def total_account_obligation(self) -> Optional[Decimal]:
347
+ """Total amount obligated for this award.
348
+
349
+ Returns:
350
+ Optional[Decimal]: The total account obligation amount, or None if not available.
351
+ """
352
+ return to_decimal(self._lazy_get("total_account_obligation", default=None))
353
+
354
+ @property
355
+ def total_outlay(self) -> Optional[Decimal]:
356
+ """Total outlay amount for the award.
357
+
358
+ Returns:
359
+ Optional[Decimal]: The total outlay amount, or None if not available.
360
+ """
361
+ return to_decimal(self._lazy_get("total_outlay", "Total Outlays", default=None))
362
+
363
+ @property
364
+ def account_outlays_by_defc(self) -> List[Dict[str, Any]]:
365
+ """Outlays broken down by Disaster Emergency Fund Code (DEFC).
366
+
367
+ Returns:
368
+ List[Dict[str, Any]]: List of outlay records by DEFC code.
369
+ """
370
+ return self._lazy_get("account_outlays_by_defc", default=[])
371
+
372
+ @property
373
+ def account_obligations_by_defc(self) -> List[Dict[str, Any]]:
374
+ """Obligations broken down by Disaster Emergency Fund Code (DEFC).
375
+
376
+ Returns:
377
+ List[Dict[str, Any]]: List of obligation records by DEFC code.
378
+ """
379
+ return self._lazy_get("account_obligations_by_defc", default=[])
380
+
381
+ @cached_property
382
+ def parent_award(self) -> Optional[Award]:
383
+ """Reference to parent award for child awards.
384
+
385
+ Returns:
386
+ Optional[Award]: The parent award object, or None if this is a parent award.
387
+ """
388
+ data = self._lazy_get("parent_award")
389
+ from .award_factory import create_award
390
+
391
+ return create_award(data, self._client) if data else None
392
+
393
+ @cached_property
394
+ def executive_details(self) -> Optional[Dict[str, Any]]:
395
+ """Executive compensation details for the award recipient.
396
+
397
+ Returns:
398
+ Optional[Dict[str, Any]]: Executive compensation data, or None if not available.
399
+ """
400
+ return self._lazy_get("executive_details")
401
+
402
+ @property
403
+ def recipient_uei(self) -> Optional[str]:
404
+ """Recipient Unique Entity Identifier (UEI).
405
+
406
+ Returns:
407
+ Optional[str]: The recipient's UEI, or None if not available.
408
+ """
409
+ # Try nested recipient object if available
410
+ if self.recipient and self.recipient.uei:
411
+ uei = self.recipient.uei
412
+ else:
413
+ uei = self._lazy_get("recipient_uei", "Recipient UEI")
414
+
415
+ return uei
416
+
417
+ @property
418
+ def covid19_obligations(self) -> Decimal:
419
+ """COVID-19 related obligations amount.
420
+
421
+ Returns:
422
+ Decimal: The COVID-19 obligations amount, or 0.00 if not available.
423
+ """
424
+ return to_decimal(
425
+ self._lazy_get("covid19_obligations", "COVID-19 Obligations", default=0)
426
+ ) or Decimal("0.00")
427
+
428
+ @property
429
+ def covid19_outlays(self) -> Decimal:
430
+ """COVID-19 related outlays amount.
431
+
432
+ Returns:
433
+ Decimal: The COVID-19 outlays amount, or 0.00 if not available.
434
+ """
435
+ return to_decimal(
436
+ self._lazy_get("covid19_outlays", "COVID-19 Outlays", default=0)
437
+ ) or Decimal("0.00")
438
+
439
+ @property
440
+ def infrastructure_obligations(self) -> Decimal:
441
+ """Infrastructure related obligations amount.
442
+
443
+ Returns:
444
+ Decimal: The infrastructure obligations amount, or 0.00 if not available.
445
+ """
446
+ return to_decimal(
447
+ self._lazy_get(
448
+ "infrastructure_obligations", "Infrastructure Obligations", default=0
449
+ )
450
+ ) or Decimal("0.00")
451
+
452
+ @property
453
+ def infrastructure_outlays(self) -> Decimal:
454
+ """Infrastructure related outlays amount.
455
+
456
+ Returns:
457
+ Decimal: The infrastructure outlays amount, or 0.00 if not available.
458
+ """
459
+ return to_decimal(
460
+ self._lazy_get(
461
+ "infrastructure_outlays", "Infrastructure Outlays", default=0
462
+ )
463
+ ) or Decimal("0.00")
464
+
465
+ # Helper properties properties. These often map to field names returned by
466
+ # the spending_by_award/Award Search results, or provide general access methods
467
+ # that are common across award types.
468
+
469
+ @property
470
+ def award_amount(self) -> Decimal:
471
+ """General helper for total obligated or loaned amount.
472
+
473
+ Returns:
474
+ Decimal: The total award amount, or 0.00 if not available.
475
+ """
476
+ return to_decimal(
477
+ self._lazy_get(
478
+ "Award Amount", "Loan Amount", "total_obligation", "total_funding"
479
+ )
480
+ ) or Decimal("0.00")
481
+
482
+ @property
483
+ def start_date(self) -> Optional[date]:
484
+ """Award start date from period of performance or obligation data.
485
+
486
+ Returns:
487
+ Optional[date]: The award start date, or None if not available.
488
+ """
489
+ start_date = self.get_value(
490
+ ["Start Date", "Base Obligation Date", "Period of Performance Start Date"]
491
+ )
492
+ if not start_date:
493
+ if self.period_of_performance and self.period_of_performance.start_date:
494
+ start_date = self.period_of_performance.start_date
495
+ return to_date(start_date)
496
+
497
+ @property
498
+ def end_date(self) -> Optional[date]:
499
+ """Award end date from period of performance data.
500
+
501
+ Returns:
502
+ Optional[date]: The award end date, or None if not available.
503
+ """
504
+ end_date = self.get_value(["End Date", "Period of Performance End Date"])
505
+ if not end_date:
506
+ if self.period_of_performance and self.period_of_performance.end_date:
507
+ end_date = self.period_of_performance.end_date
508
+ return to_date(end_date)
509
+
510
+ @property
511
+ def usa_spending_url(self) -> str:
512
+ """USASpending.gov public URL for this award.
513
+
514
+ Returns:
515
+ str: The public URL for this award, or empty string if award ID unavailable.
516
+ """
517
+ award_id = self.generated_unique_award_id
518
+ if award_id and isinstance(award_id, str):
519
+ return f"https://www.usaspending.gov/award/{award_id}/"
520
+ else:
521
+ return ""
522
+
523
+ # Properties that return complex objects and related award data
524
+ #
525
+ # Currently implemented are:
526
+ #
527
+ # Belongs To (one-to-one relationships):
528
+ # - parent_award (Award object: parent award if this is a child award)
529
+ # - recipient (Recipient object: details about the award recipient)
530
+ # - funding_agency (Agency object: details about the funding agency)
531
+ # - awarding_agency (Agency object: details about the awarding agency)
532
+ # - funding_subtier_agency (SubTierAgency object: details about the funding subtier agency)
533
+ # - awarding_subtier_agency (SubTierAgency object: details about the awarding subtier agency)
534
+ #
535
+ # Has One (one-to-one relationships):
536
+ # - period_of_performance (PlaceOfPerformance object: Start and End dates for award)
537
+ # - place_of_performance (Location object: location where the work is performed)
538
+ #
539
+ # Has Many (one-to-many relationships):
540
+ # - transactions (TransactionsSearch object: query builder for transactions associated with the award)
541
+ # - funding (FundingSearch object: query builder for treasury funding records (outlay and obligation) associated with the award)
542
+ # - subawards (SubAwardsSearch object: query builder for subawards associated with the award)
543
+
544
+ @cached_property
545
+ def period_of_performance(self) -> Optional[PeriodOfPerformance]:
546
+ """Award period of performance dates.
547
+
548
+ Returns:
549
+ Optional[PeriodOfPerformance]: Period of performance object with start/end dates, or None.
550
+ """
551
+ if "period_of_performance" in self.raw and isinstance(
552
+ self.raw.get("period_of_performance"), dict
553
+ ):
554
+ return PeriodOfPerformance(self.raw.get("period_of_performance"))
555
+
556
+ # Award search results return Period of Performance information in a flat structure
557
+ # We need to assign these values to a PeriodOfPerformance object
558
+ # to maintain consistency.
559
+ date_keys = ["Start Date", "End Date", "Last Modified Date"]
560
+ if any(k in self._data for k in date_keys):
561
+ return PeriodOfPerformance(
562
+ {
563
+ "start_date": self.get_value(
564
+ [
565
+ "Start Date",
566
+ "Base Obligation Date",
567
+ "Period of Performance Start Date",
568
+ ]
569
+ ),
570
+ "end_date": self.get_value(
571
+ ["End Date", "Period of Performance Current End Date"]
572
+ ),
573
+ "last_modified_date": self.get_value("Last Modified Date"),
574
+ }
575
+ )
576
+
577
+ # If no data, trigger fetch
578
+ self._ensure_details()
579
+ return PeriodOfPerformance(self.get_value("period_of_performance"))
580
+
581
+ @cached_property
582
+ def place_of_performance(self) -> Optional[Location]:
583
+ """Award place of performance location.
584
+
585
+ Returns:
586
+ Optional[Location]: Location object for where work is performed, or None.
587
+ """
588
+ data = self._lazy_get(
589
+ "place_of_performance", "Primary Place of Performance", default=None
590
+ )
591
+ if not isinstance(data, dict) or not data:
592
+ return None
593
+
594
+ # Check if all values in the dict are None/null (common for IDV awards)
595
+ if all(v is None for v in data.values()):
596
+ return None
597
+
598
+ return Location(data, self._client)
599
+
600
+ @cached_property
601
+ def recipient(self) -> Optional[Recipient]:
602
+ """Award recipient with lazy loading.
603
+
604
+ Returns:
605
+ Optional[Recipient]: Recipient object with award recipient details, or None.
606
+ """
607
+ # First check if we already have a nested recipient object
608
+ if "recipient" in self._data and isinstance(self._data["recipient"], dict):
609
+ return Recipient(self._data["recipient"], self._client)
610
+
611
+ # Then, check for flat recipient fields from search results
612
+ recipient_keys = ["Recipient Name", "recipient_id", "Recipient Location"]
613
+ if any(key in self._data for key in recipient_keys):
614
+ recipient_data = {
615
+ "recipient_name": self._data.get("Recipient Name"),
616
+ "recipient_unique_id": self._data.get("Recipient DUNS Number"),
617
+ "recipient_id": self._data.get("recipient_id"),
618
+ "recipient_hash": self._data.get("recipient_hash"),
619
+ "recipient_uei": self._data.get("Recipient UEI"),
620
+ }
621
+ recipient = Recipient(recipient_data, self._client)
622
+ if "Recipient Location" in self._data and isinstance(
623
+ self._data["Recipient Location"], dict
624
+ ):
625
+ recipient.location = Location(
626
+ self._data["Recipient Location"], self._client
627
+ )
628
+ return recipient
629
+
630
+ # If no recipient data is available locally, trigger a fetch
631
+ self._ensure_details()
632
+ if "recipient" in self._data and isinstance(self._data["recipient"], dict):
633
+ return Recipient(self._data["recipient"], self._client)
634
+
635
+ return None
636
+
637
+ def _load_agency_data(self, agency_type: str) -> Optional[Dict[str, Any]]:
638
+ """Load agency data from either nested or flat structure.
639
+
640
+ Args:
641
+ agency_type (str): Either "funding" or "awarding".
642
+
643
+ Returns:
644
+ Optional[Dict[str, Any]]: Processed agency data dict or None if not available.
645
+ """
646
+ if agency_type not in ["funding", "awarding"]:
647
+ raise ValueError(f"Invalid agency_type: {agency_type}")
648
+
649
+ # Define field mappings based on agency type
650
+ if agency_type == "funding":
651
+ nested_key = "funding_agency"
652
+ flat_keys = [
653
+ "Funding Agency",
654
+ "Funding Agency Code",
655
+ "Funding Sub Agency",
656
+ "Funding Sub Agency Code",
657
+ ]
658
+ name_key = "Funding Agency"
659
+ code_key = "Funding Agency Code"
660
+ sub_name_key = "Funding Sub Agency"
661
+ sub_code_key = "Funding Sub Agency Code"
662
+ # No funding_agency_id available in search results
663
+ id_key = None
664
+ else: # awarding
665
+ nested_key = "awarding_agency"
666
+ flat_keys = [
667
+ "Awarding Agency",
668
+ "Awarding Agency Code",
669
+ "Awarding Sub Agency",
670
+ "Awarding Sub Agency Code",
671
+ ]
672
+ name_key = "Awarding Agency"
673
+ code_key = "Awarding Agency Code"
674
+ sub_name_key = "Awarding Sub Agency"
675
+ sub_code_key = "Awarding Sub Agency Code"
676
+ id_key = "awarding_agency_id"
677
+
678
+ # First check if we have nested agency data (from full award details)
679
+ if self.raw.get(nested_key):
680
+ return self.raw.get(nested_key)
681
+
682
+ # Then check for flat agency fields (from search results)
683
+ if any(key in self.raw for key in flat_keys):
684
+ data = {
685
+ "toptier_agency": {
686
+ "name": self.raw.get(name_key),
687
+ "code": self.raw.get(code_key), # Agency code
688
+ "abbreviation": self.raw.get(code_key),
689
+ },
690
+ "subtier_agency": {
691
+ "name": self.raw.get(sub_name_key),
692
+ "code": self.raw.get(sub_code_key), # Subtier code
693
+ "abbreviation": self.raw.get(sub_code_key),
694
+ },
695
+ "id": self.raw.get(id_key) if id_key else None,
696
+ "has_agency_page": False, # Not available in search results
697
+ "office_agency_name": None, # Not available in search results
698
+ }
699
+ return data
700
+
701
+ # Finally try lazy loading
702
+ return self._lazy_get(nested_key)
703
+
704
+ @cached_property
705
+ def funding_agency(self) -> Optional[Agency]:
706
+ """Funding agency information.
707
+
708
+ Returns:
709
+ Optional[Agency]: Agency object for the funding agency, or None.
710
+ """
711
+ data = self._load_agency_data("funding")
712
+
713
+ if not data:
714
+ return None
715
+
716
+ # Extract toptier data and merge with top-level agency fields
717
+ toptier_data = data.get("toptier_agency", {})
718
+ agency_data = {
719
+ "agency_id": data.get("id"),
720
+ "has_agency_page": data.get("has_agency_page"),
721
+ "office_agency_name": data.get("office_agency_name"),
722
+ **toptier_data, # Merge toptier fields (name, code, abbreviation, slug)
723
+ }
724
+
725
+ subtier_data = data.get("subtier_agency")
726
+ return Agency(agency_data, self._client, subtier_data)
727
+
728
+ @cached_property
729
+ def awarding_agency(self) -> Optional[Agency]:
730
+ """Awarding agency information.
731
+
732
+ Returns:
733
+ Optional[Agency]: Agency object for the awarding agency, or None.
734
+ """
735
+ data = self._load_agency_data("awarding")
736
+
737
+ if not data:
738
+ return None
739
+
740
+ # Extract toptier data and merge with top-level agency fields
741
+ toptier_data = data.get("toptier_agency", {})
742
+ agency_data = {
743
+ "agency_id": data.get("id"),
744
+ "has_agency_page": data.get("has_agency_page"),
745
+ "office_agency_name": data.get("office_agency_name"),
746
+ **toptier_data, # Merge toptier fields (name, code, abbreviation, slug)
747
+ }
748
+
749
+ subtier_data = data.get("subtier_agency")
750
+ return Agency(agency_data, self._client, subtier_data)
751
+
752
+ @cached_property
753
+ def funding_subtier_agency(self) -> Optional["SubTierAgency"]:
754
+ """Funding subtier agency information.
755
+
756
+ Returns:
757
+ Optional[SubTierAgency]: SubTierAgency object for the funding subtier, or None.
758
+ """
759
+ data = self._load_agency_data("funding")
760
+
761
+ if not data:
762
+ return None
763
+
764
+ subtier_data = data.get("subtier_agency")
765
+ if not subtier_data:
766
+ return None
767
+
768
+ # Create a copy and add office_agency_name if available
769
+ enhanced_subtier_data = subtier_data.copy()
770
+ office_name = data.get("office_agency_name")
771
+ if office_name:
772
+ enhanced_subtier_data["office_agency_name"] = office_name
773
+
774
+ from .subtier_agency import SubTierAgency
775
+
776
+ return SubTierAgency(enhanced_subtier_data, self._client)
777
+
778
+ @cached_property
779
+ def awarding_subtier_agency(self) -> Optional["SubTierAgency"]:
780
+ """Awarding subtier agency information.
781
+
782
+ Returns:
783
+ Optional[SubTierAgency]: SubTierAgency object for the awarding subtier, or None.
784
+ """
785
+ data = self._load_agency_data("awarding")
786
+
787
+ if not data:
788
+ return None
789
+
790
+ subtier_data = data.get("subtier_agency")
791
+ if not subtier_data:
792
+ return None
793
+
794
+ # Create a copy and add office_agency_name if available
795
+ enhanced_subtier_data = subtier_data.copy()
796
+ office_name = data.get("office_agency_name")
797
+ if office_name:
798
+ enhanced_subtier_data["office_agency_name"] = office_name
799
+
800
+ from .subtier_agency import SubTierAgency
801
+
802
+ return SubTierAgency(enhanced_subtier_data, self._client)
803
+
804
+ @property
805
+ def transactions(self) -> "TransactionsSearch":
806
+ """Get transactions query builder for this award.
807
+
808
+ Returns a TransactionsSearch object that can be further filtered and chained.
809
+
810
+ Examples:
811
+ >>> award.transactions.count() # Get count without loading all data
812
+ >>> award.transactions.limit(10).all() # Get first 10 transactions
813
+ >>> list(award.transactions) # Iterate through all transactions
814
+ """
815
+ return self._client.transactions.award_id(self.generated_unique_award_id)
816
+
817
+ @property
818
+ def funding(self) -> "FundingSearch":
819
+ """Get funding query builder for this award.
820
+
821
+ Returns a FundingSearch object that can be further filtered and chained.
822
+
823
+ Examples:
824
+ >>> award.funding.count() # Get count without loading all data
825
+ >>> award.funding.order_by("fiscal_date", "asc").all() # Get all funding records sorted by date
826
+ >>> list(award.funding.limit(10)) # Iterate through first 10 funding records
827
+ """
828
+ return self._client.funding.award_id(self.generated_unique_award_id)
829
+
830
+ @property
831
+ def subawards(self) -> "SubAwardsSearch":
832
+ """Get subawards query builder for this award.
833
+
834
+ Returns:
835
+ SubAwardsSearch: Query builder object for subawards (implemented in subclasses).
836
+
837
+ Note:
838
+ Implemented in subclasses.
839
+ """
840
+ raise NotImplementedError()
841
+
842
+ # Downloading detailed award data
843
+ @property
844
+ def _download_type(self) -> Optional[AwardType]:
845
+ """Type required by the download API.
846
+
847
+ Returns:
848
+ Optional[AwardType]: Download type ('contract', 'assistance', or 'idv').
849
+
850
+ Note:
851
+ Implementation varies by model subclass.
852
+ """
853
+ from .contract import Contract
854
+ from .grant import Grant
855
+ from .idv import IDV
856
+
857
+ if isinstance(self, Contract):
858
+ return "contract"
859
+ elif isinstance(self, Grant):
860
+ return "assistance"
861
+ elif isinstance(self, IDV):
862
+ return "idv"
863
+ else:
864
+ raise (NotImplementedError)
865
+
866
+ def download(
867
+ self, file_format: FileFormat = "csv", destination_dir: Optional[str] = None
868
+ ) -> "DownloadJob":
869
+ """
870
+ Queue a download job for this award's detailed data.
871
+
872
+ This utilizes the USASpending bulk download API, which queues the request
873
+ and processes it asynchronously.
874
+
875
+ Args:
876
+ file_format: The format of the file(s) in the zip file containing the data
877
+ destination_dir: Directory where the file will be saved (defaults to CWD).
878
+
879
+ Returns:
880
+ A DownloadJob object. Use job.wait_for_completion() to block until finished.
881
+
882
+ Raises:
883
+ ConfigurationError: If the Award instance lacks a client reference.
884
+ ValidationError: If the award ID or download type is missing/invalid.
885
+
886
+ Example:
887
+ >>> contract = client.awards.find_by_generated_id("CONT_AWD_123...")
888
+ >>> job = contract.download(destination_dir="./data")
889
+ >>> print(f"Job queued: {job.file_name}. Waiting...")
890
+ >>> extracted_files = job.wait_for_completion(timeout=600)
891
+ >>> print(f"Download complete. Files: {extracted_files}")
892
+ """
893
+
894
+ award_id = self.generated_unique_award_id
895
+
896
+ if not award_id:
897
+ # If we don't have an award ID, we cannot proceed
898
+ raise ValidationError(
899
+ "Cannot download award data without a 'generated_unique_award_id'. Ensure the award object is fully loaded."
900
+ )
901
+
902
+ download_type = self._download_type
903
+
904
+ if not download_type:
905
+ # Safety check in case a subclass doesn't implement _download_type or the implementation returns None
906
+ raise ValidationError(
907
+ f"Download is not supported or implemented for award type: {self.__class__.__name__}."
908
+ )
909
+
910
+ # Access the DownloadManager via the client's download resource.
911
+ # We route the call through the appropriate method on the resource.
912
+ if download_type == "contract":
913
+ return self._client.downloads.contract(
914
+ award_id, file_format, destination_dir
915
+ )
916
+ elif download_type == "assistance":
917
+ return self._client.downloads.assistance(
918
+ award_id, file_format, destination_dir
919
+ )
920
+ elif download_type == "idv":
921
+ return self._client.downloads.idv(award_id, file_format, destination_dir)
922
+ else:
923
+ raise NotImplementedError
924
+
925
+ def __repr__(self) -> str:
926
+ """String representation of Award.
927
+
928
+ Returns:
929
+ str: Formatted string showing award ID and recipient name.
930
+ """
931
+ recipient_name = self.recipient.name if self.recipient else "?"
932
+ award_id = self.award_identifier or self.generated_unique_award_id or "?"
933
+ return f"<Award {award_id} → {recipient_name}>"