diff options
Diffstat (limited to 'src/cepis/models.py')
| -rw-r--r-- | src/cepis/models.py | 171 |
1 files changed, 171 insertions, 0 deletions
diff --git a/src/cepis/models.py b/src/cepis/models.py new file mode 100644 index 0000000..b6f29fd --- /dev/null +++ b/src/cepis/models.py @@ -0,0 +1,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", "")), + ) |