summaryrefslogtreecommitdiffstats
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
parenta82459630e957a8d94c375394dae66734f3c90c1 (diff)
downloadpython-main.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.
-rw-r--r--README.md40
-rw-r--r--src/cepis/__init__.py13
-rw-r--r--src/cepis/client.py38
-rw-r--r--src/cepis/errors.py13
-rw-r--r--src/cepis/models.py74
-rw-r--r--tests/test_cepis.py109
6 files changed, 275 insertions, 12 deletions
diff --git a/README.md b/README.md
index dba6aa2..20f2cfa 100644
--- a/README.md
+++ b/README.md
@@ -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(