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