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