summaryrefslogtreecommitdiffstats
path: root/src/cepis/models.py
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:32:01 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:32:01 -0300
commitb199ae044f8f9cd4b745214cd5e5495f86b5eb54 (patch)
treea8e7a134e77381b11091126a8946208e2e6afbcb /src/cepis/models.py
parenta82459630e957a8d94c375394dae66734f3c90c1 (diff)
downloadpython-b199ae044f8f9cd4b745214cd5e5495f86b5eb54.tar.gz
Carry the municipality of a CEP that does not existHEADv0.2.0main
A failing CEP is rarely random: it is someone who knows the city and guessed the number, and the classic guess is the -000 that 957 municipalities never received. The API now answers those 404s with a faixa block naming the municipality the CEP would have belonged to, so a caller can ask "did you mean somewhere in Areal/RJ?" instead of "invalid CEP". CEPNotFound gains faixa, defaulting to None. It stays a 404 and stays an exception — the field is context for the error, never a demotion of it into a partial success. Parsing is deliberately total: anything missing, empty or malformed under faixa yields None, because turning a clean not-found into a parse failure over an optional courtesy field would be strictly worse than not having the field at all. Faixa's cep_ini/cep_fim are the smallest and largest CEP that actually exist there, not the bounds the Correios publish, and the range in between is sparse — hence ceps, so the pair is never read as a list of valid numbers.
Diffstat (limited to 'src/cepis/models.py')
-rw-r--r--src/cepis/models.py74
1 files changed, 74 insertions, 0 deletions
diff --git a/src/cepis/models.py b/src/cepis/models.py
index b6f29fd..5750012 100644
--- a/src/cepis/models.py
+++ b/src/cepis/models.py
@@ -140,6 +140,80 @@ class Address:
@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`.