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