From b199ae044f8f9cd4b745214cd5e5495f86b5eb54 Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Mon, 27 Jul 2026 20:32:01 -0300 Subject: Carry the municipality of a CEP that does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cepis/models.py | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) (limited to 'src/cepis/models.py') 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 @@ -139,6 +139,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`. -- cgit v1.3