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,364 @@
1
+ """Recipient business category constants and categorizations for USASpending data.
2
+
3
+ This module contains all business category codes, groupings, and descriptions
4
+ as defined by the USASpending.gov API. Categories are organized hierarchically,
5
+ with some categories intentionally belonging to multiple groups.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # Dictionary of business category groups with their codes and descriptions
11
+ # This mirrors the hierarchical structure used by the USASpending API
12
+ BUSINESS_CATEGORY_GROUPS = {
13
+ "category_business": {
14
+ "category_business": "Category Business",
15
+ "small_business": "Small Business",
16
+ "other_than_small_business": "Not Designated a Small Business",
17
+ "corporate_entity_tax_exempt": "Corporate Entity Tax Exempt",
18
+ "corporate_entity_not_tax_exempt": "Corporate Entity Not Tax Exempt",
19
+ "partnership_or_limited_liability_partnership": "Partnership or Limited Liability Partnership",
20
+ "sole_proprietorship": "Sole Proprietorship",
21
+ "manufacturer_of_goods": "Manufacturer of Goods",
22
+ "subchapter_s_corporation": "Subchapter S Corporation",
23
+ "limited_liability_corporation": "Limited Liability Corporation",
24
+ },
25
+ "minority_owned": {
26
+ "minority_owned_business": "Minority Owned Business",
27
+ "alaskan_native_corporation_owned_firm": "Alaskan Native Corporation Owned Firm",
28
+ "american_indian_owned_business": "American Indian Owned Business",
29
+ "asian_pacific_american_owned_business": "Asian Pacific American Owned Business",
30
+ "black_american_owned_business": "Black American Owned Business",
31
+ "hispanic_american_owned_business": "Hispanic American Owned Business",
32
+ "native_american_owned_business": "Native American Owned Business",
33
+ "native_hawaiian_organization_owned_firm": "Native Hawaiian Organization Owned Firm",
34
+ "subcontinent_asian_indian_american_owned_business": "Indian (Subcontinent) American Owned Business",
35
+ "tribally_owned_firm": "Tribally Owned Firm",
36
+ "other_minority_owned_business": "Other Minority Owned Business",
37
+ },
38
+ "women_owned": {
39
+ "woman_owned_business": "Woman Owned Business",
40
+ "women_owned_small_business": "Women Owned Small Business",
41
+ "economically_disadvantaged_women_owned_small_business": "Economically Disadvantaged Women Owned Small Business",
42
+ "joint_venture_women_owned_small_business": "Joint Venture Women Owned Small Business",
43
+ "joint_venture_economically_disadvantaged_women_owned_small_business": "Joint Venture Economically Disadvantaged Women Owned Small Business",
44
+ },
45
+ "veteran_owned": {
46
+ "veteran_owned_business": "Veteran Owned Business",
47
+ "service_disabled_veteran_owned_business": "Service Disabled Veteran Owned Business",
48
+ },
49
+ "special_designations": {
50
+ "special_designations": "Special Designations",
51
+ "8a_program_participant": "8(a) Program Participant",
52
+ "ability_one_program": "AbilityOne Program Participant",
53
+ "dot_certified_disadvantaged_business_enterprise": "DoT Certified Disadvantaged Business Enterprise",
54
+ "emerging_small_business": "Emerging Small Business",
55
+ "federally_funded_research_and_development_corp": "Federally Funded Research and Development Corp",
56
+ "historically_underutilized_business_firm": "HUBZone Firm",
57
+ "labor_surplus_area_firm": "Labor Surplus Area Firm",
58
+ "sba_certified_8a_joint_venture": "SBA Certified 8 a Joint Venture",
59
+ "self_certified_small_disadvanted_business": "Self-Certified Small Disadvantaged Business",
60
+ "small_agricultural_cooperative": "Small Agricultural Cooperative",
61
+ "small_disadvantaged_business": "Small Disadvantaged Business",
62
+ "community_developed_corporation_owned_firm": "Community Developed Corporation Owned Firm",
63
+ "us_owned_business": "U.S.-Owned Business",
64
+ "foreign_owned_and_us_located_business": "Foreign-Owned and U.S.-Incorporated Business",
65
+ "foreign_owned": "Foreign Owned",
66
+ "foreign_government": "Foreign Government",
67
+ "international_organization": "International Organization",
68
+ "domestic_shelter": "Domestic Shelter",
69
+ "hospital": "Hospital",
70
+ "veterinary_hospital": "Veterinary Hospital",
71
+ },
72
+ "nonprofit": {
73
+ "nonprofit": "Nonprofit Organization",
74
+ "foundation": "Foundation",
75
+ "community_development_corporations": "Community Development Corporation",
76
+ },
77
+ "higher_education": {
78
+ "higher_education": "Higher Education",
79
+ "public_institution_of_higher_education": "Higher Education (Public)",
80
+ "private_institution_of_higher_education": "Higher Education (Private)",
81
+ "minority_serving_institution_of_higher_education": "Higher Education (Minority Serving)",
82
+ "educational_institution": "Educational Institution",
83
+ "school_of_forestry": "School of Forestry",
84
+ "veterinary_college": "Veterinary College",
85
+ },
86
+ "government": {
87
+ "government": "Government",
88
+ "national_government": "U.S. National Government",
89
+ "regional_and_state_government": "U.S. Regional/State Government",
90
+ "regional_organization": "U.S. Regional Government Organization",
91
+ "interstate_entity": "U.S. Interstate Government Entity",
92
+ "us_territory_or_possession": "U.S. Territory Government",
93
+ "local_government": "U.S. Local Government",
94
+ "indian_native_american_tribal_government": "Native American Tribal Government",
95
+ "authorities_and_commissions": "U.S. Government Authorities",
96
+ "council_of_governments": "Council of Governments",
97
+ },
98
+ "individuals": {
99
+ "individuals": "Individuals",
100
+ },
101
+ }
102
+
103
+ # Categories that intentionally appear in multiple groups
104
+ # Based on USASpending API logic
105
+ OVERLAPPING_CATEGORIES = {
106
+ # These appear in both category_business and special_designations
107
+ "emerging_small_business": ["category_business", "special_designations"],
108
+ "small_agricultural_cooperative": ["category_business", "special_designations"],
109
+ "small_disadvantaged_business": ["category_business", "special_designations"],
110
+ "self_certified_small_disadvanted_business": [
111
+ "category_business",
112
+ "special_designations",
113
+ ],
114
+ # Women-owned small businesses also trigger small_business
115
+ "women_owned_small_business": ["women_owned", "category_business"],
116
+ "economically_disadvantaged_women_owned_small_business": [
117
+ "women_owned",
118
+ "category_business",
119
+ ],
120
+ "joint_venture_women_owned_small_business": ["women_owned", "category_business"],
121
+ "joint_venture_economically_disadvantaged_women_owned_small_business": [
122
+ "women_owned",
123
+ "category_business",
124
+ ],
125
+ }
126
+
127
+
128
+ # Create a complete mapping including overlaps
129
+ def _build_complete_descriptions():
130
+ """Build complete description map including overlapping categories."""
131
+ descriptions = {}
132
+ for group_name, group_dict in BUSINESS_CATEGORY_GROUPS.items():
133
+ for code, description in group_dict.items():
134
+ if code not in descriptions:
135
+ descriptions[code] = description
136
+ return descriptions
137
+
138
+
139
+ BUSINESS_CATEGORY_DESCRIPTIONS = _build_complete_descriptions()
140
+
141
+ # Create frozensets for each group
142
+ CATEGORY_BUSINESS_CODES = frozenset(
143
+ BUSINESS_CATEGORY_GROUPS["category_business"].keys()
144
+ )
145
+ MINORITY_OWNED_CODES = frozenset(BUSINESS_CATEGORY_GROUPS["minority_owned"].keys())
146
+ WOMEN_OWNED_CODES = frozenset(BUSINESS_CATEGORY_GROUPS["women_owned"].keys())
147
+ VETERAN_OWNED_CODES = frozenset(BUSINESS_CATEGORY_GROUPS["veteran_owned"].keys())
148
+ SPECIAL_DESIGNATIONS_CODES = frozenset(
149
+ BUSINESS_CATEGORY_GROUPS["special_designations"].keys()
150
+ )
151
+ NONPROFIT_CODES = frozenset(BUSINESS_CATEGORY_GROUPS["nonprofit"].keys())
152
+ HIGHER_EDUCATION_CODES = frozenset(BUSINESS_CATEGORY_GROUPS["higher_education"].keys())
153
+ GOVERNMENT_CODES = frozenset(BUSINESS_CATEGORY_GROUPS["government"].keys())
154
+ INDIVIDUALS_CODES = frozenset(BUSINESS_CATEGORY_GROUPS["individuals"].keys())
155
+
156
+ # All valid business category codes
157
+ ALL_BUSINESS_CATEGORIES = frozenset(BUSINESS_CATEGORY_DESCRIPTIONS.keys())
158
+
159
+ # For backward compatibility - matches original frozenset structure
160
+ BUSINESS_CATEGORIES = ALL_BUSINESS_CATEGORIES
161
+
162
+
163
+ def get_category_group(code: str) -> str:
164
+ """Get the primary category group name for a given business category code.
165
+
166
+ For categories that belong to multiple groups, returns the first/primary group.
167
+ Use get_all_groups_for_code() to get all groups.
168
+
169
+ Args:
170
+ code: Business category code (e.g., "small_business", "nonprofit")
171
+
172
+ Returns:
173
+ Primary category group name (e.g., "category_business", "nonprofit") or empty string if not found
174
+ """
175
+ if not isinstance(code, str):
176
+ return ""
177
+
178
+ # Check overlapping categories first for their primary group
179
+ if code in OVERLAPPING_CATEGORIES:
180
+ return OVERLAPPING_CATEGORIES[code][0]
181
+
182
+ # Check each group
183
+ for group_name, codes in BUSINESS_CATEGORY_GROUPS.items():
184
+ if code in codes:
185
+ return group_name
186
+ return ""
187
+
188
+
189
+ def get_all_groups_for_code(code: str) -> list[str]:
190
+ """Get all category groups that a business category code belongs to.
191
+
192
+ Handles categories that belong to multiple groups (e.g., emerging_small_business).
193
+
194
+ Args:
195
+ code: Business category code
196
+
197
+ Returns:
198
+ List of all category group names the code belongs to
199
+ """
200
+ if not isinstance(code, str):
201
+ return []
202
+
203
+ groups = []
204
+
205
+ # Check if it's an overlapping category
206
+ if code in OVERLAPPING_CATEGORIES:
207
+ return OVERLAPPING_CATEGORIES[code].copy()
208
+
209
+ # Otherwise check each group
210
+ for group_name, codes in BUSINESS_CATEGORY_GROUPS.items():
211
+ if code in codes:
212
+ groups.append(group_name)
213
+
214
+ return groups
215
+
216
+
217
+ def is_valid_business_category(code: str) -> bool:
218
+ """Check if a code is a valid business category.
219
+
220
+ Args:
221
+ code: Business category code to validate
222
+
223
+ Returns:
224
+ True if the code is valid, False otherwise
225
+ """
226
+ if not isinstance(code, str):
227
+ return False
228
+ return code in ALL_BUSINESS_CATEGORIES
229
+
230
+
231
+ def get_description(code: str) -> str:
232
+ """Get the description for a given business category code.
233
+
234
+ Args:
235
+ code: Business category code
236
+
237
+ Returns:
238
+ Description string or empty string if not found
239
+ """
240
+ if not isinstance(code, str):
241
+ return ""
242
+ return BUSINESS_CATEGORY_DESCRIPTIONS.get(code, "")
243
+
244
+
245
+ # Convenience functions for common category checks
246
+ def is_small_business(code: str) -> bool:
247
+ """Check if a code represents a small business.
248
+
249
+ Args:
250
+ code: Business category code
251
+
252
+ Returns:
253
+ True if the code represents a small business
254
+ """
255
+ if not isinstance(code, str):
256
+ return False
257
+
258
+ # Direct small business code
259
+ if code == "small_business":
260
+ return True
261
+
262
+ # Women-owned small businesses
263
+ if code in {
264
+ "women_owned_small_business",
265
+ "economically_disadvantaged_women_owned_small_business",
266
+ "joint_venture_women_owned_small_business",
267
+ "joint_venture_economically_disadvantaged_women_owned_small_business",
268
+ }:
269
+ return True
270
+
271
+ # Special designation small businesses
272
+ if code in {
273
+ "emerging_small_business",
274
+ "self_certified_small_disadvanted_business",
275
+ "small_agricultural_cooperative",
276
+ "small_disadvantaged_business",
277
+ }:
278
+ return True
279
+
280
+ return False
281
+
282
+
283
+ def is_minority_owned(code: str) -> bool:
284
+ """Check if a code represents a minority-owned business.
285
+
286
+ Args:
287
+ code: Business category code
288
+
289
+ Returns:
290
+ True if the code represents a minority-owned business
291
+ """
292
+ if not isinstance(code, str):
293
+ return False
294
+ return code in MINORITY_OWNED_CODES
295
+
296
+
297
+ def is_women_owned(code: str) -> bool:
298
+ """Check if a code represents a women-owned business.
299
+
300
+ Args:
301
+ code: Business category code
302
+
303
+ Returns:
304
+ True if the code represents a women-owned business
305
+ """
306
+ if not isinstance(code, str):
307
+ return False
308
+ return code in WOMEN_OWNED_CODES
309
+
310
+
311
+ def is_veteran_owned(code: str) -> bool:
312
+ """Check if a code represents a veteran-owned business.
313
+
314
+ Args:
315
+ code: Business category code
316
+
317
+ Returns:
318
+ True if the code represents a veteran-owned business
319
+ """
320
+ if not isinstance(code, str):
321
+ return False
322
+ return code in VETERAN_OWNED_CODES
323
+
324
+
325
+ def is_government_entity(code: str) -> bool:
326
+ """Check if a code represents a government entity.
327
+
328
+ Args:
329
+ code: Business category code
330
+
331
+ Returns:
332
+ True if the code represents a government entity
333
+ """
334
+ if not isinstance(code, str):
335
+ return False
336
+ return code in GOVERNMENT_CODES
337
+
338
+
339
+ def is_educational_institution(code: str) -> bool:
340
+ """Check if a code represents an educational institution.
341
+
342
+ Args:
343
+ code: Business category code
344
+
345
+ Returns:
346
+ True if the code represents an educational institution
347
+ """
348
+ if not isinstance(code, str):
349
+ return False
350
+ return code in HIGHER_EDUCATION_CODES
351
+
352
+
353
+ def is_nonprofit_organization(code: str) -> bool:
354
+ """Check if a code represents a nonprofit organization.
355
+
356
+ Args:
357
+ code: Business category code
358
+
359
+ Returns:
360
+ True if the code represents a nonprofit organization
361
+ """
362
+ if not isinstance(code, str):
363
+ return False
364
+ return code in NONPROFIT_CODES
@@ -0,0 +1,58 @@
1
+ """Recipient spending model for USASpending spending by recipient data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional, TYPE_CHECKING
6
+ from decimal import Decimal
7
+ from ..utils.formatter import to_decimal, round_to_millions
8
+ from .recipient import Recipient
9
+
10
+ if TYPE_CHECKING:
11
+ from ..client import USASpendingClient
12
+
13
+
14
+ class RecipientSpending(Recipient):
15
+ """Model for spending by recipient data.
16
+
17
+ Represents spending data grouped by recipient with recipient-specific
18
+ fields like recipient_id and UEI.
19
+ """
20
+
21
+ def __init__(self, data: dict, client: Optional["USASpendingClient"] = None):
22
+ """Initialize RecipientSpending model.
23
+
24
+ Args:
25
+ data: Raw recipient spending data from API
26
+ client: USASpendingClient client instance
27
+ """
28
+ super().__init__(data, client)
29
+
30
+ @property
31
+ def duns(self) -> Optional[str]:
32
+ return self.code
33
+
34
+ @property
35
+ def code(self) -> Optional[str]:
36
+ """DUNS number from spending data (stored in 'code' field)."""
37
+ return self.get_value(["code"], default=None)
38
+
39
+ @property
40
+ def amount(self) -> Optional[Decimal]:
41
+ """Total spending amount for this record."""
42
+ return to_decimal(self.get_value(["amount"]))
43
+
44
+ @property
45
+ def total_outlays(self) -> Optional[Decimal]:
46
+ """Total outlays for this spending record."""
47
+ return to_decimal(self.get_value(["total_outlays"]))
48
+
49
+ @property
50
+ def spending_level(self) -> Optional[str]:
51
+ """The spending level used for this data (transactions, awards, subawards)."""
52
+ return self.get_value(["spending_level"])
53
+
54
+ def __repr__(self) -> str:
55
+ """String representation of RecipientSpending."""
56
+ name = self.name or "Unknown Recipient"
57
+ formatted_amount = round_to_millions(self.amount) or 0
58
+ return f"<RecipientSpending {name}: {formatted_amount}>"
@@ -0,0 +1,70 @@
1
+ """Base model for USASpending spending by category data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional, TYPE_CHECKING
6
+ from decimal import Decimal
7
+ from ..utils.formatter import to_decimal
8
+ from .base_model import BaseModel
9
+
10
+ if TYPE_CHECKING:
11
+ from ..client import USASpendingClient
12
+
13
+
14
+ class Spending(BaseModel):
15
+ """Base model for spending by category data.
16
+
17
+ Represents common fields across spending by recipient and district categories.
18
+ This model provides access to spending data with amounts, names, codes, and outlays.
19
+ """
20
+
21
+ def __init__(self, data: dict, client: Optional["USASpendingClient"] = None):
22
+ """Initialize Spending model.
23
+
24
+ Args:
25
+ data: Raw spending data from API
26
+ client: USASpendingClient client instance
27
+ """
28
+ super().__init__(data)
29
+ self._client = client
30
+
31
+ @property
32
+ def id(self) -> Optional[int]:
33
+ """Database ID for the spending record."""
34
+ return self.get_value(["id"])
35
+
36
+ @property
37
+ def name(self) -> Optional[str]:
38
+ """Display name for the spending category (recipient name or district name)."""
39
+ return self.get_value(["name"])
40
+
41
+ @property
42
+ def code(self) -> Optional[str]:
43
+ """Code associated with the spending record (DUNS, district code, etc.)."""
44
+ return self.get_value(["code"])
45
+
46
+ @property
47
+ def amount(self) -> Optional[Decimal]:
48
+ """Total spending amount for this record."""
49
+ return to_decimal(self.get_value(["amount"]))
50
+
51
+ @property
52
+ def total_outlays(self) -> Optional[Decimal]:
53
+ """Total outlays for this spending record."""
54
+ return to_decimal(self.get_value(["total_outlays"]))
55
+
56
+ @property
57
+ def spending_level(self) -> Optional[str]:
58
+ """The spending level used for this data (transactions, awards, subawards)."""
59
+ return self.get_value(["spending_level"])
60
+
61
+ @property
62
+ def category(self) -> Optional[str]:
63
+ """The category type (recipient or district)."""
64
+ return self.get_value(["category"])
65
+
66
+ def __repr__(self) -> str:
67
+ """String representation of Spending."""
68
+ name = self.name or "Unknown"
69
+ amount = self.amount or 0
70
+ return f"<Spending {name}: ${amount:,.2f}>"
@@ -0,0 +1,43 @@
1
+ """State spending model for USASpending spending by state/territory data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional, TYPE_CHECKING
6
+
7
+ from .spending import Spending
8
+
9
+ if TYPE_CHECKING:
10
+ from ..client import USASpendingClient
11
+
12
+
13
+ class StateSpending(Spending):
14
+ """Model for spending by state/territory data.
15
+
16
+ Represents spending data grouped by state/territory with
17
+ state-specific properties.
18
+ """
19
+
20
+ def __init__(self, data: dict, client: Optional["USASpendingClient"] = None):
21
+ """Initialize StateSpending model.
22
+
23
+ Args:
24
+ data: Raw state spending data from API
25
+ client: USASpendingClient client instance
26
+ """
27
+ super().__init__(data, client)
28
+
29
+ @property
30
+ def state_code(self) -> Optional[str]:
31
+ """State/territory code (e.g., 'WA', 'CA')."""
32
+ return self.code
33
+
34
+ @property
35
+ def state_name(self) -> Optional[str]:
36
+ """Full state/territory name (e.g., 'Washington')."""
37
+ return self.name
38
+
39
+ def __repr__(self) -> str:
40
+ """String representation of StateSpending."""
41
+ name = self.state_name or "Unknown State"
42
+ amount = self.amount or 0
43
+ return f"<StateSpending {name}: ${amount:,.2f}>"