diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:32:01 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:32:01 -0300 |
| commit | b199ae044f8f9cd4b745214cd5e5495f86b5eb54 (patch) | |
| tree | a8e7a134e77381b11091126a8946208e2e6afbcb | |
| parent | a82459630e957a8d94c375394dae66734f3c90c1 (diff) | |
| download | python-main.tar.gz | |
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.
| -rw-r--r-- | README.md | 40 | ||||
| -rw-r--r-- | src/cepis/__init__.py | 13 | ||||
| -rw-r--r-- | src/cepis/client.py | 38 | ||||
| -rw-r--r-- | src/cepis/errors.py | 13 | ||||
| -rw-r--r-- | src/cepis/models.py | 74 | ||||
| -rw-r--r-- | tests/test_cepis.py | 109 |
6 files changed, 275 insertions, 12 deletions
@@ -184,6 +184,46 @@ except CepisError: so code already handling those types during input validation keeps working without knowing this library exists. +### Which municipality the CEP would have been in + +A CEP that fails is rarely random: it is usually someone who knows the city and +guessed the number — and the classic guess is the `-000` that 957 +municipalities never received. So `CEPNotFound` carries `faixa`, the +municipality that CEP would have belonged to, when the API can work it out: + +```python +try: + address = cepis.lookup("25845-000") +except CEPNotFound as exc: + if exc.faixa: + f = exc.faixa + f.localidade # 'Areal' + f.uf # 'RJ' + f.estado # 'Rio de Janeiro' + f.regiao # 'Sudeste' + f.ibge # '3300225' + f.label # 'Areal/RJ' + f.cep_ini # '25845001' + f.cep_fim # '25849899' + f.ceps # 118 + print(f"That CEP does not exist. Did you mean somewhere in {f.label}?") +``` + +Three things about it: + +- **It is still a 404.** `faixa` is context for the error, never an address. + `lookup` raises exactly as before; nothing here says the CEP exists. +- **`cep_ini` and `cep_fim` are the CEPs that really exist**, not the bounds + the Correios publish. The DNE's own start for Areal is `25845000` — precisely + the CEP that just failed, which is why the range starts one above it. +- **The range is sparse.** Areal's 118 CEPs are scattered across the ~4,900 + numbers between `25845001` and `25849899`. That is what `ceps` is for: never + read the pair as a list of valid CEPs, and do not iterate it. + +`faixa` is `None` when the CEP falls in a band no municipality owns — the API +**omits** the field there rather than sending null — and also when talking to +an API older than the field. Always test it before use. + `InvalidResponse` almost always means something between you and the API is answering instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error page. Useful as a diagnosis: the problem is not in your code. diff --git a/src/cepis/__init__.py b/src/cepis/__init__.py index bc98d29..f32754a 100644 --- a/src/cepis/__init__.py +++ b/src/cepis/__init__.py @@ -24,8 +24,12 @@ Errors are explicit and split by decision — whether retrying helps or not:: try: address = cepis.lookup(cep_from_form) - except CEPNotFound: - ... # the CEP does not exist: ask the user for another + except CEPNotFound as exc: + # The CEP does not exist. The API often knows which municipality it + # would have belonged to — enough to ask a better question than + # "invalid CEP". Still an error: `exc.faixa` is not an address. + if exc.faixa: + ... # f"no such CEP in {exc.faixa.label}" except CepisError: ... # network, server, rate limit: retry later @@ -41,7 +45,7 @@ this module. Anyone needing different settings should build their own. from __future__ import annotations -__version__ = "0.1.0" +__version__ = "0.2.0" from .client import DEFAULT_URL, MAX_BATCH, Client, normalize_cep from .errors import ( @@ -53,7 +57,7 @@ from .errors import ( RateLimited, ServerError, ) -from .models import Address, Geo, Health +from .models import Address, Faixa, Geo, Health __all__ = [ "DEFAULT_URL", @@ -62,6 +66,7 @@ __all__ = [ "CEPNotFound", "CepisError", "Client", + "Faixa", "Geo", "Health", "InvalidCEP", diff --git a/src/cepis/client.py b/src/cepis/client.py index bebc6e6..ffd1332 100644 --- a/src/cepis/client.py +++ b/src/cepis/client.py @@ -35,7 +35,7 @@ from .errors import ( RateLimited, ServerError, ) -from .models import Address, Health +from .models import Address, Faixa, Health DEFAULT_URL = "https://cep.is" @@ -160,6 +160,16 @@ class Client: API's own message: the format already passed local validation, so the server knows something this client does not. + A 404 may carry the municipality the CEP would have belonged to, in + `CEPNotFound.faixa` (see :class:`cepis.Faixa`) — still a 404, still an + error, but usually enough to ask a better question than "invalid CEP":: + + try: + address = client.lookup(cep) + except CEPNotFound as exc: + if exc.faixa: + print(f"no such CEP in {exc.faixa.label}") + :raises InvalidCEP: the CEP is not 8 digits (no request is made). :raises CEPNotFound: well-formed, but not in the database. :raises RateLimited: 429 after every attempt. @@ -170,7 +180,11 @@ class Client: status, body, _ = self._request(f"/{number}") if status == 404: - raise CEPNotFound(f"CEP {number} does not exist in the DNE database") + faixa = Faixa.from_json(self._error_json(body).get("faixa")) + message = f"CEP {number} does not exist in the DNE database" + if faixa is not None: + message = f"{message} (it would be in {faixa.label})" + raise CEPNotFound(message, faixa=faixa) if status == 400: raise InvalidCEP(self._error_message(body, f"CEP {number} rejected")) @@ -258,15 +272,23 @@ class Client: return data @staticmethod - def _error_message(body: bytes, fallback: str) -> str: - """Pull `mensagem` out of an error body, falling back to a default.""" + def _error_json(body: bytes) -> dict[str, Any]: + """Decode an error body, or return `{}`. + + Unlike :meth:`_json` this never raises. An error response is already + the bad news; whether its body could be parsed only decides how much + detail the exception carries, never whether one is raised. + """ try: data = json.loads(body.decode("utf-8")) - if isinstance(data, dict) and data.get("mensagem"): - return str(data["mensagem"]) except (ValueError, UnicodeDecodeError): - pass - return fallback + return {} + return data if isinstance(data, dict) else {} + + def _error_message(self, body: bytes, fallback: str) -> str: + """Pull `mensagem` out of an error body, falling back to a default.""" + message = self._error_json(body).get("mensagem") + return str(message) if message else fallback def _backoff(self, attempt: int, retry_after: float | None) -> float: """How long to wait before the next attempt. diff --git a/src/cepis/errors.py b/src/cepis/errors.py index 1698972..12862df 100644 --- a/src/cepis/errors.py +++ b/src/cepis/errors.py @@ -19,6 +19,8 @@ before it ever leaves the machine. from __future__ import annotations +from .models import Faixa + class CepisError(Exception): """Base of every error. Catch this to handle any failure.""" @@ -45,8 +47,19 @@ class CEPNotFound(CepisError, KeyError): `KeyError.__str__` wraps the message in quotes (inherited from dict behaviour), which is why `__str__` is overridden here — otherwise every message would come out with spurious quotes around it. + + `faixa` is the municipality the CEP *would* have belonged to, when the API + could work it out: see :class:`cepis.Faixa`. It is `None` whenever the API + did not say — either because the CEP falls in a band no municipality owns, + or because the server is older than this field. Either way the exception is + the same exception: `faixa` is context for the error, never a demotion of + it into a partial success. """ + def __init__(self, message: str, faixa: Faixa | None = None) -> None: + super().__init__(message) + self.faixa = faixa + def __str__(self) -> str: return self.args[0] if self.args else "" 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`. diff --git a/tests/test_cepis.py b/tests/test_cepis.py index dc27179..31df5e9 100644 --- a/tests/test_cepis.py +++ b/tests/test_cepis.py @@ -46,6 +46,20 @@ SE_ADDRESS = { "siafi": "7107", } +# The `faixa` block of a 404: 25845000 is the CEP the Correios publish as the +# start of Areal/RJ and the one the DNE does not actually have, which is why +# cep_ini is 25845001. +AREAL_FAIXA = { + "localidade": "Areal", + "uf": "RJ", + "estado": "Rio de Janeiro", + "regiao": "Sudeste", + "ibge": "3300225", + "cep_ini": "25845001", + "cep_fim": "25849899", + "ceps": 118, +} + CSV_HEADER = ( "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi" ) @@ -252,6 +266,101 @@ class TestLookup(BaseTest): self.client.lookup("01001000") +class TestFaixa(BaseTest): + """The municipality carried by a 404 — context for the error, not a hit.""" + + def _not_found(self, path: str, **extra: object) -> None: + body: dict[str, object] = { + "erro": "cep_nao_encontrado", + "mensagem": "CEP não existe na base do DNE", + } + body.update(extra) + self.routes.respond(path, (404, json.dumps(body), {})) + + def test_reads_the_faixa_when_present(self) -> None: + self._not_found("/25845000", faixa=AREAL_FAIXA) + with self.assertRaises(CEPNotFound) as ctx: + self.client.lookup("25845000") + + faixa = ctx.exception.faixa + assert faixa is not None + self.assertEqual(faixa.localidade, "Areal") + self.assertEqual(faixa.uf, "RJ") + self.assertEqual(faixa.ibge, "3300225") + self.assertEqual(faixa.label, "Areal/RJ") + + def test_bounds_are_the_ceps_that_exist_and_the_range_is_sparse(self) -> None: + """118 CEPs across ~4,900 numbers: the pair is not a list of valid CEPs.""" + self._not_found("/25845000", faixa=AREAL_FAIXA) + with self.assertRaises(CEPNotFound) as ctx: + self.client.lookup("25845000") + + faixa = ctx.exception.faixa + assert faixa is not None + self.assertEqual(faixa.cep_ini, "25845001") + self.assertEqual(faixa.cep_fim, "25849899") + self.assertEqual(faixa.ceps, 118) + span = int(faixa.cep_fim) - int(faixa.cep_ini) + 1 + self.assertLess(faixa.ceps, span) + # The CEP that failed is the DNE's own published start of the range, + # and it is below cep_ini precisely because it does not exist. + self.assertLess(int("25845000"), int(faixa.cep_ini)) + + def test_message_names_the_municipality(self) -> None: + self._not_found("/25845000", faixa=AREAL_FAIXA) + with self.assertRaises(CEPNotFound) as ctx: + self.client.lookup("25845000") + self.assertIn("Areal/RJ", str(ctx.exception)) + self.assertNotIn('"', str(ctx.exception)) + + def test_absent_faixa_is_none(self) -> None: + """Omitted, never null: a band no municipality owns just has no faixa.""" + self._not_found("/99999999") + with self.assertRaises(CEPNotFound) as ctx: + self.client.lookup("99999999") + self.assertIsNone(ctx.exception.faixa) + self.assertIn("99999999", str(ctx.exception)) + + def test_still_a_not_found_with_a_faixa(self) -> None: + """A faixa must never promote a 404 into an address.""" + self._not_found("/25845000", faixa=AREAL_FAIXA) + with self.assertRaises(KeyError): + self.client.lookup("25845000") + + def test_faixa_is_not_retried(self) -> None: + self._not_found("/25845000", faixa=AREAL_FAIXA) + with self.assertRaises(CEPNotFound): + self.client.lookup("25845000") + self.assertEqual(self.routes.count("/25845000"), 1) + + def test_malformed_faixa_still_raises_a_clean_not_found(self) -> None: + """Whatever arrives under `faixa`, the exception is the same exception.""" + for junk in ({}, [], "Areal", 42, {"uf": "RJ"}, {"localidade": ""}): + with self.subTest(faixa=junk): + self.routes.respond( + "/25845000", + (404, json.dumps({"erro": "cep_nao_encontrado", "faixa": junk}), {}), + ) + with self.assertRaises(CEPNotFound) as ctx: + self.client.lookup("25845000") + self.assertIsNone(ctx.exception.faixa) + + def test_bad_ceps_count_yields_none(self) -> None: + """A field that cannot be read makes the whole block untrustworthy.""" + faixa = dict(AREAL_FAIXA, ceps="não é número") + self._not_found("/25845000", faixa=faixa) + with self.assertRaises(CEPNotFound) as ctx: + self.client.lookup("25845000") + self.assertIsNone(ctx.exception.faixa) + + def test_unparseable_404_body_does_not_become_a_parse_error(self) -> None: + """An HTML 404 from a proxy is still a CEPNotFound, not InvalidResponse.""" + self.routes.respond("/99999999", (404, "<html>not found</html>", {})) + with self.assertRaises(CEPNotFound) as ctx: + self.client.lookup("99999999") + self.assertIsNone(ctx.exception.faixa) + + class TestRetry(BaseTest): def test_retries_on_500_then_succeeds(self) -> None: self.routes.respond( |