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,72 @@
1
+ """Factory for creating appropriate Award subclass instances."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Dict, Any, Optional, TYPE_CHECKING
5
+
6
+ from .award_types import get_category_for_code
7
+ from ..exceptions import ValidationError
8
+
9
+ if TYPE_CHECKING:
10
+ from .award import Award
11
+ from ..client import USASpendingClient
12
+
13
+
14
+ def create_award(
15
+ data_or_id: Dict[str, Any] | str, client: Optional[USASpendingClient] = None
16
+ ) -> Award:
17
+ """
18
+ Factory function to create the appropriate Award subclass based on the award data.
19
+
20
+ Args:
21
+ data_or_id: Award data dictionary or unique award ID string
22
+ client: Optional USASpendingClient instance
23
+
24
+ Returns:
25
+ Appropriate Award subclass instance (Contract, Grant, IDV, Loan, or base Award)
26
+ """
27
+ # Import here to avoid circular imports
28
+ from .award import Award
29
+ from .contract import Contract
30
+ from .grant import Grant
31
+ from .idv import IDV
32
+ from .loan import Loan
33
+
34
+ # If it's just an ID, create base Award and let lazy loading determine type
35
+ if isinstance(data_or_id, str):
36
+ return Award(data_or_id, client)
37
+
38
+ if not isinstance(data_or_id, dict):
39
+ raise ValidationError("Award factory expects a dict or an award_id string")
40
+
41
+ # Determine award type from data
42
+ category = data_or_id.get("category", "").lower()
43
+ award_type = data_or_id.get("type", "")
44
+
45
+ award_class_map = {
46
+ "contract": Contract,
47
+ "idv": IDV,
48
+ "grant": Grant,
49
+ "loan": Loan,
50
+ }
51
+
52
+ # Determine class from category first (most reliable)
53
+ award_class = award_class_map.get(category)
54
+
55
+ # Fallback to type codes if category doesn't match
56
+ if not award_class and award_type:
57
+ code_category = get_category_for_code(award_type)
58
+ if code_category:
59
+ # Map config category names to award class names
60
+ category_map = {
61
+ "contracts": "contract",
62
+ "idvs": "idv",
63
+ "grants": "grant",
64
+ "loans": "loan",
65
+ }
66
+ mapped_category = category_map.get(code_category)
67
+ if mapped_category:
68
+ award_class = award_class_map.get(mapped_category)
69
+
70
+ # Use the determined class or default to the base Award class
71
+ cls = award_class or Award
72
+ return cls(data_or_id, client)
@@ -0,0 +1,114 @@
1
+ """Award type constants and categorizations for USASpending data.
2
+
3
+ This module contains all award type codes, groupings, and descriptions
4
+ as defined by the USASpending.gov API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ # Dictionary of award type groups with their codes and descriptions
10
+ # This is the single source of truth for award type categorization
11
+ AWARD_TYPE_GROUPS = {
12
+ "contracts": {
13
+ "A": "BPA Call",
14
+ "B": "Purchase Order",
15
+ "C": "Delivery Order",
16
+ "D": "Definitive Contract",
17
+ },
18
+ "loans": {"07": "Direct Loan", "08": "Guaranteed/Insured Loan"},
19
+ "idvs": {
20
+ "IDV_A": "GWAC Government Wide Acquisition Contract",
21
+ "IDV_B": "IDC Multi-Agency Contract, Other Indefinite Delivery Contract",
22
+ "IDV_B_A": "IDC Indefinite Delivery Contract / Requirements",
23
+ "IDV_B_B": "IDC Indefinite Delivery Contract / Indefinite Quantity",
24
+ "IDV_B_C": "IDC Indefinite Delivery Contract / Definite Quantity",
25
+ "IDV_C": "FSS Federal Supply Schedule",
26
+ "IDV_D": "BOA Basic Ordering Agreement",
27
+ "IDV_E": "BPA Blanket Purchase Agreement",
28
+ },
29
+ "grants": {
30
+ "02": "Block Grant",
31
+ "03": "Formula Grant",
32
+ "04": "Project Grant",
33
+ "05": "Cooperative Agreement",
34
+ },
35
+ "direct_payments": {
36
+ "06": "Direct Payment for Specified Use",
37
+ "10": "Direct Payment with Unrestricted Use",
38
+ },
39
+ "other_assistance": {
40
+ "09": "Insurance",
41
+ "11": "Other Financial Assistance",
42
+ "-1": "Not Specified",
43
+ },
44
+ }
45
+
46
+ # Create a flattened map for easy description lookups
47
+ AWARD_TYPE_DESCRIPTIONS = {
48
+ code: description
49
+ for group in AWARD_TYPE_GROUPS.values()
50
+ for code, description in group.items()
51
+ }
52
+
53
+ # Regenerate frozensets from this single source of truth
54
+ CONTRACT_CODES = frozenset(AWARD_TYPE_GROUPS["contracts"].keys())
55
+ IDV_CODES = frozenset(AWARD_TYPE_GROUPS["idvs"].keys())
56
+ LOAN_CODES = frozenset(AWARD_TYPE_GROUPS["loans"].keys())
57
+ GRANT_CODES = frozenset(AWARD_TYPE_GROUPS["grants"].keys())
58
+ DIRECT_PAYMENT_CODES = frozenset(AWARD_TYPE_GROUPS["direct_payments"].keys())
59
+ OTHER_CODES = frozenset(AWARD_TYPE_GROUPS["other_assistance"].keys())
60
+
61
+ # All valid award type codes
62
+ ALL_AWARD_CODES = (
63
+ CONTRACT_CODES
64
+ | IDV_CODES
65
+ | LOAN_CODES
66
+ | GRANT_CODES
67
+ | DIRECT_PAYMENT_CODES
68
+ | OTHER_CODES
69
+ )
70
+
71
+
72
+ def get_category_for_code(code: str) -> str:
73
+ """Get the category name for a given award type code.
74
+
75
+ Args:
76
+ code: Award type code (e.g., "A", "02", "IDV_A")
77
+
78
+ Returns:
79
+ Category name (e.g., "contracts", "grants", "idvs") or empty string if not found
80
+ """
81
+ if not isinstance(code, str):
82
+ return ""
83
+ for category_name, codes in AWARD_TYPE_GROUPS.items():
84
+ if code in codes:
85
+ return category_name
86
+ return ""
87
+
88
+
89
+ def is_valid_award_type(code: str) -> bool:
90
+ """Check if a code is a valid award type.
91
+
92
+ Args:
93
+ code: Award type code to validate
94
+
95
+ Returns:
96
+ True if the code is valid, False otherwise
97
+ """
98
+ if not isinstance(code, str):
99
+ return False
100
+ return code in ALL_AWARD_CODES
101
+
102
+
103
+ def get_description(code: str) -> str:
104
+ """Get the description for a given award type code.
105
+
106
+ Args:
107
+ code: Award type code
108
+
109
+ Returns:
110
+ Description string or empty string if not found
111
+ """
112
+ if not isinstance(code, str):
113
+ return ""
114
+ return AWARD_TYPE_DESCRIPTIONS.get(code, "")
@@ -0,0 +1,214 @@
1
+ # usaspending/models/base_model.py
2
+ from typing import Optional, Dict, Any, List, Set, TYPE_CHECKING
3
+ from weakref import ref
4
+
5
+ if TYPE_CHECKING:
6
+ from ..client import USASpendingClient
7
+
8
+ from ..exceptions import ValidationError, DetachedInstanceError
9
+
10
+
11
+ class BaseModel:
12
+ """Base class for all models with fundamental behaviors."""
13
+
14
+ def __init__(self, data: Dict[str, Any]):
15
+ self._data = data or {}
16
+
17
+ @staticmethod
18
+ def validate_init_data(
19
+ data_or_id: Any,
20
+ model_name: str,
21
+ id_field: Optional[str] = None,
22
+ allow_string_id: bool = False,
23
+ ) -> Dict[str, Any]:
24
+ """
25
+ Validate and normalize initialization data for models.
26
+
27
+ Args:
28
+ data_or_id: Input data (dict, string, or other type)
29
+ model_name: Name of the model for error messages
30
+ id_field: Field name for ID when string is provided
31
+ allow_string_id: Whether to accept string as ID
32
+
33
+ Returns:
34
+ Normalized dictionary of data
35
+
36
+ Raises:
37
+ ValidationError: If data is invalid
38
+ """
39
+ if data_or_id is None:
40
+ raise ValidationError(
41
+ f"{model_name} data cannot be None. This may indicate an API or caching issue."
42
+ )
43
+
44
+ if isinstance(data_or_id, dict):
45
+ # Return a copy to avoid modifying the original
46
+ return data_or_id.copy()
47
+
48
+ if isinstance(data_or_id, str):
49
+ if not allow_string_id:
50
+ raise ValidationError(f"{model_name} expects dict, got str")
51
+
52
+ if not id_field:
53
+ raise ValidationError(f"{model_name} expects dict, got str")
54
+
55
+ if not data_or_id.strip():
56
+ raise ValidationError(f"{model_name} ID cannot be empty or whitespace")
57
+
58
+ return {id_field: data_or_id}
59
+
60
+ # Invalid type
61
+ type_name = type(data_or_id).__name__
62
+ if allow_string_id:
63
+ raise ValidationError(
64
+ f"{model_name} expects dict or string, got {type_name}"
65
+ )
66
+ else:
67
+ raise ValidationError(f"{model_name} expects dict, got {type_name}")
68
+
69
+ @property
70
+ def raw(self) -> Dict[str, Any]:
71
+ """Get raw data dictionary."""
72
+ return self._data
73
+
74
+ def to_dict(self) -> Dict[str, Any]:
75
+ """Convert to dictionary."""
76
+ return self._data
77
+
78
+ def get_value(self, keys: List[str], default: Any = None) -> Any:
79
+ """Return the first non-None value from the given keys."""
80
+ if not isinstance(keys, list):
81
+ keys = [keys]
82
+
83
+ if not isinstance(self._data, dict):
84
+ raise TypeError("Empty object data")
85
+ for key in keys:
86
+ if key in self._data:
87
+ value = self._data[key]
88
+ if value is not None: # Check for non-None instead of truthiness
89
+ return value
90
+ return default
91
+
92
+
93
+ class ClientAwareModel(BaseModel):
94
+ """Base class for all models that need API client access."""
95
+
96
+ def __init__(self, data: Dict[str, Any], client: "USASpendingClient"):
97
+ super().__init__(data)
98
+ self._client_ref = ref(client) # Weak reference prevents circular refs
99
+
100
+ @property
101
+ def _client(self) -> "USASpendingClient":
102
+ """Get client instance if still alive and usable.
103
+
104
+ Returns:
105
+ USASpendingClient: The client instance.
106
+
107
+ Raises:
108
+ DetachedInstanceError: If the client has been closed or garbage collected.
109
+ """
110
+ client = self._client_ref() if self._client_ref else None
111
+
112
+ if client is None:
113
+ raise DetachedInstanceError(
114
+ f"Cannot access {self.__class__.__name__} properties: "
115
+ "the USASpendingClient has been garbage collected. "
116
+ "Ensure the client remains in scope, or access all needed data "
117
+ "within the 'with USASpendingClient()' context block."
118
+ )
119
+
120
+ if hasattr(client, "_closed") and client._closed:
121
+ raise DetachedInstanceError(
122
+ f"Cannot access {self.__class__.__name__} properties: "
123
+ "the USASpendingClient session is closed. "
124
+ "Access all lazy-loaded properties within the 'with' block, "
125
+ "or use explicit cleanup (client.close()) only after you're done with the models."
126
+ )
127
+
128
+ return client
129
+
130
+ def reattach(
131
+ self,
132
+ client: "USASpendingClient",
133
+ recursive: bool = False,
134
+ _visited: Optional[Set[int]] = None,
135
+ ) -> None:
136
+ """Reattach this model to a new client session.
137
+
138
+ This method updates the model's client reference to point to a new
139
+ USASpendingClient, allowing you to use objects created in one session
140
+ within a different session context.
141
+
142
+ Args:
143
+ client: The new USASpendingClient to attach to.
144
+ recursive: If True, recursively reattach nested LazyRecord objects
145
+ (e.g., award.recipient, award.awarding_agency). Default False.
146
+ _visited: Internal parameter for cycle detection. Do not pass manually.
147
+
148
+ Example:
149
+ >>> # Create objects in one session
150
+ >>> with USASpendingClient() as client:
151
+ ... award = client.awards.find_by_award_id("123")
152
+ ... # Session closes here
153
+ ...
154
+ >>> # Later, reattach to a new session
155
+ >>> with USASpendingClient() as new_client:
156
+ ... award.reattach(new_client)
157
+ ... print(award.subaward_count) # Now works!
158
+ ...
159
+ >>> # Recursive reattach for nested objects
160
+ >>> with USASpendingClient() as new_client:
161
+ ... award.reattach(new_client, recursive=True)
162
+ ... # Now award.recipient and award.awarding_agency also reattached
163
+ ... print(award.recipient.name)
164
+
165
+ Note:
166
+ QueryBuilder properties (like award.transactions) are not affected
167
+ by reattach. They create new query builders when accessed, which
168
+ automatically use the reattached client.
169
+
170
+ Raises:
171
+ DetachedInstanceError: If the provided client is closed.
172
+ """
173
+ # Validate that the new client is usable
174
+ if hasattr(client, "_closed") and client._closed:
175
+ raise DetachedInstanceError(
176
+ f"Cannot reattach {self.__class__.__name__} to a closed client. "
177
+ "Ensure the client is active (within a 'with' block or before close())."
178
+ )
179
+
180
+ # Update this model's client reference
181
+ self._client_ref = ref(client)
182
+
183
+ # Handle recursive reattachment if requested
184
+ if recursive:
185
+ # Import here to avoid circular dependency
186
+ from .lazy_record import LazyRecord
187
+
188
+ # Initialize visited set for cycle detection
189
+ if _visited is None:
190
+ _visited = set()
191
+
192
+ # Prevent infinite recursion on circular references
193
+ obj_id = id(self)
194
+ if obj_id in _visited:
195
+ return
196
+ _visited.add(obj_id)
197
+
198
+ # Find and reattach all LazyRecord properties
199
+ for attr_name in dir(self):
200
+ # Skip private attributes and methods
201
+ if attr_name.startswith("_"):
202
+ continue
203
+
204
+ try:
205
+ attr = getattr(self, attr_name)
206
+
207
+ # Recursively reattach LazyRecord instances
208
+ if isinstance(attr, LazyRecord):
209
+ attr.reattach(client, recursive=True, _visited=_visited)
210
+
211
+ except (AttributeError, Exception):
212
+ # Skip properties that raise errors during access
213
+ # (e.g., properties that require API calls, methods, etc.)
214
+ pass
@@ -0,0 +1,147 @@
1
+ """Contract award model for USASpending data."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Dict, Any, Optional, TYPE_CHECKING
5
+ from functools import cached_property
6
+ from decimal import Decimal
7
+
8
+ from .award import Award
9
+ from ..utils.formatter import to_decimal
10
+
11
+ if TYPE_CHECKING:
12
+ from ..queries.subawards_search import SubAwardsSearch
13
+
14
+
15
+ class Contract(Award):
16
+ """Contract award type including definitive contracts and purchase orders."""
17
+
18
+ TYPE_FIELDS = [
19
+ "piid",
20
+ "base_exercised_options",
21
+ "base_and_all_options",
22
+ "contract_award_type",
23
+ "naics_code",
24
+ "naics_description",
25
+ "naics_hierarchy",
26
+ "psc_code",
27
+ "psc_description",
28
+ "psc_hierarchy",
29
+ "latest_transaction_contract_data",
30
+ ]
31
+
32
+ SEARCH_FIELDS = Award.SEARCH_FIELDS + [
33
+ "Start Date",
34
+ "End Date",
35
+ "Award Amount",
36
+ "Total Outlays",
37
+ "Contract Award Type",
38
+ "NAICS",
39
+ "PSC",
40
+ ]
41
+
42
+ @property
43
+ def piid(self) -> Optional[str]:
44
+ """
45
+ Procurement Instrument Identifier - A unique identifier assigned to a federal
46
+ contract, purchase order, basic ordering agreement, basic agreement, and
47
+ blanket purchase agreement. It is used to track the contract, and any
48
+ modifications or transactions related to it. After October 2017, it is
49
+ between 13 and 17 digits, both letters and numbers.
50
+ """
51
+ return self._lazy_get("piid")
52
+
53
+ @property
54
+ def base_exercised_options(self) -> Optional[Decimal]:
55
+ """The sum of the base_exercised_options_val from associated transactions"""
56
+ return to_decimal(self._lazy_get("base_exercised_options", default=None))
57
+
58
+ @property
59
+ def base_and_all_options(self) -> Optional[Decimal]:
60
+ """The sum of the base_and_all_options_value from associated transactions"""
61
+ return to_decimal(self._lazy_get("base_and_all_options", default=None))
62
+
63
+ @property
64
+ def contract_award_type(self) -> Optional[str]:
65
+ """Contract award type description."""
66
+ return self.type_description
67
+
68
+ @property
69
+ def naics_code(self) -> Optional[str]:
70
+ """NAICS industry classification code."""
71
+ naics_data = self._lazy_get("naics", "NAICS")
72
+ if isinstance(naics_data, dict):
73
+ return naics_data.get("code")
74
+ if self.naics_hierarchy and isinstance(
75
+ self.naics_hierarchy.get("base_code"), dict
76
+ ):
77
+ return self.naics_hierarchy["base_code"].get("code")
78
+ if self.latest_transaction_contract_data:
79
+ return self.latest_transaction_contract_data.get("naics")
80
+ return None
81
+
82
+ @property
83
+ def naics_description(self) -> Optional[str]:
84
+ """NAICS industry classification description."""
85
+ naics_data = self._lazy_get("naics", "NAICS")
86
+ if isinstance(naics_data, dict):
87
+ return naics_data.get("description")
88
+ if self.naics_hierarchy and isinstance(
89
+ self.naics_hierarchy.get("base_code"), dict
90
+ ):
91
+ return self.naics_hierarchy["base_code"].get("description")
92
+ if self.latest_transaction_contract_data:
93
+ return self.latest_transaction_contract_data.get("naics_description")
94
+ return None
95
+
96
+ @property
97
+ def psc_code(self) -> Optional[str]:
98
+ """Product/Service Code (PSC) for contracts."""
99
+ psc_data = self._lazy_get("psc", "PSC")
100
+ if isinstance(psc_data, dict):
101
+ return psc_data.get("code")
102
+ if self.psc_hierarchy and isinstance(self.psc_hierarchy.get("base_code"), dict):
103
+ return self.psc_hierarchy["base_code"].get("code")
104
+ return None
105
+
106
+ @property
107
+ def psc_description(self) -> Optional[str]:
108
+ """Product/Service Code (PSC) description."""
109
+ psc_data = self._lazy_get("psc", "PSC")
110
+ if isinstance(psc_data, dict):
111
+ return psc_data.get("description")
112
+ if self.psc_hierarchy and isinstance(self.psc_hierarchy.get("base_code"), dict):
113
+ return self.psc_hierarchy["base_code"].get("description")
114
+ return None
115
+
116
+ @cached_property
117
+ def psc_hierarchy(self) -> Optional[Dict[str, Any]]:
118
+ """Product/Service Code (PSC) hierarchy information."""
119
+ return self._lazy_get("psc_hierarchy")
120
+
121
+ @cached_property
122
+ def naics_hierarchy(self) -> Optional[Dict[str, Any]]:
123
+ """North American Industry Classification System (NAICS) hierarchy."""
124
+ return self._lazy_get("naics_hierarchy")
125
+
126
+ @cached_property
127
+ def latest_transaction_contract_data(self) -> Optional[Dict[str, Any]]:
128
+ """Latest contract transaction data with procurement-specific details."""
129
+ return self._lazy_get("latest_transaction_contract_data")
130
+
131
+ @property
132
+ def subawards(self) -> "SubAwardsSearch":
133
+ """Get subawards query builder for this contract award with appropriate award type filters.
134
+
135
+ Returns a SubAwardsSearch object that can be further filtered and chained.
136
+ Automatically applies contract award type filters.
137
+
138
+ Examples:
139
+ >>> contract.subawards.count() # Get count without loading all data
140
+ >>> contract.subawards.limit(10).all() # Get first 10 subawards
141
+ >>> list(contract.subawards) # Iterate through all subawards
142
+ """
143
+ from .award_types import CONTRACT_CODES
144
+
145
+ return self._client.subawards.award_id(
146
+ self.generated_unique_award_id
147
+ ).award_type_codes(*CONTRACT_CODES)
@@ -0,0 +1,70 @@
1
+ """District spending model for USASpending spending by district 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 DistrictSpending(Spending):
14
+ """Model for spending by congressional district data.
15
+
16
+ Represents spending data grouped by congressional district with
17
+ district-specific parsing and display logic.
18
+ """
19
+
20
+ def __init__(self, data: dict, client: Optional["USASpendingClient"] = None):
21
+ """Initialize DistrictSpending model.
22
+
23
+ Args:
24
+ data: Raw district spending data from API
25
+ client: USASpendingClient client instance
26
+ """
27
+ super().__init__(data, client)
28
+
29
+ @property
30
+ def district_code(self) -> Optional[str]:
31
+ """Congressional district code."""
32
+ return self.code
33
+
34
+ @property
35
+ def state_code(self) -> Optional[str]:
36
+ """Extract state code from district name if available.
37
+
38
+ District names typically follow format like 'TX-12' or 'MS-MULTIPLE DISTRICTS'.
39
+ """
40
+ if self.name:
41
+ # Names like "TX-12" or "MS-MULTIPLE DISTRICTS"
42
+ parts = self.name.split("-", 1)
43
+ if len(parts) >= 2:
44
+ return parts[0]
45
+ return None
46
+
47
+ @property
48
+ def district_number(self) -> Optional[str]:
49
+ """Extract district number from district name if available.
50
+
51
+ Returns the numeric part or special designation like 'MULTIPLE DISTRICTS'.
52
+ """
53
+ if self.name:
54
+ # Names like "TX-12" or "MS-MULTIPLE DISTRICTS"
55
+ parts = self.name.split("-", 1)
56
+ if len(parts) >= 2:
57
+ return parts[1]
58
+ return None
59
+
60
+ @property
61
+ def is_multiple_districts(self) -> bool:
62
+ """Check if this represents multiple districts in a state."""
63
+ district_num = self.district_number
64
+ return district_num is not None and "MULTIPLE" in district_num.upper()
65
+
66
+ def __repr__(self) -> str:
67
+ """String representation of DistrictSpending."""
68
+ name = self.name or "Unknown District"
69
+ amount = self.amount or 0
70
+ return f"<DistrictSpending {name}: ${amount:,.2f}>"
@@ -0,0 +1,74 @@
1
+ # src/usaspending/models/download.py
2
+
3
+ from __future__ import annotations
4
+ from typing import Optional, Literal
5
+ from enum import Enum
6
+
7
+ from ..utils.formatter import to_float, to_int
8
+ from .base_model import BaseModel
9
+
10
+ AwardType = Literal["contract", "assistance", "idv"]
11
+ FileFormat = Literal["csv", "tsv", "pstxt"]
12
+
13
+
14
+ class DownloadState(Enum):
15
+ """Enumeration for download job states."""
16
+
17
+ PENDING = "pending" # Custom state before first API check
18
+ READY = "ready"
19
+ RUNNING = "running"
20
+ FINISHED = "finished"
21
+ FAILED = "failed"
22
+ UNKNOWN = "unknown" # Custom state if API returns unexpected value
23
+
24
+
25
+ class DownloadStatus(BaseModel):
26
+ """Represents the status details of a download job returned by the API."""
27
+
28
+ @property
29
+ def file_name(self) -> Optional[str]:
30
+ return self.get_value("file_name")
31
+
32
+ @property
33
+ def message(self) -> Optional[str]:
34
+ """A human readable error message if the status is failed, otherwise null."""
35
+ return self.get_value("message")
36
+
37
+ @property
38
+ def seconds_elapsed(self) -> Optional[float]:
39
+ """Time taken to generate the file or time taken so far."""
40
+ return to_float(self.get_value("seconds_elapsed"))
41
+
42
+ @property
43
+ def api_status(self) -> DownloadState:
44
+ """Current state of the request from the API."""
45
+ status_str = self.get_value("status")
46
+ if status_str:
47
+ try:
48
+ return DownloadState(status_str)
49
+ except ValueError:
50
+ return DownloadState.UNKNOWN
51
+ return DownloadState.UNKNOWN
52
+
53
+ @property
54
+ def total_columns(self) -> Optional[int]:
55
+ return to_int(self.get_value("total_columns"))
56
+
57
+ @property
58
+ def total_rows(self) -> Optional[int]:
59
+ return to_int(self.get_value("total_rows"))
60
+
61
+ @property
62
+ def total_size_kb(self) -> Optional[float]:
63
+ """Estimated file size in kilobytes."""
64
+ return to_float(self.get_value("total_size"))
65
+
66
+ @property
67
+ def file_url(self) -> Optional[str]:
68
+ """The URL for the file (relative path)."""
69
+ return self.get_value("file_url")
70
+
71
+ def __repr__(self) -> str:
72
+ return (
73
+ f"<DownloadStatus status='{self.api_status.value}' file='{self.file_name}'>"
74
+ )