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,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Dict, Any, Optional
|
|
3
|
+
from titlecase import titlecase
|
|
4
|
+
from ..utils.formatter import contracts_titlecase
|
|
5
|
+
from .base_model import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Location(BaseModel):
|
|
9
|
+
"""Location model for USASpending data."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, data: Dict[str, Any], client=None):
|
|
12
|
+
"""Initialize Location. Client parameter is ignored for compatibility."""
|
|
13
|
+
super().__init__(data)
|
|
14
|
+
|
|
15
|
+
# simple direct fields --------------------------------------------------
|
|
16
|
+
@property
|
|
17
|
+
def address_line1(self) -> Optional[str]:
|
|
18
|
+
return self._format_location_string_property(self.get_value(["address_line1"]))
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def address_line2(self) -> Optional[str]:
|
|
22
|
+
return self._format_location_string_property(self.get_value(["address_line2"]))
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def address_line3(self) -> Optional[str]:
|
|
26
|
+
return self._format_location_string_property(self.get_value(["address_line3"]))
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def city_name(self) -> Optional[str]:
|
|
30
|
+
city_name = self.get_value(["city_name", "city"])
|
|
31
|
+
if not isinstance(city_name, str):
|
|
32
|
+
return None
|
|
33
|
+
return titlecase(city_name)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def city(self) -> Optional[str]:
|
|
37
|
+
return self.city_name
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def state_name(self) -> Optional[str]:
|
|
41
|
+
state_name = titlecase(self.get_value(["state_name", "state"]))
|
|
42
|
+
if not isinstance(state_name, str):
|
|
43
|
+
return None
|
|
44
|
+
return titlecase(state_name)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def country_name(self) -> Optional[str]:
|
|
48
|
+
country = self._format_location_string_property(
|
|
49
|
+
self.get_value(["country_name"])
|
|
50
|
+
)
|
|
51
|
+
if country and country.lower() == "usa":
|
|
52
|
+
country = "USA"
|
|
53
|
+
return country
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def zip4(self) -> Optional[str]:
|
|
57
|
+
return self.get_value(["zip4"])
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def county_name(self) -> Optional[str]:
|
|
61
|
+
county_name = titlecase(self.get_value(["county_name", "county"]))
|
|
62
|
+
if not isinstance(county_name, str):
|
|
63
|
+
return None
|
|
64
|
+
return county_name
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def county_code(self) -> Optional[str]:
|
|
68
|
+
return self.get_value(["county_code"])
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def congressional_code(self) -> Optional[str]:
|
|
72
|
+
return self.get_value(["congressional_code", "district"])
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def foreign_province(self) -> Optional[str]:
|
|
76
|
+
return self.get_value(["foreign_province"])
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def foreign_postal_code(self) -> Optional[str]:
|
|
80
|
+
return self.get_value(["foreign_postal_code"])
|
|
81
|
+
|
|
82
|
+
# dual-source fields ----------------------------------------------------
|
|
83
|
+
@property
|
|
84
|
+
def state_code(self) -> Optional[str]:
|
|
85
|
+
return self.get_value(["state_code", "Place of Performance State Code"])
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def country_code(self) -> Optional[str]:
|
|
89
|
+
return self.get_value(
|
|
90
|
+
["location_country_code", "Place of Performance Country Code"]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def zip5(self) -> Optional[str]:
|
|
95
|
+
val = self.get_value(["zip5", "Place of Performance Zip5"])
|
|
96
|
+
return str(val) if val is not None else ""
|
|
97
|
+
|
|
98
|
+
# convenience -----------------------------------------------------------
|
|
99
|
+
@property
|
|
100
|
+
def district(self) -> Optional[str]:
|
|
101
|
+
pieces = [p for p in (self.state_code, self.congressional_code) if p]
|
|
102
|
+
return "-".join(pieces) or ""
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def formatted_address(self) -> Optional[str]:
|
|
106
|
+
lines: list[str] = [
|
|
107
|
+
line
|
|
108
|
+
for line in (self.address_line1, self.address_line2, self.address_line3)
|
|
109
|
+
if line
|
|
110
|
+
]
|
|
111
|
+
trailing = [p for p in (self.city, self.state_code, self.zip5) if p]
|
|
112
|
+
if trailing:
|
|
113
|
+
lines.append(", ".join(trailing))
|
|
114
|
+
if self.country_name:
|
|
115
|
+
lines.append(self.country_name)
|
|
116
|
+
return "\n".join(lines) or None
|
|
117
|
+
|
|
118
|
+
def _format_location_string_property(self, text: str) -> Optional[str]:
|
|
119
|
+
"""Format a location string with string check."""
|
|
120
|
+
if not isinstance(text, str):
|
|
121
|
+
return None
|
|
122
|
+
return contracts_titlecase(text.strip())
|
|
123
|
+
|
|
124
|
+
def __repr__(self) -> str:
|
|
125
|
+
return f"<Location {self.city or '?'} {self.state_code or ''} {self.country_code or ''}>"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Dict, Any, Optional
|
|
3
|
+
from .base_model import BaseModel
|
|
4
|
+
from ..utils.formatter import to_date
|
|
5
|
+
from datetime import date
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PeriodOfPerformance(BaseModel):
|
|
9
|
+
def __init__(self, data: Dict[str, Any]):
|
|
10
|
+
super().__init__(data)
|
|
11
|
+
self._start_date = to_date(
|
|
12
|
+
self.get_value(
|
|
13
|
+
["start_date", "Start Date", "Period of Performance Start Date"]
|
|
14
|
+
)
|
|
15
|
+
)
|
|
16
|
+
self._end_date = to_date(
|
|
17
|
+
self.get_value(
|
|
18
|
+
["end_date", "End Date", "Period of Performance Current End Date"]
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def start_date(self) -> Optional[date]:
|
|
24
|
+
return self._start_date
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def end_date(self) -> Optional[date]:
|
|
28
|
+
return self._end_date
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def last_modified_date(self) -> Optional[date]:
|
|
32
|
+
return to_date(self.get_value(["last_modified_date", "Last Modified Date"]))
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def potential_end_date(self) -> Optional[date]:
|
|
36
|
+
return to_date(
|
|
37
|
+
self.get_value(
|
|
38
|
+
["potential_end_date", "Period of Performance Potential End Date"]
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def __repr__(self) -> str:
|
|
43
|
+
return f"<Period of Performance {self._start_date or '?'} -> {self._end_date or '?'}>"
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Dict, Any, Optional, List, TYPE_CHECKING
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
import re
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
from .lazy_record import LazyRecord
|
|
8
|
+
from .location import Location
|
|
9
|
+
from ..utils.formatter import to_decimal, contracts_titlecase
|
|
10
|
+
|
|
11
|
+
from ..logging_config import USASpendingLogger
|
|
12
|
+
|
|
13
|
+
logger = USASpendingLogger.get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ..client import USASpendingClient
|
|
17
|
+
|
|
18
|
+
# FUTURE: Add logic to self-categorize recipient type based on FPDS categories
|
|
19
|
+
# This would enhance the Recipient model by automatically determining the recipient
|
|
20
|
+
# type (corporation, university, government, etc.) based on FPDS category codes
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Recipient(LazyRecord):
|
|
24
|
+
# compiled once at import time
|
|
25
|
+
_LIST_SUFFIX_RE = re.compile(
|
|
26
|
+
r"""
|
|
27
|
+
^(?P<base>.+?) # everything before the dash (non-greedy)
|
|
28
|
+
-\[\s*(?P<body>[^\]]+)\] # -[ ... ]
|
|
29
|
+
$ # end of string
|
|
30
|
+
""",
|
|
31
|
+
re.VERBOSE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
data_or_id: Dict[str, Any] | str,
|
|
37
|
+
client: Optional[USASpendingClient] = None,
|
|
38
|
+
):
|
|
39
|
+
# Use the base validation method
|
|
40
|
+
raw = self.validate_init_data(
|
|
41
|
+
data_or_id, "Recipient", id_field="recipient_id", allow_string_id=True
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Apply recipient-specific ID cleaning
|
|
45
|
+
rid = raw.get("recipient_id") or raw.get("recipient_hash")
|
|
46
|
+
if rid:
|
|
47
|
+
raw["recipient_id"] = self._clean_recipient_id(rid)
|
|
48
|
+
|
|
49
|
+
super().__init__(raw, client)
|
|
50
|
+
|
|
51
|
+
def _fetch_details(self) -> Optional[Dict[str, Any]]:
|
|
52
|
+
"""Fetch full recipient details from the API."""
|
|
53
|
+
recipient_id = self.recipient_id
|
|
54
|
+
if not recipient_id:
|
|
55
|
+
logger.error(
|
|
56
|
+
"Cannot lazy-load Recipient data. Property `recipient_id` is required to fetch details."
|
|
57
|
+
)
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
# Make direct API call to avoid circular dependency
|
|
61
|
+
endpoint = f"/recipient/{recipient_id}/"
|
|
62
|
+
response = self._client._make_request("GET", endpoint)
|
|
63
|
+
return response
|
|
64
|
+
except Exception as e:
|
|
65
|
+
# If fetch fails, return None to avoid breaking the application
|
|
66
|
+
logger.error(f"Failed to fetch recipient details for {recipient_id}: {e}")
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _clean_recipient_id(rid: str) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Normalise list-annotated recipient IDs.
|
|
73
|
+
Sometimes these look like "abc123-['C','R']".
|
|
74
|
+
This will select the first letter after the dash,
|
|
75
|
+
"""
|
|
76
|
+
if not isinstance(rid, str):
|
|
77
|
+
return rid # defensive; shouldn't happen
|
|
78
|
+
|
|
79
|
+
rid = rid.strip().rstrip("/") # drop accidental trailing slash
|
|
80
|
+
|
|
81
|
+
m = Recipient._LIST_SUFFIX_RE.match(rid)
|
|
82
|
+
if not m:
|
|
83
|
+
return rid # already in normal form
|
|
84
|
+
|
|
85
|
+
base = m.group("base")
|
|
86
|
+
body = m.group("body")
|
|
87
|
+
|
|
88
|
+
# turn "'C','R'" or "'R'" etc. into a list of clean tokens
|
|
89
|
+
tokens = [
|
|
90
|
+
tok.strip().strip("'\"").upper() for tok in body.split(",") if tok.strip()
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
letter = tokens[0]
|
|
94
|
+
return f"{base}-{letter}" if letter else base
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def recipient_id(self) -> Optional[str]:
|
|
98
|
+
return self.get_value(["recipient_id", "recipient_hash"], default=None)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def name(self) -> Optional[str]:
|
|
102
|
+
return contracts_titlecase(
|
|
103
|
+
self._lazy_get("name", "recipient_name", "Recipient Name", default=None)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def alternate_names(self) -> List[Optional[str]]:
|
|
108
|
+
"""
|
|
109
|
+
Returns a list of alternate names for the recipient, formatted in title case.
|
|
110
|
+
If no alternate names are available, returns an empty list.
|
|
111
|
+
"""
|
|
112
|
+
names = self._lazy_get("alternate_names", default=[])
|
|
113
|
+
if isinstance(names, list):
|
|
114
|
+
return [
|
|
115
|
+
contracts_titlecase(name) for name in names if isinstance(name, str)
|
|
116
|
+
]
|
|
117
|
+
else:
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def duns(self) -> Optional[str]:
|
|
122
|
+
return self._lazy_get(
|
|
123
|
+
"duns", "recipient_unique_id", "Recipient DUNS Number", default=None
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def uei(self) -> Optional[str]:
|
|
128
|
+
return self._lazy_get("uei", "recipient_uei")
|
|
129
|
+
|
|
130
|
+
@cached_property
|
|
131
|
+
def parent(self) -> Optional["Recipient"]:
|
|
132
|
+
pid = self._lazy_get("parent_id")
|
|
133
|
+
|
|
134
|
+
# Don't load a parent if parent id is missing or
|
|
135
|
+
# the parent recipient_id is theh same as the current one
|
|
136
|
+
if not pid or pid == self.recipient_id:
|
|
137
|
+
return None
|
|
138
|
+
else:
|
|
139
|
+
return Recipient(
|
|
140
|
+
{
|
|
141
|
+
"recipient_id": pid,
|
|
142
|
+
"name": self.get_value("parent_name"),
|
|
143
|
+
"duns": self.get_value("parent_duns"),
|
|
144
|
+
"uei": self.get_value("parent_uei"),
|
|
145
|
+
},
|
|
146
|
+
client=self._client,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@cached_property
|
|
150
|
+
def parents(self) -> List["Recipient"]:
|
|
151
|
+
plist = []
|
|
152
|
+
# Use _lazy_get to ensure parents data is loaded if not present
|
|
153
|
+
parents_data = self._lazy_get("parents", default=[])
|
|
154
|
+
|
|
155
|
+
for p in parents_data:
|
|
156
|
+
if isinstance(p, dict):
|
|
157
|
+
# Skip if parent_id is missing or the same as current recipient_id
|
|
158
|
+
if not p.get("parent_id") or p.get("parent_id") == self.recipient_id:
|
|
159
|
+
continue
|
|
160
|
+
plist.append(
|
|
161
|
+
Recipient(
|
|
162
|
+
{
|
|
163
|
+
"recipient_id": p.get("parent_id"),
|
|
164
|
+
"name": p.get("parent_name"),
|
|
165
|
+
"duns": p.get("parent_duns"),
|
|
166
|
+
"uei": p.get("parent_uei"),
|
|
167
|
+
},
|
|
168
|
+
client=self._client,
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
return plist
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def business_types(self) -> List[str]:
|
|
175
|
+
return self._lazy_get("business_types", "business_categories", default=[])
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def business_categories(self) -> List[str]:
|
|
179
|
+
return self.business_types
|
|
180
|
+
|
|
181
|
+
@cached_property
|
|
182
|
+
def location(self) -> Optional[Location]:
|
|
183
|
+
"""Get recipient location - shares same client."""
|
|
184
|
+
data = self._lazy_get("location")
|
|
185
|
+
return Location(data, self._client) if data else None
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def total_transaction_amount(self) -> Optional[Decimal]:
|
|
189
|
+
return to_decimal(self._lazy_get("total_transaction_amount"))
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def total_transactions(self):
|
|
193
|
+
return self._lazy_get("total_transactions")
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def total_face_value_loan_amount(self) -> Optional[Decimal]:
|
|
197
|
+
return to_decimal(self._lazy_get("total_face_value_loan_amount"))
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def total_face_value_loan_transactions(self):
|
|
201
|
+
return self._lazy_get("total_face_value_loan_transactions")
|
|
202
|
+
|
|
203
|
+
def __repr__(self) -> str:
|
|
204
|
+
return f"<Recipient {self.name or '?'} ({self.recipient_id})>"
|