Coverage for polar/kit/address.py: 62%
160 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 16:17 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 16:17 +0000
1from enum import StrEnum 1ab
2from typing import TYPE_CHECKING, Annotated, Any, NotRequired, Self, TypedDict, cast 1ab
4import pycountry 1ab
5from pydantic import BaseModel, BeforeValidator, Field, model_validator 1ab
6from pydantic.json_schema import WithJsonSchema 1ab
7from sqlalchemy.dialects.postgresql import JSONB 1ab
8from sqlalchemy.engine.interfaces import Dialect 1ab
9from sqlalchemy.types import TypeDecorator 1ab
11from polar.kit.schemas import EmptyStrToNone 1ab
14class CountryData: 1ab
15 alpha_2: str
18_ALL_COUNTRIES: set[str] = { 1ab
19 cast(CountryData, country).alpha_2 for country in pycountry.countries
20}
21_SUPPORTED_COUNTRIES: set[str] = _ALL_COUNTRIES - { 1ab
22 # US Trade Embargos
23 "CU",
24 "IR",
25 "KP",
26 "SY",
27 "RU",
28}
29ALL_COUNTRIES = sorted(_ALL_COUNTRIES) 1ab
30SUPPORTED_COUNTRIES = sorted(_SUPPORTED_COUNTRIES) 1ab
32if TYPE_CHECKING: 32 ↛ 34line 32 didn't jump to line 34 because the condition on line 32 was never true1ab
34 class CountryAlpha2(StrEnum):
35 pass
37 class CountryAlpha2Input(StrEnum):
38 pass
39else:
40 CountryAlpha2 = Annotated[ 1ab
41 StrEnum("CountryAlpha2", [(country, country) for country in ALL_COUNTRIES]),
42 WithJsonSchema(
43 {
44 "type": "string",
45 "title": "CountryAlpha2",
46 "enum": ALL_COUNTRIES,
47 "x-speakeasy-enums": ALL_COUNTRIES,
48 }
49 ),
50 ]
51 CountryAlpha2Input = Annotated[ 1ab
52 StrEnum(
53 "CountryAlpha2Input",
54 [(country, country) for country in SUPPORTED_COUNTRIES],
55 ),
56 WithJsonSchema(
57 {
58 "type": "string",
59 "title": "CountryAlpha2Input",
60 "enum": SUPPORTED_COUNTRIES,
61 "x-speakeasy-enums": SUPPORTED_COUNTRIES,
62 }
63 ),
64 ]
67class USState(StrEnum): 1ab
68 US_AL = "US-AL" 1ab
69 US_AK = "US-AK" 1ab
70 US_AZ = "US-AZ" 1ab
71 US_AR = "US-AR" 1ab
72 US_CA = "US-CA" 1ab
73 US_CO = "US-CO" 1ab
74 US_CT = "US-CT" 1ab
75 US_DE = "US-DE" 1ab
76 US_FL = "US-FL" 1ab
77 US_GA = "US-GA" 1ab
78 US_HI = "US-HI" 1ab
79 US_ID = "US-ID" 1ab
80 US_IL = "US-IL" 1ab
81 US_IN = "US-IN" 1ab
82 US_IA = "US-IA" 1ab
83 US_KS = "US-KS" 1ab
84 US_KY = "US-KY" 1ab
85 US_LA = "US-LA" 1ab
86 US_ME = "US-ME" 1ab
87 US_MD = "US-MD" 1ab
88 US_MA = "US-MA" 1ab
89 US_MI = "US-MI" 1ab
90 US_MN = "US-MN" 1ab
91 US_MS = "US-MS" 1ab
92 US_MO = "US-MO" 1ab
93 US_MT = "US-MT" 1ab
94 US_NE = "US-NE" 1ab
95 US_NV = "US-NV" 1ab
96 US_NH = "US-NH" 1ab
97 US_NJ = "US-NJ" 1ab
98 US_NM = "US-NM" 1ab
99 US_NY = "US-NY" 1ab
100 US_NC = "US-NC" 1ab
101 US_ND = "US-ND" 1ab
102 US_OH = "US-OH" 1ab
103 US_OK = "US-OK" 1ab
104 US_OR = "US-OR" 1ab
105 US_PA = "US-PA" 1ab
106 US_RI = "US-RI" 1ab
107 US_SC = "US-SC" 1ab
108 US_SD = "US-SD" 1ab
109 US_TN = "US-TN" 1ab
110 US_TX = "US-TX" 1ab
111 US_UT = "US-UT" 1ab
112 US_VT = "US-VT" 1ab
113 US_VA = "US-VA" 1ab
114 US_WA = "US-WA" 1ab
115 US_WV = "US-WV" 1ab
116 US_WI = "US-WI" 1ab
117 US_WY = "US-WY" 1ab
118 US_DC = "US-DC" 1ab
121class CAProvince(StrEnum): 1ab
122 CA_AB = "CA-AB" 1ab
123 CA_BC = "CA-BC" 1ab
124 CA_MB = "CA-MB" 1ab
125 CA_NB = "CA-NB" 1ab
126 CA_NL = "CA-NL" 1ab
127 CA_NS = "CA-NS" 1ab
128 CA_ON = "CA-ON" 1ab
129 CA_PE = "CA-PE" 1ab
130 CA_QC = "CA-QC" 1ab
131 CA_SK = "CA-SK" 1ab
134class AddressDict(TypedDict): 1ab
135 line1: NotRequired[str] 1ab
136 line2: NotRequired[str] 1ab
137 postal_code: NotRequired[str] 1ab
138 city: NotRequired[str] 1ab
139 state: NotRequired[str] 1ab
140 country: str 1ab
143class Address(BaseModel): 1ab
144 line1: EmptyStrToNone | None = None 1ab
145 line2: EmptyStrToNone | None = None 1ab
146 postal_code: EmptyStrToNone | None = None 1ab
147 city: EmptyStrToNone | None = None 1ab
148 state: EmptyStrToNone | None = None 1ab
149 country: CountryAlpha2 = Field(examples=["US", "SE", "FR"]) 1ab
151 @model_validator(mode="after") 1ab
152 def validate_state(self) -> Self: 1ab
153 if self.state is None: 1abc
154 return self 1c
156 # Normalize US and CA state with a prefix
157 if self.country in {"US", "CA"}: 157 ↛ 162line 157 didn't jump to line 162 because the condition on line 157 was always true1ab
158 if not self.state.startswith(f"{self.country}-"): 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true1ab
159 self.state = f"{self.country}-{self.state}"
161 # Validate US and CA state
162 if self.country == "US" and self.state not in USState: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true1ab
163 raise ValueError("Invalid US state")
164 if self.country == "CA" and self.state not in CAProvince: 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true1ab
165 raise ValueError("Invalid CA province")
167 return self 1ab
169 def to_dict(self) -> AddressDict: 1ab
170 return cast(AddressDict, self.model_dump(exclude_none=True))
172 def get_unprefixed_state(self) -> str | None: 1ab
173 if self.state is None:
174 return None
175 if self.country in {"US", "CA"}:
176 return self.state.split("-")[1]
177 return self.state
179 def has_state(self) -> bool: 1ab
180 return self.state is not None
182 def has_address(self) -> bool: 1ab
183 return (
184 self.line1 is not None
185 or self.line2 is not None
186 or self.city is not None
187 or self.postal_code is not None
188 )
190 def to_text(self) -> str: 1ab
191 lines = []
192 if self.line1:
193 lines.append(self.line1)
194 if self.line2:
195 lines.append(self.line2)
197 city_line = ""
198 if self.city:
199 city_line += self.city
200 if self.state:
201 state = pycountry.subdivisions.get(code=self.state)
202 if state is not None:
203 city_line += f", {cast(Any, state.name)}"
204 else:
205 city_line += f", {self.get_unprefixed_state()}"
206 if self.postal_code:
207 city_line += f" {self.postal_code}"
208 if city_line:
209 lines.append(city_line)
211 if self.country:
212 country = pycountry.countries.get(alpha_2=self.country)
213 if country is not None:
214 lines.append(country.name)
215 else:
216 lines.append(self.country)
218 return "\n".join(lines)
221class AddressInput(Address): 1ab
222 country: Annotated[CountryAlpha2Input, BeforeValidator(str.upper)] = Field( # type: ignore 1ab
223 examples=["US", "SE", "FR"]
224 )
227class AddressType(TypeDecorator[Any]): 1ab
228 impl = JSONB 1ab
229 cache_ok = True 1ab
231 def process_bind_param(self, value: Any, dialect: Dialect) -> Any: 1ab
232 if isinstance(value, Address):
233 return value.model_dump(exclude_none=True)
234 return value
236 def process_result_value(self, value: str | None, dialect: Dialect) -> Any: 1ab
237 if value is not None:
238 return Address.model_validate(value)
239 return value