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,66 @@
1
+ from typing import TYPE_CHECKING
2
+ from .single_resource_base import SingleResourceBase
3
+ from ..exceptions import ValidationError
4
+ from ..client import USASpendingClient
5
+ from ..logging_config import USASpendingLogger
6
+
7
+ if TYPE_CHECKING:
8
+ from ..models.recipient import Recipient
9
+
10
+ logger = USASpendingLogger.get_logger(__name__)
11
+
12
+
13
+ class RecipientQuery(SingleResourceBase):
14
+ """Retrieve a single-recipient"""
15
+
16
+ def __init__(self, client: USASpendingClient):
17
+ super().__init__(client)
18
+
19
+ @property
20
+ def _endpoint(self) -> str:
21
+ """Base endpoint for single recipient retrieval."""
22
+ return "/recipient/"
23
+
24
+ def find_by_id(self, recipient_id: str) -> "Recipient":
25
+ """Filter by unique recipient identifier."""
26
+ if not recipient_id:
27
+ raise ValidationError("recipient_id is required")
28
+
29
+ # Make API request
30
+ response = self._get_resource(recipient_id)
31
+
32
+ # Create model instance
33
+ from ..models.recipient import Recipient
34
+
35
+ return Recipient(response, client=self._client)
36
+
37
+ def _clean_recipient_id(self, recipient_id: str) -> str:
38
+ """Clean recipient ID format.
39
+
40
+ Handles cases like "abc123-['C','R']" -> "abc123-R"
41
+ """
42
+ import re
43
+
44
+ # Pattern for list suffix: -[...]
45
+ pattern = r"^(.+?)-\[\s*([^\]]+)\]$"
46
+ match = re.match(pattern, recipient_id)
47
+
48
+ if not match:
49
+ return recipient_id
50
+
51
+ base = match.group(1)
52
+ tokens = match.group(2)
53
+
54
+ # Extract and clean tokens
55
+ clean_tokens = []
56
+ for token in tokens.split(","):
57
+ clean_token = token.strip().strip("'\"").upper()
58
+ if clean_token:
59
+ clean_tokens.append(clean_token)
60
+
61
+ # Prefer 'R' if present, otherwise first token
62
+ suffix = (
63
+ "R" if "R" in clean_tokens else (clean_tokens[0] if clean_tokens else "")
64
+ )
65
+
66
+ return f"{base}-{suffix}" if suffix else base
@@ -0,0 +1,167 @@
1
+ """Recipients search query builder for USASpending recipient search endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional, Literal, TYPE_CHECKING
6
+
7
+ from ..client import USASpendingClient
8
+ from usaspending.queries.query_builder import QueryBuilder
9
+ from usaspending.logging_config import USASpendingLogger
10
+
11
+ if TYPE_CHECKING:
12
+ from usaspending.models.recipient import Recipient
13
+
14
+ logger = USASpendingLogger.get_logger(__name__)
15
+
16
+ # Valid award types for recipient searches
17
+ AwardType = Literal[
18
+ "all",
19
+ "contracts",
20
+ "grants",
21
+ "loans",
22
+ "direct_payments",
23
+ "other_financial_assistance",
24
+ ]
25
+ # Valid sort fields
26
+ SortField = Literal["name", "duns", "amount"]
27
+ # Valid sort directions
28
+ SortDirection = Literal["asc", "desc"]
29
+
30
+
31
+ class RecipientsSearch(QueryBuilder["Recipient"]):
32
+ """
33
+ Builds and executes recipient search queries, allowing for complex
34
+ filtering on recipient data. This class follows a fluent interface pattern.
35
+
36
+ Supports filtering by keyword, award type, and sorting by various fields.
37
+ """
38
+
39
+ def __init__(self, client: USASpendingClient):
40
+ """
41
+ Initializes the RecipientsSearch query builder.
42
+
43
+ Args:
44
+ client: The USASpending client instance.
45
+ """
46
+ super().__init__(client)
47
+ self._keyword: Optional[str] = None
48
+ self._award_type: AwardType = "all"
49
+ self._sort_field: SortField = "amount"
50
+ self._sort_direction: SortDirection = "desc"
51
+
52
+ @property
53
+ def _endpoint(self) -> str:
54
+ """The API endpoint for this query."""
55
+ return "/recipient/"
56
+
57
+ def _clone(self) -> RecipientsSearch:
58
+ """Creates an immutable copy of the query builder."""
59
+ clone = super()._clone()
60
+ clone._keyword = self._keyword
61
+ clone._award_type = self._award_type
62
+ clone._sort_field = self._sort_field
63
+ clone._sort_direction = self._sort_direction
64
+ return clone
65
+
66
+ def _build_payload(self, page: int) -> dict[str, Any]:
67
+ """Constructs the final API request payload."""
68
+ payload = {
69
+ "page": page,
70
+ "limit": self._get_effective_page_size(),
71
+ "sort": self._sort_field,
72
+ "order": self._sort_direction,
73
+ "award_type": self._award_type,
74
+ }
75
+
76
+ # Add keyword filter if provided
77
+ if self._keyword:
78
+ payload["keyword"] = self._keyword
79
+
80
+ return payload
81
+
82
+ def _transform_result(self, result: dict[str, Any]) -> "Recipient":
83
+ """Transforms a single API result item into a Recipient model."""
84
+ from usaspending.models.recipient import Recipient
85
+
86
+ if not result.get("recipient_id") and result.get("id"):
87
+ result["recipient_id"] = result["id"]
88
+ return Recipient(result, self._client)
89
+
90
+ def count(self) -> int:
91
+ """
92
+ Get the total count of results using the dedicated count endpoint.
93
+
94
+ Uses the /v2/recipient/count/ endpoint which takes the same filters
95
+ but returns just a count value.
96
+
97
+ Returns:
98
+ The total number of matching recipients.
99
+ """
100
+ logger.debug(f"{self.__class__.__name__}.count() called")
101
+
102
+ # Build payload for count endpoint (no pagination params needed)
103
+ payload = {
104
+ "award_type": self._award_type,
105
+ }
106
+
107
+ # Add keyword filter if provided
108
+ if self._keyword:
109
+ payload["keyword"] = self._keyword
110
+
111
+ # Make API request to count endpoint
112
+ count_endpoint = "/recipient/count/"
113
+ response = self._client._make_request("POST", count_endpoint, json=payload)
114
+
115
+ total_count = response.get("count", 0)
116
+ logger.info(f"{self.__class__.__name__}.count() = {total_count}")
117
+ return total_count
118
+
119
+ # ==========================================================================
120
+ # Filter Methods
121
+ # ==========================================================================
122
+
123
+ def keyword(self, keyword: str) -> RecipientsSearch:
124
+ """
125
+ Filter by recipient name, UEI, or DUNS keyword.
126
+
127
+ Args:
128
+ keyword: The keyword to search for across recipient identifiers.
129
+
130
+ Returns:
131
+ A new RecipientsSearch instance with the filter applied.
132
+ """
133
+ clone = self._clone()
134
+ clone._keyword = keyword.strip() if keyword else None
135
+ return clone
136
+
137
+ def award_type(self, award_type: AwardType) -> RecipientsSearch:
138
+ """
139
+ Filter by award type.
140
+
141
+ Args:
142
+ award_type: The award type to filter by.
143
+
144
+ Returns:
145
+ A new RecipientsSearch instance with the filter applied.
146
+ """
147
+ clone = self._clone()
148
+ clone._award_type = award_type
149
+ return clone
150
+
151
+ def order_by(
152
+ self, field: SortField, direction: SortDirection = "desc"
153
+ ) -> RecipientsSearch:
154
+ """
155
+ Set the sort field and direction.
156
+
157
+ Args:
158
+ field: The field to sort by (name, duns, amount).
159
+ direction: The sort direction (asc or desc).
160
+
161
+ Returns:
162
+ A new RecipientsSearch instance with the sorting applied.
163
+ """
164
+ clone = self._clone()
165
+ clone._sort_field = field
166
+ clone._sort_direction = direction
167
+ return clone
@@ -0,0 +1,83 @@
1
+ from ..exceptions import ValidationError
2
+ from ..client import USASpendingClient
3
+ from typing import Any
4
+ from abc import ABC, abstractmethod
5
+ from ..logging_config import USASpendingLogger
6
+
7
+ logger = USASpendingLogger.get_logger(__name__)
8
+
9
+
10
+ class SingleResourceBase(ABC):
11
+ """
12
+ Base class for retrieving single resources like awards or recipients,
13
+ anything with a simple GET request, generally.
14
+ """
15
+
16
+ def __init__(self, client: USASpendingClient):
17
+ self._client = client
18
+
19
+ @property
20
+ @abstractmethod
21
+ def _endpoint(self) -> str:
22
+ """Base endpoint for single resource retrieval."""
23
+ pass
24
+
25
+ @abstractmethod
26
+ def find_by_id(self, resource_id: str) -> Any:
27
+ """Filter by unique resource identifier."""
28
+ pass
29
+
30
+ def _get_resource(self, resource_id: str) -> dict:
31
+ """Retrieve a single resource by ID."""
32
+ if (
33
+ not resource_id
34
+ or not isinstance(resource_id, str)
35
+ or not resource_id.strip()
36
+ ):
37
+ raise ValidationError("A non-empty resource_id string is required")
38
+
39
+ # Clean recipient ID
40
+ cleaned_resource_id = self._clean_resource_id(resource_id)
41
+
42
+ if not cleaned_resource_id:
43
+ raise ValidationError(
44
+ "No resource id found after cleaning. Original: %s. Cleaned: %s",
45
+ resource_id,
46
+ cleaned_resource_id,
47
+ )
48
+
49
+ # Construct valid endpoint
50
+ endpoint = self._construct_endpoint(cleaned_resource_id)
51
+
52
+ # Make API request
53
+ response = self._client._make_request("GET", endpoint)
54
+
55
+ # Validate response
56
+ if response is None:
57
+ raise ValidationError(
58
+ f"API request for {endpoint} returned None. This may be due to caching issues."
59
+ )
60
+
61
+ if not isinstance(response, dict):
62
+ raise ValidationError(
63
+ f"API request for {endpoint} returned invalid response type: {type(response)}. Expected dict."
64
+ )
65
+
66
+ return response
67
+
68
+ def _construct_endpoint(self, resource_id: str) -> str:
69
+ """Construct the full endpoint URL for a specific resource ID."""
70
+ return f"{self._endpoint}{resource_id}/"
71
+
72
+ def _clean_resource_id(self, resource_id: str) -> str:
73
+ """Very basic resource ID cleaning.
74
+
75
+ More complex logic is implemented in specific resource classes.
76
+
77
+ Args:
78
+ resource_id: The raw resource ID string
79
+
80
+ Returns:
81
+ Cleaned resource ID string
82
+ """
83
+ return str(resource_id).strip()
@@ -0,0 +1,261 @@
1
+ """Spending search query builder for USASpending spending by category endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional, Literal
6
+
7
+ from ..client import USASpendingClient
8
+ from usaspending.exceptions import ValidationError
9
+ from usaspending.models.spending import Spending
10
+ from usaspending.models.recipient_spending import RecipientSpending
11
+ from usaspending.models.district_spending import DistrictSpending
12
+ from usaspending.models.state_spending import StateSpending
13
+ from usaspending.queries.query_builder import SearchQueryBuilder
14
+ from usaspending.logging_config import USASpendingLogger
15
+ from usaspending.queries.filters import (
16
+ SimpleListFilter,
17
+ )
18
+
19
+ # Import award type codes from models
20
+ # These are defined by USASpending.gov and represent different categories of awards
21
+
22
+
23
+ logger = USASpendingLogger.get_logger(__name__)
24
+
25
+ SpendingLevel = Literal["transactions", "awards", "subawards"]
26
+ SpendingCategory = Literal["recipient", "district", "state"]
27
+
28
+
29
+ class SpendingSearch(SearchQueryBuilder["Spending"]):
30
+ """
31
+ Builds and executes spending by category search queries, allowing for complex
32
+ filtering on spending data. This class follows a fluent interface pattern.
33
+
34
+ Supports both recipient and district spending searches with configurable
35
+ spending levels (transactions, awards, subawards).
36
+ """
37
+
38
+ def __init__(self, client: USASpendingClient):
39
+ """
40
+ Initializes the SpendingSearch query builder.
41
+
42
+ Args:
43
+ client: The USASpending client instance.
44
+ """
45
+ super().__init__(client)
46
+ self._category: Optional[SpendingCategory] = None
47
+ self._spending_level: SpendingLevel = "transactions"
48
+ self._subawards: bool = False
49
+
50
+ @property
51
+ def _endpoint(self) -> str:
52
+ """The API endpoint for this query."""
53
+ if self._category == "recipient":
54
+ return "/search/spending_by_category/recipient/"
55
+ elif self._category == "district":
56
+ return "/search/spending_by_category/district/"
57
+ elif self._category == "state":
58
+ return "/search/spending_by_category/state_territory/"
59
+ else:
60
+ raise ValidationError(
61
+ "Category must be set. Use .by_recipient(), .by_district(), or .by_state() method."
62
+ )
63
+
64
+ def _clone(self) -> SpendingSearch:
65
+ """Creates an immutable copy of the query builder."""
66
+ clone = super()._clone()
67
+ clone._category = self._category
68
+ clone._spending_level = self._spending_level
69
+ clone._subawards = self._subawards
70
+ return clone
71
+
72
+ def _build_payload(self, page: int) -> dict[str, Any]:
73
+ """Constructs the final API request payload from the filter objects."""
74
+
75
+ if self._category is None:
76
+ raise ValidationError(
77
+ "Category must be set. Use .by_recipient(), .by_district(), or .by_state() method."
78
+ )
79
+
80
+ final_filters = self._aggregate_filters()
81
+
82
+ payload = {
83
+ "filters": final_filters,
84
+ "category": self._category,
85
+ "limit": self._get_effective_page_size(),
86
+ "page": page,
87
+ "spending_level": self._spending_level,
88
+ }
89
+
90
+ # Add deprecated subawards field if needed
91
+ if self._subawards:
92
+ payload["subawards"] = self._subawards
93
+
94
+ return payload
95
+
96
+ def _transform_result(self, result: dict[str, Any]) -> Spending:
97
+ """Transforms a single API result item into appropriate Spending model."""
98
+ # Add category info to result data for model initialization
99
+ result_with_category = {
100
+ **result,
101
+ "category": self._category,
102
+ "spending_level": self._spending_level,
103
+ }
104
+
105
+ if self._category == "recipient":
106
+ return RecipientSpending(result_with_category, self._client)
107
+ elif self._category == "district":
108
+ return DistrictSpending(result_with_category, self._client)
109
+ elif self._category == "state":
110
+ return StateSpending(result_with_category, self._client)
111
+ else:
112
+ return Spending(result_with_category, self._client)
113
+
114
+ def count(self) -> int:
115
+ """
116
+ Get the total count of results by iterating through pages.
117
+
118
+ Respects pagination constraints like limit() and max_pages() to match
119
+ the behavior of iteration. The spending by category endpoints don't
120
+ have a total count in page_metadata, so we fetch pages and count results.
121
+
122
+ Returns:
123
+ The total number of matching spending records, up to any set limits.
124
+ """
125
+ logger.debug(f"{self.__class__.__name__}.count() called")
126
+
127
+ # Early return for zero or negative limits
128
+ if self._total_limit is not None and self._total_limit <= 0:
129
+ logger.info(
130
+ f"{self.__class__.__name__}.count() = 0 (limit: {self._total_limit})"
131
+ )
132
+ return 0
133
+
134
+ total_count = 0
135
+ page = 1
136
+ pages_fetched = 0
137
+
138
+ while True:
139
+ # Check if we've reached the max pages limit
140
+ if self._max_pages and pages_fetched >= self._max_pages:
141
+ logger.debug(f"Max pages limit ({self._max_pages}) reached")
142
+ break
143
+
144
+ response = self._execute_query(page)
145
+ results = response.get("results", [])
146
+
147
+ # Count items, but respect total_limit
148
+ items_to_count = len(results)
149
+ if self._total_limit is not None:
150
+ remaining = self._total_limit - total_count
151
+ items_to_count = min(items_to_count, remaining)
152
+
153
+ total_count += items_to_count
154
+
155
+ # Stop if we've reached our limit
156
+ if self._total_limit is not None and total_count >= self._total_limit:
157
+ logger.debug(f"Total limit of {self._total_limit} items reached")
158
+ break
159
+
160
+ # Check if there are more pages
161
+ page_metadata = response.get("page_metadata", {})
162
+ has_next = page_metadata.get("hasNext", False)
163
+
164
+ if not has_next or not results:
165
+ break
166
+
167
+ page += 1
168
+ pages_fetched += 1
169
+
170
+ logger.info(f"{self.__class__.__name__}.count() = {total_count}")
171
+ return total_count
172
+
173
+ # ==========================================================================
174
+ # Category Selection Methods
175
+ # ==========================================================================
176
+
177
+ def by_recipient(self) -> SpendingSearch:
178
+ """
179
+ Configure search to return spending grouped by recipient.
180
+
181
+ Returns:
182
+ A new SpendingSearch instance configured for recipient spending.
183
+ """
184
+ clone = self._clone()
185
+ clone._category = "recipient"
186
+ return clone
187
+
188
+ def by_district(self) -> SpendingSearch:
189
+ """
190
+ Configure search to return spending grouped by congressional district.
191
+
192
+ Returns:
193
+ A new SpendingSearch instance configured for district spending.
194
+ """
195
+ clone = self._clone()
196
+ clone._category = "district"
197
+ return clone
198
+
199
+ def by_state(self) -> SpendingSearch:
200
+ """
201
+ Configure search to return spending grouped by state/territory.
202
+
203
+ Returns:
204
+ A new SpendingSearch instance configured for state spending.
205
+ """
206
+ clone = self._clone()
207
+ clone._category = "state"
208
+ return clone
209
+
210
+ # ==========================================================================
211
+ # Spending Level Configuration
212
+ # ==========================================================================
213
+
214
+ def spending_level(self, level: SpendingLevel) -> SpendingSearch:
215
+ """
216
+ Set the spending level for data aggregation.
217
+
218
+ Args:
219
+ level: The spending level - "transactions", "awards", or "subawards"
220
+
221
+ Returns:
222
+ A new SpendingSearch instance with the spending level configured.
223
+ """
224
+ clone = self._clone()
225
+ clone._spending_level = level
226
+ return clone
227
+
228
+ def subawards_only(self, enabled: bool = True) -> SpendingSearch:
229
+ """
230
+ Enable subawards search (deprecated parameter).
231
+
232
+ Args:
233
+ enabled: Whether to search subawards instead of prime awards
234
+
235
+ Returns:
236
+ A new SpendingSearch instance with subawards flag set.
237
+ """
238
+ clone = self._clone()
239
+ clone._subawards = enabled
240
+ return clone
241
+
242
+ # ==========================================================================
243
+ # Filter Methods (same as AwardsSearch)
244
+ # ==========================================================================
245
+
246
+ def recipient_id(self, recipient_id: str) -> SpendingSearch:
247
+ """
248
+ Filter by specific recipient ID.
249
+
250
+ Args:
251
+ recipient_id: Unique identifier for the recipient.
252
+
253
+ Returns:
254
+ A new SpendingSearch instance with the filter applied.
255
+ """
256
+ clone = self._clone()
257
+ clone._filter_objects.append(
258
+ SimpleListFilter(key="recipient_id", values=[recipient_id])
259
+ )
260
+ return clone
261
+