summaryrefslogtreecommitdiffstats
path: root/src/cepis/models.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/cepis/models.py')
-rw-r--r--src/cepis/models.py171
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", "")),
+ )