summaryrefslogtreecommitdiffstats
path: root/src/cepis/models.py
blob: 5750012147a00fb9b89600e76a9377907fdcc718 (plain)
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", "")),
        )