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,219 @@
|
|
|
1
|
+
# src/usaspending/download/job.py
|
|
2
|
+
"""
|
|
3
|
+
This module defines the DownloadJob class, which encapsulates the lifecycle of a single
|
|
4
|
+
award data download task from the USASpending API. It handles polling for status updates,
|
|
5
|
+
downloading the completed file, and extracting its contents.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import time
|
|
10
|
+
import os
|
|
11
|
+
from typing import TYPE_CHECKING, Dict, Any, Optional, List
|
|
12
|
+
|
|
13
|
+
from ..exceptions import DownloadError, APIError
|
|
14
|
+
from ..logging_config import USASpendingLogger
|
|
15
|
+
from ..models.download import DownloadStatus, DownloadState
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .manager import DownloadManager
|
|
19
|
+
|
|
20
|
+
logger = USASpendingLogger.get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DownloadJob:
|
|
24
|
+
"""
|
|
25
|
+
Represents a single award data download task, managing its lifecycle (polling, downloading, extraction).
|
|
26
|
+
|
|
27
|
+
This class is designed to be instantiated by the `DownloadManager` after a download
|
|
28
|
+
request has been successfully queued with the USASpending API. It provides methods
|
|
29
|
+
to monitor the status of the download job, wait for its completion, and process
|
|
30
|
+
the downloaded data (downloading the zip file and extracting its contents).
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
file_name (str): The unique identifier for the download job, typically a filename
|
|
34
|
+
provided by the USASpending API.
|
|
35
|
+
destination_dir (str): The local directory where the downloaded zip file will be
|
|
36
|
+
saved and its contents extracted. Defaults to the current
|
|
37
|
+
working directory if not specified.
|
|
38
|
+
request_details (Optional[Dict[str, Any]]): A dictionary containing details of
|
|
39
|
+
the original download request, as
|
|
40
|
+
returned by the API.
|
|
41
|
+
state (DownloadState): The current state of the download job (e.g., PENDING,
|
|
42
|
+
RUNNING, FINISHED, FAILED). This is a read-only property.
|
|
43
|
+
status_details (Optional[DownloadStatus]): A `DownloadStatus` object containing
|
|
44
|
+
the latest detailed status information
|
|
45
|
+
retrieved from the API. This is a
|
|
46
|
+
read-only property.
|
|
47
|
+
error_message (Optional[str]): A message describing the error if the job
|
|
48
|
+
transitions to a FAILED state. This is a
|
|
49
|
+
read-only property.
|
|
50
|
+
result_files (Optional[List[str]]): A list of absolute paths to the files
|
|
51
|
+
extracted from the downloaded zip archive
|
|
52
|
+
upon successful completion. This is a
|
|
53
|
+
read-only property.
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
DEFAULT_POLL_INTERVAL = 30 # seconds
|
|
58
|
+
DEFAULT_TIMEOUT = 1800 # 30 minutes
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
manager: DownloadManager,
|
|
63
|
+
file_name: str,
|
|
64
|
+
initial_file_url: Optional[str],
|
|
65
|
+
request_details: Optional[Dict[str, Any]],
|
|
66
|
+
destination_dir: Optional[str] = None,
|
|
67
|
+
):
|
|
68
|
+
self._manager = manager
|
|
69
|
+
self.file_name = file_name
|
|
70
|
+
self._initial_file_url = initial_file_url # URL provided at initiation
|
|
71
|
+
self.request_details = request_details
|
|
72
|
+
self.destination_dir = destination_dir or os.getcwd()
|
|
73
|
+
|
|
74
|
+
self._status_details: Optional[DownloadStatus] = None
|
|
75
|
+
self._state: DownloadState = DownloadState.PENDING
|
|
76
|
+
self._result_files: Optional[List[str]] = None
|
|
77
|
+
self._error_message: Optional[str] = None
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def state(self) -> DownloadState:
|
|
81
|
+
"""Current state of the job."""
|
|
82
|
+
return self._state
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def status_details(self) -> Optional[DownloadStatus]:
|
|
86
|
+
"""Detailed status information from the API (cached)."""
|
|
87
|
+
return self._status_details
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def error_message(self) -> Optional[str]:
|
|
91
|
+
return self._error_message
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def result_files(self) -> Optional[List[str]]:
|
|
95
|
+
"""List of extracted file paths upon successful completion."""
|
|
96
|
+
return self._result_files
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def is_complete(self) -> bool:
|
|
100
|
+
"""True if the job is finished (success or failure)."""
|
|
101
|
+
return self._state in [DownloadState.FINISHED, DownloadState.FAILED]
|
|
102
|
+
|
|
103
|
+
def refresh_status(self) -> DownloadState:
|
|
104
|
+
"""Polls the API for the latest status and updates the internal state."""
|
|
105
|
+
if self.is_complete:
|
|
106
|
+
return self._state
|
|
107
|
+
|
|
108
|
+
logger.debug(f"Checking status for {self.file_name}...")
|
|
109
|
+
try:
|
|
110
|
+
self._status_details = self._manager.check_status(self.file_name)
|
|
111
|
+
self._state = self._status_details.api_status
|
|
112
|
+
|
|
113
|
+
if self._state == DownloadState.FAILED:
|
|
114
|
+
self._error_message = (
|
|
115
|
+
self._status_details.message or "API reported failure."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return self._state
|
|
119
|
+
|
|
120
|
+
except APIError as e:
|
|
121
|
+
# Handle transient API errors during polling without failing the job immediately
|
|
122
|
+
logger.warning(
|
|
123
|
+
f"API Error checking status for {self.file_name}: {e}. Will retry."
|
|
124
|
+
)
|
|
125
|
+
return self._state # Return previous state
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.error(f"Unexpected error checking status for {self.file_name}: {e}")
|
|
128
|
+
self._error_message = f"Status check failed: {e}"
|
|
129
|
+
self._state = DownloadState.FAILED
|
|
130
|
+
return self._state
|
|
131
|
+
|
|
132
|
+
def wait_for_completion(
|
|
133
|
+
self,
|
|
134
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
135
|
+
poll_interval: int = DEFAULT_POLL_INTERVAL,
|
|
136
|
+
cleanup_zip: bool = False,
|
|
137
|
+
) -> List[str]:
|
|
138
|
+
"""
|
|
139
|
+
Blocks until the download is complete, then downloads and unzips the file.
|
|
140
|
+
"""
|
|
141
|
+
logger.info(
|
|
142
|
+
f"Waiting for download job {self.file_name}. Timeout: {timeout}s. Polling every {poll_interval}s."
|
|
143
|
+
)
|
|
144
|
+
start_time = time.time()
|
|
145
|
+
|
|
146
|
+
while True:
|
|
147
|
+
current_state = self.refresh_status()
|
|
148
|
+
|
|
149
|
+
if current_state == DownloadState.FINISHED:
|
|
150
|
+
logger.info(
|
|
151
|
+
f"Download job finished. Total wait time: {time.time() - start_time:.2f}s. Proceeding to process."
|
|
152
|
+
)
|
|
153
|
+
return self._process_download(cleanup_zip)
|
|
154
|
+
|
|
155
|
+
if current_state == DownloadState.FAILED:
|
|
156
|
+
raise DownloadError(
|
|
157
|
+
f"Download job failed: {self.error_message}",
|
|
158
|
+
file_name=self.file_name,
|
|
159
|
+
status=DownloadState.FAILED.value,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if time.time() - start_time > timeout:
|
|
163
|
+
self._state = DownloadState.FAILED
|
|
164
|
+
self._error_message = (
|
|
165
|
+
f"Timeout waiting for download job after {timeout} seconds."
|
|
166
|
+
)
|
|
167
|
+
raise DownloadError(
|
|
168
|
+
self._error_message, file_name=self.file_name, status="timeout"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
logger.info(f"Job status: {current_state.value}. Waiting...")
|
|
172
|
+
time.sleep(poll_interval)
|
|
173
|
+
|
|
174
|
+
def _process_download(self, cleanup_zip: bool) -> List[str]:
|
|
175
|
+
"""Downloads the finished file, unzips it, and optionally cleans up the zip."""
|
|
176
|
+
os.makedirs(self.destination_dir, exist_ok=True)
|
|
177
|
+
|
|
178
|
+
zip_path = os.path.join(self.destination_dir, self.file_name)
|
|
179
|
+
|
|
180
|
+
# Create a subdirectory for extraction based on the filename (without .zip)
|
|
181
|
+
extract_subdir_name = os.path.splitext(self.file_name)[0]
|
|
182
|
+
extract_path = os.path.join(self.destination_dir, extract_subdir_name)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
# Use the latest file_url from the status details if available, fallback to initial URL
|
|
186
|
+
final_url = (
|
|
187
|
+
self._status_details.file_url
|
|
188
|
+
if self._status_details
|
|
189
|
+
else self._initial_file_url
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if not final_url:
|
|
193
|
+
raise DownloadError(
|
|
194
|
+
"File URL is missing, cannot download.", file_name=self.file_name
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
self._manager.download_file(final_url, zip_path, self.file_name)
|
|
198
|
+
self._result_files = self._manager.unzip_file(zip_path, extract_path)
|
|
199
|
+
|
|
200
|
+
if cleanup_zip:
|
|
201
|
+
logger.info(f"Cleaning up zip file: {zip_path}")
|
|
202
|
+
try:
|
|
203
|
+
os.remove(zip_path)
|
|
204
|
+
except OSError as e:
|
|
205
|
+
logger.warning(f"Could not remove zip file {zip_path}: {e}")
|
|
206
|
+
|
|
207
|
+
return self._result_files
|
|
208
|
+
|
|
209
|
+
except DownloadError as e:
|
|
210
|
+
self._error_message = str(e)
|
|
211
|
+
self._state = DownloadState.FAILED
|
|
212
|
+
raise
|
|
213
|
+
except Exception as e:
|
|
214
|
+
self._error_message = f"Error during file download or extraction: {e}"
|
|
215
|
+
self._state = DownloadState.FAILED
|
|
216
|
+
raise DownloadError(self._error_message, file_name=self.file_name) from e
|
|
217
|
+
|
|
218
|
+
def __repr__(self) -> str:
|
|
219
|
+
return f"<DownloadJob file_name='{self.file_name}' state='{self.state.value}'>"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# src/usaspending/download/manager.py
|
|
2
|
+
"""
|
|
3
|
+
This module defines the DownloadManager class, responsible for orchestrating the
|
|
4
|
+
download process of award data from the USASpending API. It handles queuing
|
|
5
|
+
download requests, checking their status, downloading the completed files,
|
|
6
|
+
and extracting their contents.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import zipfile
|
|
11
|
+
import os
|
|
12
|
+
from typing import TYPE_CHECKING, Optional, List
|
|
13
|
+
|
|
14
|
+
from ..exceptions import APIError, DownloadError
|
|
15
|
+
from ..logging_config import USASpendingLogger
|
|
16
|
+
from ..models.download import DownloadStatus, AwardType, FileFormat
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ..client import USASpendingClient
|
|
20
|
+
from .job import DownloadJob
|
|
21
|
+
|
|
22
|
+
logger = USASpendingLogger.get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DownloadManager:
|
|
26
|
+
"""Handles the core logic for queuing, monitoring, downloading, and processing award data."""
|
|
27
|
+
|
|
28
|
+
BASE_ENDPOINT = "/download/"
|
|
29
|
+
|
|
30
|
+
def __init__(self, client: USASpendingClient):
|
|
31
|
+
self._client = client
|
|
32
|
+
|
|
33
|
+
def queue_download(
|
|
34
|
+
self,
|
|
35
|
+
download_type: AwardType,
|
|
36
|
+
award_id: str,
|
|
37
|
+
file_format: FileFormat,
|
|
38
|
+
destination_dir: Optional[str],
|
|
39
|
+
) -> DownloadJob:
|
|
40
|
+
"""
|
|
41
|
+
Sends the initial request to the API to start the download job.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
endpoint = f"{self.BASE_ENDPOINT}{download_type}/"
|
|
45
|
+
payload = {"award_id": award_id, "file_format": file_format}
|
|
46
|
+
|
|
47
|
+
logger.info(
|
|
48
|
+
f"Queueing {download_type} download for award {award_id} (Format: {file_format})"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
response_data = self._client._make_uncached_request(
|
|
53
|
+
"POST", endpoint, json=payload
|
|
54
|
+
)
|
|
55
|
+
except APIError as e:
|
|
56
|
+
logger.error(f"Failed to queue download for award {award_id}: {e}")
|
|
57
|
+
raise DownloadError(f"Failed to queue download: {e}") from e
|
|
58
|
+
|
|
59
|
+
# Import DownloadJob here to avoid circular dependency
|
|
60
|
+
from .job import DownloadJob
|
|
61
|
+
|
|
62
|
+
file_name = response_data.get("file_name")
|
|
63
|
+
if not file_name:
|
|
64
|
+
raise DownloadError("API response missing required field 'file_name'.")
|
|
65
|
+
|
|
66
|
+
job = DownloadJob(
|
|
67
|
+
manager=self,
|
|
68
|
+
file_name=file_name,
|
|
69
|
+
initial_file_url=response_data.get("file_url"),
|
|
70
|
+
request_details=response_data.get("download_request"),
|
|
71
|
+
destination_dir=destination_dir,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return job
|
|
75
|
+
|
|
76
|
+
def check_status(self, file_name: str) -> DownloadStatus:
|
|
77
|
+
"""
|
|
78
|
+
Checks the status of a download job via the API.
|
|
79
|
+
"""
|
|
80
|
+
endpoint = f"{self.BASE_ENDPOINT}status"
|
|
81
|
+
params = {"file_name": file_name}
|
|
82
|
+
|
|
83
|
+
# Never cache this endpoint
|
|
84
|
+
response_data = self._client._make_uncached_request(
|
|
85
|
+
"GET", endpoint, params=params
|
|
86
|
+
)
|
|
87
|
+
return DownloadStatus(response_data)
|
|
88
|
+
|
|
89
|
+
def download_file(
|
|
90
|
+
self, file_url: str, destination_path: str, file_name: str
|
|
91
|
+
) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Downloads the zipped file from the provided URL using the client's binary download method.
|
|
94
|
+
|
|
95
|
+
This delegates to the client's _download_binary_file method which handles:
|
|
96
|
+
- Session management with proper headers
|
|
97
|
+
- Retry logic with exponential backoff
|
|
98
|
+
- Streaming for large files
|
|
99
|
+
- Cleanup of partial downloads on failure
|
|
100
|
+
"""
|
|
101
|
+
logger.info(f"Initiating download of {file_name}")
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
# Use the client's binary download method for consistency
|
|
105
|
+
self._client._download_binary_file(file_url, destination_path)
|
|
106
|
+
|
|
107
|
+
except Exception as e:
|
|
108
|
+
# Log with file_name context
|
|
109
|
+
logger.error(f"Failed to download file {file_name}: {e}")
|
|
110
|
+
# Ensure the exception includes the file_name
|
|
111
|
+
if hasattr(e, "file_name") and not e.file_name:
|
|
112
|
+
e.file_name = file_name
|
|
113
|
+
raise
|
|
114
|
+
|
|
115
|
+
def unzip_file(self, zip_path: str, extract_dir: str) -> List[str]:
|
|
116
|
+
"""
|
|
117
|
+
Unzips the downloaded file.
|
|
118
|
+
"""
|
|
119
|
+
logger.info(f"Unzipping {zip_path} to {extract_dir}")
|
|
120
|
+
if not os.path.exists(extract_dir):
|
|
121
|
+
os.makedirs(extract_dir)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
with zipfile.ZipFile(zip_path, "r") as zip_ref:
|
|
125
|
+
extracted_files = zip_ref.namelist()
|
|
126
|
+
zip_ref.extractall(extract_dir)
|
|
127
|
+
|
|
128
|
+
logger.info(f"Successfully extracted {len(extracted_files)} files.")
|
|
129
|
+
return [os.path.join(extract_dir, f) for f in extracted_files]
|
|
130
|
+
|
|
131
|
+
except zipfile.BadZipFile:
|
|
132
|
+
raise DownloadError(
|
|
133
|
+
f"Downloaded file is not a valid zip archive: {zip_path}",
|
|
134
|
+
file_name=os.path.basename(zip_path),
|
|
135
|
+
)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
raise DownloadError(
|
|
138
|
+
f"An error occurred during unzipping: {e}",
|
|
139
|
+
file_name=os.path.basename(zip_path),
|
|
140
|
+
) from e
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Custom exceptions for USASpending API client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class USASpendingError(Exception):
|
|
5
|
+
"""Base exception for all USASpending client errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIError(USASpendingError):
|
|
11
|
+
"""Raised when the API returns an error response."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self, message: str, status_code: int = None, response_body: dict = None
|
|
15
|
+
):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.response_body = response_body
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HTTPError(USASpendingError):
|
|
22
|
+
"""Raised when an HTTP error occurs."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str, status_code: int):
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RateLimitError(USASpendingError):
|
|
30
|
+
"""Raised when rate limit is exceeded."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str = "Rate limit exceeded", retry_after: int = None):
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.retry_after = retry_after
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ValidationError(USASpendingError):
|
|
38
|
+
"""Raised when input validation fails."""
|
|
39
|
+
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DetachedInstanceError(USASpendingError):
|
|
44
|
+
"""Raised when attempting to access lazy-loaded properties on a model whose client session is closed.
|
|
45
|
+
|
|
46
|
+
This error occurs when you try to access properties that require API calls
|
|
47
|
+
(lazy-loaded properties) on model instances after the USASpendingClient that
|
|
48
|
+
created them has been closed or garbage collected.
|
|
49
|
+
|
|
50
|
+
To avoid this error:
|
|
51
|
+
1. Access all needed properties within the client context manager
|
|
52
|
+
2. Use explicit client cleanup (client.close()) only after you're done with models
|
|
53
|
+
3. Call fetch_all_details() on models before the client context exits
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConfigurationError(USASpendingError):
|
|
60
|
+
"""Raised when client configuration is invalid."""
|
|
61
|
+
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DownloadError(USASpendingError):
|
|
66
|
+
"""Raised when an award download process fails, times out, or encounters issues during file processing."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, message: str, file_name: str = None, status: str = None):
|
|
69
|
+
super().__init__(message)
|
|
70
|
+
self.file_name = file_name
|
|
71
|
+
self.status = status
|