1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
|
"""The data the API returns, as objects.
**Field names are the API's own, in Portuguese** — `logradouro`, `bairro`,
`localidade`. Everything else in this library is English; the fields are the
one deliberate exception.
The reason is fidelity: what you read in the JSON at https://cep.is/01001000
is exactly what you access on the object. Translating to `street`/`city` would
mean maintaining a field-by-field mapping and forcing anyone reading the API
docs alongside this library to translate back in their head. The values are
Portuguese either way — they come from the Correios database.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class Geo:
"""The CEP's coordinate, when the database has one.
The point is the **median** of the coordinates of that CEP's addresses,
not the mean: one badly geocoded address 40 km away shifts a mean and does
not move a median.
Always check `precisao` before using `lat`/`lon` for distance:
- `"logradouro"`: the point is a stretch of street. `raio_m` lands around
100–150 m. Good for maps and for distance.
- `"localidade"`: the CEP *is* the whole municipality (a city-wide general
CEP), and the point is its centroid. `raio_m` runs from 7 km to 40 km.
Computing shipping from this is wrong by tens of kilometres.
`raio_m` is the radius containing 90% of that CEP's addresses. It exists
precisely so that error cannot pass unnoticed.
"""
lat: float
lon: float
raio_m: int
precisao: str
@property
def is_precise(self) -> bool:
"""`True` when the coordinate points at a street, not a municipality.
Shorthand for `precisao == "logradouro"`, which is the check to make
before using the point to measure distance.
"""
return self.precisao == "logradouro"
@dataclass(frozen=True)
class Address:
"""A CEP and everything the database knows about it.
Fields the database does not have for that CEP come back as empty strings,
never `None` — that is what the API returns, and changing it here would
create a difference between reading the JSON and reading this library.
`geo` is the exception: it is `None` when there is no coordinate. There the
difference is real, because the API **omits** the whole field in that case
(about 43% of CEPs) rather than sending null.
`raw` holds the response exactly as it arrived, uninterpreted, so a field
the API grows before this library is updated is not lost.
"""
cep: str
logradouro: str
complemento: str
unidade: str
bairro: str
localidade: str
uf: str
estado: str
regiao: str
ibge: str
gia: str
ddd: str
siafi: str
geo: Geo | None = None
raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
@property
def formatted_cep(self) -> str:
"""The CEP with its mask: `01001-000`.
The native contract always returns it unmasked; formatting is
presentation, and it lives here so it does not become a repeated
f-string in every project that uses this.
"""
return f"{self.cep[:5]}-{self.cep[5:]}"
@classmethod
def from_json(cls, data: dict[str, Any]) -> Address:
"""Build from the dict the API returned.
Unknown fields are ignored during construction but preserved in `raw`.
A new field in the API therefore cannot break this library — an
outdated client should fail as "I cannot read that yet", never
`TypeError`.
A malformed `geo` block yields `geo=None` rather than propagating the
error: it is not worth losing the whole address over, since the rest of
the response is still useful.
"""
geo = None
block = data.get("geo")
if isinstance(block, dict):
try:
geo = Geo(
lat=float(block["lat"]),
lon=float(block["lon"]),
raio_m=int(block["raio_m"]),
precisao=str(block["precisao"]),
)
except (KeyError, TypeError, ValueError):
geo = None
return cls(
cep=str(data.get("cep", "")),
logradouro=str(data.get("logradouro", "")),
complemento=str(data.get("complemento", "")),
unidade=str(data.get("unidade", "")),
bairro=str(data.get("bairro", "")),
localidade=str(data.get("localidade", "")),
uf=str(data.get("uf", "")),
estado=str(data.get("estado", "")),
regiao=str(data.get("regiao", "")),
ibge=str(data.get("ibge", "")),
gia=str(data.get("gia", "")),
ddd=str(data.get("ddd", "")),
siafi=str(data.get("siafi", "")),
geo=geo,
raw=dict(data),
)
@dataclass(frozen=True)
class Faixa:
"""The municipality a CEP *would* belong to, when it does not exist.
This is context for an error, **not an address**. It arrives on the 404
body of `GET /{cep}` and is exposed as :attr:`cepis.CEPNotFound.faixa`. The
CEP asked for still does not exist; nothing here says otherwise, and
treating a `Faixa` as if it were an :class:`Address` is the one mistake
this type makes possible.
It exists because a failing CEP is rarely random: it is usually someone who
knows the city and guessed the number — most often the `-000` that 957
municipalities never received. Knowing which municipality was meant is
enough to ask a much better question than "invalid CEP".
`cep_ini` and `cep_fim` are the **smallest and largest CEP that actually
exist** in that municipality — not the bounds the Correios publish. For
Areal/RJ the DNE's own start is `25845000`, which is precisely the CEP that
just failed; `cep_ini` is `25845001`.
The range is **sparse**. Areal's 118 CEPs are scattered across the ~4,900
numbers between `25845001` and `25849899`. `ceps` is how many there really
are, and it is there so the pair is never read as "everything in between is
valid": iterating the range would produce mostly CEPs that do not exist.
"""
localidade: str
uf: str
estado: str
regiao: str
ibge: str
cep_ini: str
cep_fim: str
ceps: int
@property
def label(self) -> str:
"""The municipality as `Areal/RJ`, for a message shown to a person."""
return f"{self.localidade}/{self.uf}"
@classmethod
def from_json(cls, block: Any) -> Faixa | None:
"""Build from the `faixa` block of a 404 body, or return `None`.
Takes whatever was under the `faixa` key — including nothing at all —
because the field is **omitted, never null**, when the CEP falls in a
band no municipality owns. Absent, empty, not a dict, or malformed all
give the same answer: `None`.
Nothing in here may raise. The caller is already on its way to raising
:class:`cepis.CEPNotFound`, and turning that clean "does not exist"
into a parse error over an optional courtesy field would be a strictly
worse outcome than simply not having the field.
"""
if not isinstance(block, dict):
return None
try:
localidade = str(block["localidade"])
if not localidade:
return None
return cls(
localidade=localidade,
uf=str(block.get("uf", "")),
estado=str(block.get("estado", "")),
regiao=str(block.get("regiao", "")),
ibge=str(block.get("ibge", "")),
cep_ini=str(block.get("cep_ini", "")),
cep_fim=str(block.get("cep_fim", "")),
ceps=int(block.get("ceps") or 0),
)
except (KeyError, TypeError, ValueError):
return None
@dataclass(frozen=True)
class Health:
"""The response of `GET /health`.
Good for two things: checking that the API answers, and knowing **which
version of the database** is live. The DNE database changes every two
weeks; `dne` identifies it.
"""
status: str
dne: str
ceps: int
geo: int
base_gerada_em: str
@property
def ok(self) -> bool:
"""`True` when the API reports itself healthy."""
return self.status == "ok"
@classmethod
def from_json(cls, data: dict[str, Any]) -> Health:
"""Build from the dict the API returned."""
return cls(
status=str(data.get("status", "")),
dne=str(data.get("dne", "")),
ceps=int(data.get("ceps") or 0),
geo=int(data.get("geo") or 0),
base_gerada_em=str(data.get("base_gerada_em", "")),
)
|