Coverage for src/usaspending/models/award.py: 78%

293 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-03 17:15 -0700

1"""Award model for USASpending data.""" 

2 

3from __future__ import annotations 

4from typing import Dict, Any, Optional, List, TYPE_CHECKING 

5from functools import cached_property 

6from datetime import datetime 

7 

8from .lazy_record import LazyRecord 

9from .recipient import Recipient 

10from .location import Location 

11from .period_of_performance import PeriodOfPerformance 

12from .agency import Agency 

13from .subtier_agency import SubTierAgency 

14from .download import AwardType, FileFormat 

15 

16from ..exceptions import ValidationError 

17from ..logging_config import USASpendingLogger 

18from ..utils.formatter import smart_sentence_case, to_float, to_date 

19 

20if TYPE_CHECKING: 

21 from ..client import USASpending 

22 from ..queries.transactions_search import TransactionsSearch 

23 from ..queries.funding_search import FundingSearch 

24 from ..queries.subawards_search import SubAwardsSearch 

25 from ..download.job import DownloadJob 

26 

27logger = USASpendingLogger.get_logger(__name__) 

28 

29class Award(LazyRecord): 

30 """Rich wrapper around a USAspending award record.""" 

31 

32 # Base fields common to all award types 

33 SEARCH_FIELDS = [ 

34 "Award ID", 

35 "recipient_id", 

36 "Recipient Name", 

37 "Recipient DUNS Number", 

38 "Recipient UEI", 

39 "Recipient Location", 

40 "Awarding Agency", 

41 "Awarding Agency Code", 

42 "Awarding Sub Agency", 

43 "Awarding Sub Agency Code", 

44 "Funding Agency", 

45 "Funding Agency Code", 

46 "Funding Sub Agency", 

47 "Funding Sub Agency Code", 

48 "Place of Performance City Code", 

49 "Place of Performance State Code", 

50 "Place of Performance Country Code", 

51 "Place of Performance Zip5", 

52 "Description", 

53 "Last Modified Date", 

54 "Base Obligation Date", 

55 "prime_award_recipient_id", 

56 "generated_internal_id", 

57 "def_codes", 

58 "COVID-19 Obligations", 

59 "COVID-19 Outlays", 

60 "Infrastructure Obligations", 

61 "Infrastructure Outlays", 

62 "Primary Place of Performance", 

63 ] 

64 

65 def __init__( 

66 self, data_or_id: Dict[str, Any] | str, client: USASpending 

67 ): 

68 """Initialize Award instance. 

69 

70 Args: 

71 data_or_id: Award data dictionary or unique award ID string 

72 client: Optional USASpending client instance 

73 """ 

74 if isinstance(data_or_id, dict): 

75 raw = data_or_id.copy() 

76 elif isinstance(data_or_id, str): 

77 raw = {"generated_unique_award_id": data_or_id} 

78 else: 

79 raise ValidationError("Award expects a dict or an award_id string") 

80 super().__init__(raw, client) 

81 

82 def _fetch_details(self) -> Optional[Dict[str, Any]]: 

83 """Fetch full award details from the awards resource.""" 

84 award_id = self.generated_unique_award_id 

85 if not award_id: 

86 raise ValidationError( 

87 "Cannot lazy-load Award data. Property `generated_unique_award_id` is required to fetch details." 

88 ) 

89 try: 

90 # Use the awards resource to get full award data 

91 full_award = self._client.awards.find_by_generated_id(award_id) 

92 full_data = full_award.raw 

93 

94 # If we're a base Award class and now have type information, 

95 # convert to appropriate subclass 

96 if full_data and self.__class__ == Award: 

97 from .award_factory import create_award 

98 

99 new_instance = create_award(full_data, self._client) 

100 if new_instance.__class__ != Award: 

101 # Copy state from new instance to self 

102 self.__class__ = new_instance.__class__ 

103 # Merge the data 

104 self._data.update(full_data) 

105 return full_data 

106 

107 return full_data 

108 except Exception: 

109 logger.error( 

110 f"Failed to fetch full details for Award ID {award_id}. " 

111 "Check if the ID is valid and the client is configured correctly." 

112 ) 

113 raise 

114 

115 # Core Award properties 

116 @property 

117 def id(self) -> Optional[int]: 

118 """Internal USASpending database ID for this award.""" 

119 return self._lazy_get("id","internal_id") 

120 

121 @property 

122 def generated_unique_award_id(self) -> Optional[str]: 

123 """USASpending-generated unique award identifier.""" 

124 # This cannot be lazy-loaded since it's required to fetch details 

125 return self.get_value(["generated_unique_award_id", "generated_internal_id"]) 

126 

127 @property 

128 def award_identifier(self) -> str: 

129 """General-purpose award identifier, type-agnostic. 

130  

131 Award-type specific values are implemented in subclasses. 

132  

133 Returns: 

134 (PIID, FAIN, URI): str 

135 """ 

136 return str(self._lazy_get("Award ID", "piid", "fain", "uri", default="")) 

137 

138 @property 

139 def category(self) -> str: 

140 """Plain english description of the award type. 

141  

142 Returns: 

143 One of "contract", "grant", "idv", "loan", or "other" if unknown 

144 """ 

145 return self._lazy_get("category", default="") 

146 

147 @property 

148 def type(self) -> Optional[str]: 

149 """ 

150 The subtype award code (e.g. "A", "B", "C", etc. for contracts. 

151 See the Config file for mappings. 

152 """ 

153 return self._lazy_get("type", default="") 

154 

155 @property 

156 def award_type_code(self) -> Optional[str]: 

157 """ More expressive property name for `type` to avoid confusion with Python built-in.""" 

158 return self.type 

159 

160 @property 

161 def type_description(self) -> Optional[str]: 

162 """The plain text description of the type of the award""" 

163 return self._lazy_get("type_description", "Contract Award Type", "Award Type", default="") 

164 

165 @property 

166 def description(self) -> str: 

167 """ 

168 A brief, plain English summary of the award. 

169 """ 

170 desc = self._lazy_get("description", "Description") 

171 if isinstance(desc, str): 

172 return smart_sentence_case(desc) 

173 return "" 

174 

175 @property 

176 def total_obligation(self) -> float: 

177 """The amount of money the government is obligated to pay for the award 

178  

179 This is a system generated element providing the sum of all the amounts 

180 entered in the "Action Obligation" field for a particular PIID and Agency. 

181  

182 Example: Contract has 9 Modifications under "Transaction Number" as '1' 

183 and 9 modifications with the same PIID under "Transaction Number" as '2'. 

184 The base contracts and all the modifications have "Action Obligation" as $10 

185 each. The value for the field "Total Obligated Amount" when the either of 

186 the bases or the modification is retrieved through atom feeds will be $200 

187 ($100 under Transaction Number 1 + $100 under Transaction Number 2).  

188 "Total Obligated Amount" is generated irrespective of the "Transaction Number" 

189 on the Awards. 

190  

191 """ 

192 return to_float(self._lazy_get("total_obligation", "Award Amount")) or 0.0 

193 

194 @property 

195 def subaward_count(self) -> int: 

196 """The number of subawards associated with this award.""" 

197 return int(self._lazy_get("subaward_count", default=0)) 

198 

199 @property 

200 def total_subaward_amount(self) -> Optional[float]: 

201 """The total amount of subawards for this award.""" 

202 return to_float(self._lazy_get("total_subaward_amount", default=None)) 

203 

204 @property 

205 def date_signed(self) -> Optional[datetime]: 

206 """The date the award was signed by the Government or a binding agreement was reached.""" 

207 return to_date(self._lazy_get("date_signed", "Base Obligation Date", default=None)) 

208 

209 @property 

210 def base_obligation_date(self) -> Optional[datetime]: 

211 return self.date_signed 

212 

213 @property 

214 def total_account_outlay(self) -> Optional[float]: 

215 """The total amount of money that has been paid out for the award from the associated federal accounts""" 

216 return to_float( 

217 self._lazy_get("total_account_outlay", default=None) 

218 ) 

219 

220 @property 

221 def total_account_obligation(self) -> Optional[float]: 

222 """Total amount obligated for this award.""" 

223 return to_float(self._lazy_get("total_account_obligation", default=None)) 

224 

225 @property 

226 def total_outlay(self) -> Optional[float]: 

227 return self._lazy_get("total_outlay", "Total Outlays", default=None) 

228 

229 @property 

230 def total_account_obligation(self) -> Optional[float]: 

231 """Total amount obligated for this award.""" 

232 return to_float(self._lazy_get("total_account_obligation", default=None)) 

233 

234 @property 

235 def account_outlays_by_defc(self) -> List[Dict[str, Any]]: 

236 """Outlays broken down by Disaster Emergency Fund Code (DEFC).""" 

237 return self._lazy_get("account_outlays_by_defc", default=[]) 

238 

239 @property 

240 def account_obligations_by_defc(self) -> List[Dict[str, Any]]: 

241 """Obligations broken down by Disaster Emergency Fund Code (DEFC).""" 

242 return self._lazy_get("account_obligations_by_defc", default=[]) 

243 

244 @cached_property 

245 def parent_award(self) -> Optional[Award]: 

246 """Reference to parent award for child awards.""" 

247 data = self._lazy_get("parent_award") 

248 from .award_factory import create_award 

249 return create_award(data, self._client) if data else None 

250 

251 @cached_property 

252 def executive_details(self) -> Optional[Dict[str, Any]]: 

253 """Executive compensation details for the award recipient.""" 

254 return self._lazy_get("executive_details") 

255 

256 @property 

257 def recipient_uei(self) -> Optional[str]: 

258 """Recipient Unique Entity Identifier (UEI).""" 

259 uei = self._lazy_get("recipient_uei", "Recipient UEI") 

260 if not uei: 

261 # Try nested recipient object if available 

262 if self.recipient and self.recipient.uei: 

263 uei = self.recipient.uei 

264 return uei 

265 

266 @property 

267 def covid19_obligations(self) -> float: 

268 """COVID-19 related obligations amount.""" 

269 return to_float( 

270 self._lazy_get("covid19_obligations", "COVID-19 Obligations", default=0) 

271 ) 

272 

273 @property 

274 def covid19_outlays(self) -> float: 

275 """COVID-19 related outlays amount.""" 

276 return to_float( 

277 self._lazy_get("covid19_outlays", "COVID-19 Outlays", default=0) 

278 ) 

279 

280 @property 

281 def infrastructure_obligations(self) -> float: 

282 """Infrastructure related obligations amount.""" 

283 return to_float( 

284 self._lazy_get( 

285 "infrastructure_obligations", "Infrastructure Obligations", default=0 

286 ) 

287 ) 

288 

289 @property 

290 def infrastructure_outlays(self) -> float: 

291 """Infrastructure related outlays amount.""" 

292 return to_float( 

293 self._lazy_get( 

294 "infrastructure_outlays", "Infrastructure Outlays", default=0 

295 ) 

296 ) 

297 

298 

299 # Helper properties properties. These often map to field names returned by 

300 # the spending_by_award/Award Search results, or provide general access methods 

301 # that are common across award types. 

302 

303 @property 

304 def award_amount(self) -> float: 

305 """General helper total obligated/loaned amount.""" 

306 return to_float(self._lazy_get("Award Amount", "Loan Amount", "total_obligation","total_funding")) or 0.0 

307 

308 @property 

309 def start_date(self) -> Optional[datetime]: 

310 start_date = self.get_value(["Start Date", "Base Obligation Date", "Period of Performance Start Date"]) 

311 if not start_date: 

312 if self.period_of_performance and self.period_of_performance.start_date: 

313 start_date = self.period_of_performance.start_date 

314 return start_date 

315 

316 @property 

317 def end_date(self) -> Optional[datetime]: 

318 end_date = self.get_value(["End Date", "Period of Performance End Date"]) 

319 if not end_date: 

320 if self.period_of_performance and self.period_of_performance.end_date: 

321 end_date = self.period_of_performance.end_date 

322 return end_date 

323 

324 @property 

325 def usa_spending_url(self) -> str: 

326 """Return the USASpending.gov public URL for this award.""" 

327 award_id = self.generated_unique_award_id 

328 if award_id and isinstance(award_id, str): 

329 return f"https://www.usaspending.gov/award/{award_id}/" 

330 else: 

331 return "" 

332 

333 # Properties that return complex objects and related award data 

334 # 

335 # Currently implemented are: 

336 # 

337 # Belongs To (one-to-one relationships): 

338 # - parent_award (Award object: parent award if this is a child award) 

339 # - recipient (Recipient object: details about the award recipient) 

340 # - funding_agency (Agency object: details about the funding agency) 

341 # - awarding_agency (Agency object: details about the awarding agency) 

342 # - funding_subtier_agency (SubTierAgency object: details about the funding subtier agency) 

343 # - awarding_subtier_agency (SubTierAgency object: details about the awarding subtier agency) 

344 # 

345 # Has One (one-to-one relationships): 

346 # - period_of_performance (PlaceOfPerformance object: Start and End dates for award) 

347 # - place_of_performance (Location object: location where the work is performed) 

348 # 

349 # Has Many (one-to-many relationships): 

350 # - transactions (TransactionsSearch object: query builder for transactions associated with the award) 

351 # - funding (FundingSearch object: query builder for treasury funding records (outlay and obligation) associated with the award) 

352 # - subawards (SubAwardsSearch object: query builder for subawards associated with the award) 

353 

354 

355 @cached_property 

356 def period_of_performance(self) -> Optional[PeriodOfPerformance]: 

357 """Award period of performance dates.""" 

358 if "period_of_performance" in self.raw and isinstance(self.raw.get("period_of_performance"), dict): 

359 return PeriodOfPerformance(self.raw.get("period_of_performance")) 

360 

361 # Award search results return Period of Performance information in a flat structure 

362 # We need to assign these values to a PeriodOfPerformance object 

363 # to maintain consistency. 

364 date_keys = ["Start Date", "End Date", "Last Modified Date"] 

365 if any(k in self._data for k in date_keys): 

366 return PeriodOfPerformance( 

367 { 

368 "start_date": self.get_value(["Start Date", "Base Obligation Date", "Period of Performance Start Date"]), 

369 "end_date": self.get_value(["End Date", "Period of Performance Current End Date"]), 

370 "last_modified_date": self.get_value("Last Modified Date"), 

371 } 

372 ) 

373 

374 # If no data, trigger fetch 

375 self._ensure_details() 

376 return PeriodOfPerformance(self.get_value("period_of_performance")) 

377 

378 

379 @cached_property 

380 def place_of_performance(self) -> Optional[Location]: 

381 """Award place of performance location.""" 

382 data = self._lazy_get( 

383 "place_of_performance", "Primary Place of Performance", default=None 

384 ) 

385 if not isinstance(data, dict) or not data: 

386 return None 

387 

388 # Check if all values in the dict are None/null (common for IDV awards) 

389 if all(v is None for v in data.values()): 

390 return None 

391 

392 return Location(data, self._client) 

393 

394 @cached_property 

395 def recipient(self) -> Optional[Recipient]: 

396 """Award recipient with lazy loading.""" 

397 # First check if we already have a nested recipient object 

398 if "recipient" in self._data and isinstance(self._data["recipient"], dict): 

399 return Recipient(self._data["recipient"], self._client) 

400 

401 # Then, check for flat recipient fields from search results 

402 recipient_keys = ["Recipient Name", "recipient_id", "Recipient Location"] 

403 if any(key in self._data for key in recipient_keys): 

404 recipient_data = { 

405 "recipient_name": self._data.get("Recipient Name"), 

406 "recipient_unique_id": self._data.get("Recipient DUNS Number"), 

407 "recipient_id": self._data.get("recipient_id"), 

408 "recipient_hash": self._data.get("recipient_hash"), 

409 "recipient_uei": self._data.get("Recipient UEI"), 

410 } 

411 recipient = Recipient(recipient_data, self._client) 

412 if "Recipient Location" in self._data and isinstance(self._data["Recipient Location"], dict): 

413 recipient.location = Location(self._data["Recipient Location"], self._client) 

414 return recipient 

415 

416 # If no recipient data is available locally, trigger a fetch 

417 self._ensure_details() 

418 if "recipient" in self._data and isinstance(self._data["recipient"], dict): 

419 return Recipient(self._data["recipient"], self._client) 

420 

421 return None 

422 

423 def _load_agency_data(self, agency_type: str) -> Optional[Dict[str, Any]]: 

424 """Load agency data from either nested or flat structure. 

425  

426 Args: 

427 agency_type: Either "funding" or "awarding" 

428  

429 Returns: 

430 Processed agency data dict or None if not available 

431 """ 

432 if agency_type not in ["funding", "awarding"]: 

433 raise ValueError(f"Invalid agency_type: {agency_type}") 

434 

435 # Define field mappings based on agency type 

436 if agency_type == "funding": 

437 nested_key = "funding_agency" 

438 flat_keys = ["Funding Agency", "Funding Agency Code", 

439 "Funding Sub Agency", "Funding Sub Agency Code"] 

440 name_key = "Funding Agency" 

441 code_key = "Funding Agency Code" 

442 sub_name_key = "Funding Sub Agency" 

443 sub_code_key = "Funding Sub Agency Code" 

444 # No funding_agency_id available in search results 

445 id_key = None 

446 else: # awarding 

447 nested_key = "awarding_agency" 

448 flat_keys = ["Awarding Agency", "Awarding Agency Code", 

449 "Awarding Sub Agency", "Awarding Sub Agency Code"] 

450 name_key = "Awarding Agency" 

451 code_key = "Awarding Agency Code" 

452 sub_name_key = "Awarding Sub Agency" 

453 sub_code_key = "Awarding Sub Agency Code" 

454 id_key = "awarding_agency_id" 

455 

456 # First check if we have nested agency data (from full award details) 

457 if self.raw.get(nested_key): 

458 return self.raw.get(nested_key) 

459 

460 # Then check for flat agency fields (from search results) 

461 if any(key in self.raw for key in flat_keys): 

462 data = { 

463 "toptier_agency": { 

464 "name": self.raw.get(name_key), 

465 "code": self.raw.get(code_key), # Agency code 

466 "abbreviation": self.raw.get(code_key), 

467 }, 

468 "subtier_agency": { 

469 "name": self.raw.get(sub_name_key), 

470 "code": self.raw.get(sub_code_key), # Subtier code 

471 "abbreviation": self.raw.get(sub_code_key), 

472 }, 

473 "id": self.raw.get(id_key) if id_key else None, 

474 "has_agency_page": False, # Not available in search results 

475 "office_agency_name": None, # Not available in search results 

476 } 

477 return data 

478 

479 # Finally try lazy loading 

480 return self._lazy_get(nested_key) 

481 

482 @cached_property 

483 def funding_agency(self) -> Optional[Agency]: 

484 """Funding agency information.""" 

485 data = self._load_agency_data("funding") 

486 

487 if not data: 

488 return None 

489 

490 # Extract toptier data and merge with top-level agency fields 

491 toptier_data = data.get("toptier_agency", {}) 

492 agency_data = { 

493 "agency_id": data.get("id"), 

494 "has_agency_page": data.get("has_agency_page"), 

495 "office_agency_name": data.get("office_agency_name"), 

496 **toptier_data # Merge toptier fields (name, code, abbreviation, slug) 

497 } 

498 

499 subtier_data = data.get("subtier_agency") 

500 return Agency(agency_data, self._client, subtier_data) 

501 

502 @cached_property 

503 def awarding_agency(self) -> Optional[Agency]: 

504 """Awarding agency information.""" 

505 data = self._load_agency_data("awarding") 

506 

507 if not data: 

508 return None 

509 

510 # Extract toptier data and merge with top-level agency fields 

511 toptier_data = data.get("toptier_agency", {}) 

512 agency_data = { 

513 "agency_id": data.get("id"), 

514 "has_agency_page": data.get("has_agency_page"), 

515 "office_agency_name": data.get("office_agency_name"), 

516 **toptier_data # Merge toptier fields (name, code, abbreviation, slug) 

517 } 

518 

519 subtier_data = data.get("subtier_agency") 

520 return Agency(agency_data, self._client, subtier_data) 

521 

522 @cached_property 

523 def funding_subtier_agency(self) -> Optional["SubTierAgency"]: 

524 """Funding subtier agency information.""" 

525 data = self._load_agency_data("funding") 

526 

527 if not data: 

528 return None 

529 

530 subtier_data = data.get("subtier_agency") 

531 if not subtier_data: 

532 return None 

533 

534 # Create a copy and add office_agency_name if available 

535 enhanced_subtier_data = subtier_data.copy() 

536 office_name = data.get("office_agency_name") 

537 if office_name: 

538 enhanced_subtier_data["office_agency_name"] = office_name 

539 

540 from .subtier_agency import SubTierAgency 

541 return SubTierAgency(enhanced_subtier_data, self._client) 

542 

543 @cached_property 

544 def awarding_subtier_agency(self) -> Optional["SubTierAgency"]: 

545 """Awarding subtier agency information.""" 

546 data = self._load_agency_data("awarding") 

547 

548 if not data: 

549 return None 

550 

551 subtier_data = data.get("subtier_agency") 

552 if not subtier_data: 

553 return None 

554 

555 # Create a copy and add office_agency_name if available 

556 enhanced_subtier_data = subtier_data.copy() 

557 office_name = data.get("office_agency_name") 

558 if office_name: 

559 enhanced_subtier_data["office_agency_name"] = office_name 

560 

561 from .subtier_agency import SubTierAgency 

562 return SubTierAgency(enhanced_subtier_data, self._client) 

563 

564 @property 

565 def transactions(self) -> "TransactionsSearch": 

566 """Get transactions query builder for this award. 

567 

568 Returns a TransactionsSearch object that can be further filtered and chained. 

569 

570 Examples: 

571 >>> award.transactions.count() # Get count without loading all data 

572 >>> award.transactions.limit(10).all() # Get first 10 transactions 

573 >>> list(award.transactions) # Iterate through all transactions 

574 """ 

575 return self._client.transactions.for_award(self.generated_unique_award_id) 

576 

577 @property 

578 def funding(self) -> "FundingSearch": 

579 """Get funding query builder for this award. 

580 

581 Returns a FundingSearch object that can be further filtered and chained. 

582 

583 Examples: 

584 >>> award.funding.count() # Get count without loading all data 

585 >>> award.funding.order_by("fiscal_date", "asc").all() # Get all funding records sorted by date 

586 >>> list(award.funding.limit(10)) # Iterate through first 10 funding records 

587 """ 

588 return self._client.funding.for_award(self.generated_unique_award_id) 

589 

590 @property 

591 def subawards(self) -> "SubAwardsSearch": 

592 """Get subawards query builder for this award. 

593 

594 Returns a SubAwardsSearch object that can be further filtered and chained. 

595 Implemented in subclasses 

596 """ 

597 raise NotImplementedError() 

598 

599 

600 # Downloading detailed award data 

601 @property 

602 def _download_type(self) -> Optional[AwardType]: 

603 """ 

604 The type required by the download API ('contract' or 'assistance').  

605 This must be implemented by model subclasses 

606 """ 

607 from .contract import Contract 

608 from .grant import Grant 

609 from .idv import IDV 

610 

611 if isinstance(self, Contract): 

612 return "contract" 

613 elif isinstance(self, Grant): 

614 return "assistance" 

615 elif isinstance(self, IDV): 

616 return "idv" 

617 else: 

618 raise(NotImplementedError) 

619 

620 

621 def download(self, file_format: FileFormat = "csv", destination_dir: Optional[str] = None) -> "DownloadJob": 

622 """ 

623 Queue a download job for this award's detailed data. 

624 

625 This utilizes the USASpending bulk download API, which queues the request  

626 and processes it asynchronously. 

627 

628 Args: 

629 file_format: The format of the file(s) in the zip file containing the data 

630 destination_dir: Directory where the file will be saved (defaults to CWD). 

631 

632 Returns: 

633 A DownloadJob object. Use job.wait_for_completion() to block until finished. 

634  

635 Raises: 

636 ConfigurationError: If the Award instance lacks a client reference. 

637 ValidationError: If the award ID or download type is missing/invalid. 

638 

639 Example: 

640 >>> contract = client.awards.find_by_generated_id("CONT_AWD_123...") 

641 >>> job = contract.download(destination_dir="./data") 

642 >>> print(f"Job queued: {job.file_name}. Waiting...") 

643 >>> extracted_files = job.wait_for_completion(timeout=600) 

644 >>> print(f"Download complete. Files: {extracted_files}") 

645 """ 

646 

647 award_id = self.generated_unique_award_id 

648 

649 if not award_id: 

650 # If we don't have an award ID, we cannot proceed 

651 raise ValidationError("Cannot download award data without a 'generated_unique_award_id'. Ensure the award object is fully loaded.") 

652 

653 download_type = self._download_type 

654 

655 if not download_type: 

656 # Safety check in case a subclass doesn't implement _download_type or the implementation returns None 

657 raise ValidationError(f"Download is not supported or implemented for award type: {self.__class__.__name__}.") 

658 

659 # Access the DownloadManager via the client's download resource. 

660 # We route the call through the appropriate method on the resource. 

661 if download_type == "contract": 

662 return self._client.downloads.contract(award_id, file_format, destination_dir) 

663 elif download_type == "assistance": 

664 return self._client.downloads.assistance(award_id, file_format, destination_dir) 

665 elif download_type == "idv": 

666 return self._client.downloads.idv(award_id, file_format, destination_dir) 

667 else: 

668 raise NotImplementedError 

669 

670 def __repr__(self) -> str: 

671 """String representation of Award.""" 

672 recipient_name = self.recipient.name if self.recipient else "?" 

673 award_id = self.award_identifier or self.generated_unique_award_id or "?" 

674 return f"<Award {award_id} → {recipient_name}>"