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,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})>"