summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 11:44:58 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 11:44:58 -0300
commita82459630e957a8d94c375394dae66734f3c90c1 (patch)
treed9d1b9e1249ccd8cd1d989f532c6368a70dc9cc2
downloadpython-0.1.0.tar.gz
Initial importv0.1.0
-rw-r--r--.gitignore11
-rw-r--r--LICENSE15
-rw-r--r--Makefile69
-rw-r--r--README.md206
-rw-r--r--pyproject.toml53
-rw-r--r--src/cepis/__init__.py104
-rw-r--r--src/cepis/__main__.py61
-rw-r--r--src/cepis/client.py365
-rw-r--r--src/cepis/errors.py91
-rw-r--r--src/cepis/models.py171
-rw-r--r--tests/test_cepis.py434
11 files changed, 1580 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0bdbfa8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+__pycache__/
+*.py[cod]
+.venv/
+dist/
+build/
+*.egg-info/
+.ruff_cache/
+# Written by uv when running or building. Not committed: this is a library, so
+# it must resolve against whatever its dependents already have, and a lock file
+# here would pin only one development machine.
+uv.lock
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..6d804a1
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,69 @@
+# Development tasks. Everything runs through `uv`, so there is nothing to
+# install first and no virtualenv to activate by hand.
+#
+# make list the targets
+# make test run the suite on PYTHON (default: the minimum supported)
+# make lint ruff check + format check, no files touched
+# make build wheel + sdist into dist/
+#
+# PYTHON pins the interpreter uv resolves. It defaults to 3.9 — the minimum
+# this package claims to support — because that is the version most likely to
+# catch a syntax or stdlib assumption that a newer one silently accepts.
+PYTHON ?= 3.9
+
+# Every 3.x this package claims to support, for `make test-all`. Keep in sync
+# with requires-python in pyproject.toml.
+PYTHON_VERSIONS := 3.9 3.10 3.11 3.12 3.13 3.14
+
+UV ?= uv
+RUN := $(UV) run --python $(PYTHON) --with-editable .
+
+.DEFAULT_GOAL := help
+.PHONY: help test test-all lint format build clean check
+
+help:
+ @echo 'Targets:'
+ @echo ' test run the test suite (PYTHON=$(PYTHON))'
+ @echo ' test-all run it on every supported Python: $(PYTHON_VERSIONS)'
+ @echo ' lint ruff check + ruff format --check (read-only)'
+ @echo ' format apply ruff format and ruff check --fix (writes)'
+ @echo ' build build the wheel and sdist into dist/'
+ @echo ' check lint + test + build, in that order'
+ @echo ' clean remove build artefacts and caches'
+ @echo
+ @echo 'Override the interpreter with e.g. make test PYTHON=3.13'
+
+test:
+ $(RUN) python -m unittest discover -s tests -v
+
+# Sequential, not parallel: uv may need to download an interpreter, and
+# interleaved unittest output from six runs is unreadable when one fails.
+# `set -e` inside the loop is what makes a failure on 3.10 stop the run instead
+# of being masked by a later success.
+test-all:
+ @set -e; for v in $(PYTHON_VERSIONS); do \
+ echo "==> Python $$v"; \
+ $(UV) run --python $$v --with-editable . python -m unittest discover -s tests; \
+ done
+
+# Read-only by design, so it is safe in CI and in a pre-commit hook: it reports
+# what `make format` would change without changing anything.
+lint:
+ $(UV)x ruff check .
+ $(UV)x ruff format --check .
+
+format:
+ $(UV)x ruff format .
+ $(UV)x ruff check --fix .
+
+build:
+ $(UV) build
+
+# The order matters: lint is the fastest to fail, build the slowest, so a
+# broken run reports the cheapest problem first.
+check: lint test build
+
+clean:
+ rm -rf dist build .ruff_cache .venv
+ find . -name '__pycache__' -type d -prune -exec rm -rf {} +
+ find . -name '*.egg-info' -type d -prune -exec rm -rf {} +
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..dba6aa2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,206 @@
+# cepis
+
+Python client for the [public CEP API at cep.is](https://cep.is). Official
+Correios data (DNE), refreshed every two weeks.
+
+```python
+import cepis
+
+address = cepis.lookup("01001-000")
+print(address.logradouro, address.localidade, address.uf)
+# Praça da Sé São Paulo SP
+```
+
+## Installation
+
+```sh
+pip install git+https://git.cep.is/python.git
+```
+
+Requires Python 3.9 or newer.
+
+## Usage
+
+### A single lookup
+
+`lookup` accepts the CEP masked or unmasked and returns an `Address`:
+
+```python
+import cepis
+
+a = cepis.lookup("01310-100")
+
+a.logradouro # 'Avenida Paulista'
+a.localidade # 'São Paulo'
+a.uf # 'SP'
+a.ibge # '3550308'
+a.cep # '01310100' (always unmasked)
+a.formatted_cep # '01310-100'
+```
+
+Fields the database does not have for that CEP come back as empty strings,
+never `None`.
+
+### Coordinates
+
+About 57% of CEPs have a coordinate. When they do not, `geo` is `None`:
+
+```python
+a = cepis.lookup("01310100")
+
+if a.geo:
+ print(a.geo.lat, a.geo.lon) # -23.567202 -46.649659
+ print(a.geo.raio_m) # 1084
+ print(a.geo.precisao) # 'logradouro'
+```
+
+**Check `precisao` before computing distance.** There are two kinds of point,
+and the difference between them is tens of kilometres:
+
+| `precisao` | typical radius | what the point is |
+| --- | --- | --- |
+| `logradouro` | 110–140 m | the stretch of street — good for maps and distance |
+| `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city |
+
+`a.geo.is_precise` is the shorthand for `precisao == "logradouro"`:
+
+```python
+if a.geo and a.geo.is_precise:
+ cost = shipping_from(a.geo.lat, a.geo.lon)
+else:
+ cost = shipping_from_municipality(a.ibge)
+```
+
+### Many CEPs at once
+
+Do not loop over `lookup`. Use `lookup_many`, which resolves up to 500 CEPs
+per request:
+
+```python
+ceps = ["01001000", "99999999", "20040020"]
+result = cepis.lookup_many(ceps)
+
+for requested, address in zip(ceps, result):
+ if address is None:
+ print(f"{requested}: not found")
+ else:
+ print(f"{requested}: {address.localidade}")
+```
+
+The returned list has **the same length and the same order** as the input. A
+CEP that does not exist, or is malformed, becomes `None` at its position — it
+does not vanish, and it does not raise.
+
+Lists longer than 500 are chunked automatically.
+
+> **Batches carry no coordinate.** The endpoint behind them is CSV, and the
+> CSV has no `geo` column. Every `Address` from `lookup_many` has `geo=None`,
+> including for CEPs that do have one. Need `geo`? Use `lookup`, one at a time.
+
+### API health
+
+Useful for two things: checking the API answers, and knowing **which version
+of the database** is live:
+
+```python
+h = cepis.health()
+h.ok # True
+h.dne # '26071' identifier of the fortnightly database
+h.ceps # 1596168
+h.base_gerada_em # '2026-07-24T03:48:28Z'
+```
+
+An API reporting itself degraded does not raise — that arrives in `h.status`.
+
+## Configuration
+
+For anything beyond the defaults, build a `Client` and reuse it. It keeps no
+state between calls and is safe to share across threads:
+
+```python
+from cepis import Client
+
+client = Client(
+ base_url="https://cep.is", # point at another instance
+ timeout=10.0, # seconds per socket operation
+ max_attempts=3, # counting the first; 1 disables retrying
+ backoff_base=0.5, # base of the exponential wait
+ backoff_max=8.0, # cap on each wait
+ total_timeout=None, # cap on the whole call, attempts included
+)
+
+address = client.lookup("01001000")
+```
+
+Retries cover 429, 5xx and network failures, with exponential backoff and
+jitter; 400 and 404 are never retried. See `help(cepis.Client)` for the
+details of each parameter.
+
+The module-level functions (`cepis.lookup`, etc.) use a shared default client.
+Do not mutate it: it applies process-wide, including to third-party libraries
+that also use this module. Need a different configuration? Build your own.
+
+### Pointing at a local instance
+
+`base_url` is the only parameter that usually changes between environments.
+The API repo brings a local instance up with `just up`:
+
+```python
+client = Client(base_url="http://localhost:8080")
+```
+
+From the command line, to confirm quickly that it answers:
+
+```sh
+python -m cepis --base-url http://localhost:8080 01001000
+```
+
+## Errors
+
+Everything derives from `CepisError`. Below that, the split is by **the
+decision you have to make**, not by the HTTP status that caused it:
+
+| error | when | does retrying help? |
+| --- | --- | --- |
+| `InvalidCEP` | not 8 digits | no |
+| `CEPNotFound` | well-formed, not in the database | no |
+| `RateLimited` | 429 | yes, later |
+| `ServerError` | 5xx | yes, later |
+| `NetworkError` | DNS, connection, timeout, TLS | yes, later |
+| `InvalidResponse` | unintelligible response | probably not |
+
+```python
+from cepis import CEPNotFound, CepisError
+
+try:
+ address = cepis.lookup(cep_from_form)
+except CEPNotFound:
+ ... # ask the user for another CEP
+except CepisError:
+ ... # transient failure: reschedule
+```
+
+`InvalidCEP` also derives from `ValueError` and `CEPNotFound` from `KeyError`,
+so code already handling those types during input validation keeps working
+without knowing this library exists.
+
+`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.
+
+## Development
+
+Everything runs through `make`, which shells out to `uv` — there is nothing to
+install first and no virtualenv to activate:
+
+```sh
+make # list the targets
+make test # run the suite
+make lint # ruff check + format check, touches nothing
+make build # wheel + sdist into dist/
+make check # lint, then test, then build
+```
+
+## Licence
+
+ISC. See [LICENSE](LICENSE).
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..9c03929
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,53 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "cepis"
+dynamic = ["version"]
+description = "Python client for the public CEP API at cep.is"
+readme = "README.md"
+license = "ISC"
+license-files = ["LICENSE"]
+requires-python = ">=3.9"
+authors = [{ name = "Murilo Ijanc", email = "[email protected]" }]
+keywords = ["cep", "correios", "dne", "endereco", "brasil", "viacep"]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "Natural Language :: Portuguese (Brazilian)",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3 :: Only",
+ "Topic :: Software Development :: Libraries",
+ "Typing :: Typed",
+]
+dependencies = []
+
+[project.urls]
+Homepage = "https://cep.is"
+Source = "https://git.cep.is/python.git"
+
+[tool.hatch.version]
+path = "src/cepis/__init__.py"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/cepis"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py39"
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
+# N818 exige que exceções terminem em "Error" — convenção do inglês. A API
+# pública desta biblioteca é em português de propósito (veja o README), então
+# a regra brigaria com `CEPNaoEncontrado` sem apontar defeito nenhum.
+ignore = ["N818"]
+# O travessão é pontuação legítima em português e aparece na prosa das
+# docstrings. Sem isto, RUF002 pede para trocá-lo por hífen e estraga o texto.
+allowed-confusables = ["–", "—"]
+
+[tool.ruff.lint.per-file-ignores]
+# Os testes exercitam propositalmente caminhos de erro e casos-limite.
+"tests/*" = ["B017"]
diff --git a/src/cepis/__init__.py b/src/cepis/__init__.py
new file mode 100644
index 0000000..bc98d29
--- /dev/null
+++ b/src/cepis/__init__.py
@@ -0,0 +1,104 @@
+"""Python client for the public CEP API at `cep.is <https://cep.is>`_.
+
+Quick use — for a script, the module-level functions are enough::
+
+ import cepis
+
+ address = cepis.lookup("01001-000")
+ print(address.localidade, address.uf) # São Paulo SP
+
+In an application — build a :class:`Client` once and reuse it::
+
+ from cepis import Client
+
+ client = Client(timeout=5.0, total_timeout=15.0)
+ address = client.lookup("01001000")
+
+Pointing at a local instance (the API repo brings one up with ``just up``)::
+
+ client = Client(base_url="http://localhost:8080")
+
+Errors are explicit and split by decision — whether retrying helps or not::
+
+ from cepis import CEPNotFound, CepisError
+
+ try:
+ address = cepis.lookup(cep_from_form)
+ except CEPNotFound:
+ ... # the CEP does not exist: ask the user for another
+ except CepisError:
+ ... # network, server, rate limit: retry later
+
+Note on field names: the API surface is English, but the **data fields keep
+the API's own Portuguese names** (``logradouro``, ``bairro``, ``localidade``).
+What you read in the JSON is what you access on the object.
+
+The module-level functions share one default client. It is built once because
+:class:`Client` keeps no state between calls and is thread-safe. Do not mutate
+it: it applies process-wide, including to third-party libraries that also use
+this module. Anyone needing different settings should build their own.
+"""
+
+from __future__ import annotations
+
+__version__ = "0.1.0"
+
+from .client import DEFAULT_URL, MAX_BATCH, Client, normalize_cep
+from .errors import (
+ CepisError,
+ CEPNotFound,
+ InvalidCEP,
+ InvalidResponse,
+ NetworkError,
+ RateLimited,
+ ServerError,
+)
+from .models import Address, Geo, Health
+
+__all__ = [
+ "DEFAULT_URL",
+ "MAX_BATCH",
+ "Address",
+ "CEPNotFound",
+ "CepisError",
+ "Client",
+ "Geo",
+ "Health",
+ "InvalidCEP",
+ "InvalidResponse",
+ "NetworkError",
+ "RateLimited",
+ "ServerError",
+ "__version__",
+ "health",
+ "lookup",
+ "lookup_many",
+ "normalize_cep",
+]
+
+_default = Client()
+
+
+def lookup(cep: str) -> Address:
+ """Look up a CEP using the default configuration.
+
+ Shorthand for ``Client().lookup(cep)``. See :meth:`Client.lookup`.
+ """
+ return _default.lookup(cep)
+
+
+def lookup_many(ceps: list[str]) -> list[Address | None]:
+ """Look up many CEPs in one batch, using the default configuration.
+
+ Shorthand for :meth:`Client.lookup_many` — read that one, especially the
+ warning that batches carry no coordinate.
+ """
+ return _default.lookup_many(ceps)
+
+
+def health() -> Health:
+ """Query the API's health using the default configuration.
+
+ Shorthand for :meth:`Client.health`.
+ """
+ return _default.health()
diff --git a/src/cepis/__main__.py b/src/cepis/__main__.py
new file mode 100644
index 0000000..9478a75
--- /dev/null
+++ b/src/cepis/__main__.py
@@ -0,0 +1,61 @@
+"""Minimal CLI: `python -m cepis 01001000`.
+
+It exists for one concrete case — quickly confirming a local instance answers
+before pointing code at it::
+
+ python -m cepis --base-url http://localhost:8080 01001000
+
+A single CEP goes through the unit lookup, which carries `geo`; several go
+through the batch, which is one request instead of N and has no `geo`.
+
+It is not the library's main interface; for that, import :mod:`cepis`.
+"""
+
+from __future__ import annotations
+
+import argparse
+import dataclasses
+import json
+import sys
+
+from . import DEFAULT_URL, CepisError, Client
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Run the CLI. Returns the process exit code."""
+ parser = argparse.ArgumentParser(
+ prog="python -m cepis",
+ description="Look CEPs up against the cep.is API.",
+ )
+ parser.add_argument("cep", nargs="+", help="one or more CEPs, masked or not")
+ parser.add_argument(
+ "--base-url",
+ default=DEFAULT_URL,
+ help=f"API URL (default: {DEFAULT_URL}) — use it to point at a local instance",
+ )
+ parser.add_argument(
+ "--timeout", type=float, default=10.0, help="per-socket timeout, in seconds"
+ )
+ args = parser.parse_args(argv)
+
+ client = Client(base_url=args.base_url, timeout=args.timeout)
+
+ try:
+ if len(args.cep) == 1:
+ addresses = [client.lookup(args.cep[0])]
+ else:
+ addresses = client.lookup_many(args.cep)
+ except CepisError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 1
+
+ output = [None if a is None else dataclasses.asdict(a) for a in addresses]
+ for item in output:
+ if item is not None:
+ item.pop("raw", None)
+ print(json.dumps(output if len(output) > 1 else output[0], ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/cepis/client.py b/src/cepis/client.py
new file mode 100644
index 0000000..bebc6e6
--- /dev/null
+++ b/src/cepis/client.py
@@ -0,0 +1,365 @@
+"""The HTTP client, built on `urllib.request`.
+
+`urllib` opens a fresh TCP+TLS connection per request and does not pool them.
+For a one-off lookup that is irrelevant. For thousands, **do not write a
+loop** — use :meth:`Client.lookup_many`, which resolves up to 500 CEPs per
+request. A loop of 500 calls pays 500 TLS handshakes; the batch pays one.
+
+Retry policy: 429 and the transient 5xx in :data:`RETRIABLE_STATUS`, plus
+network failures, are retried with exponentially backed-off, jittered waits.
+400 and 404 are deliberately absent from that set — both are definitive
+answers about the request, so retrying returns the same thing more slowly
+while burning someone else's quota.
+"""
+
+from __future__ import annotations
+
+import csv
+import io
+import json
+import random
+import re
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from email.utils import parsedate_to_datetime
+from typing import Any
+
+from . import __version__
+from .errors import (
+ CEPNotFound,
+ InvalidCEP,
+ InvalidResponse,
+ NetworkError,
+ RateLimited,
+ ServerError,
+)
+from .models import Address, Health
+
+DEFAULT_URL = "https://cep.is"
+
+MAX_BATCH = 500
+
+RETRIABLE_STATUS = frozenset({429, 500, 502, 503, 504})
+
+_DIGITS_ONLY = re.compile(r"^\d{8}$")
+
+
+def normalize_cep(value: str) -> str:
+ """Return the CEP's 8 digits, accepting the mask.
+
+ `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`.
+
+ Raises :class:`InvalidCEP` unless exactly 8 digits remain. This runs
+ **before** any request: a 7-digit CEP is the caller's bug, and spending a
+ round trip for the server to say so wastes both sides' time.
+ """
+ if not isinstance(value, str):
+ raise InvalidCEP(f"CEP must be a str, got {type(value).__name__}")
+ cleaned = value.strip().replace("-", "").replace(".", "").replace(" ", "")
+ if not _DIGITS_ONLY.match(cleaned):
+ raise InvalidCEP(f"CEP must be 8 digits, got {value!r}")
+ return cleaned
+
+
+def _retry_after_seconds(value: str | None) -> float | None:
+ """Parse the `Retry-After` header in both RFC 9110 forms.
+
+ It may arrive as a number of seconds (`"120"`) or as an HTTP date
+ (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Handling only the first is the common
+ bug, and it makes a client ignore exactly the wait request that matters
+ most.
+ """
+ if not value:
+ return None
+ value = value.strip()
+ try:
+ return max(0.0, float(value))
+ except ValueError:
+ pass
+ try:
+ target = parsedate_to_datetime(value)
+ except (TypeError, ValueError):
+ return None
+ if target is None:
+ return None
+ return max(0.0, target.timestamp() - time.time())
+
+
+class Client:
+ """Client for the cep.is API.
+
+ Reuse one instance: the configuration is immutable and no method keeps
+ state between calls, so it is safe to share across threads.
+
+ :param base_url:
+ Where the API lives. Point it at a local instance (the API repo brings
+ one up on `http://localhost:8080` with `just up`) or at a test server.
+ It is the only parameter that usually changes between environments. A
+ trailing slash is stripped, so it cannot produce doubled separators
+ that some proxies normalise and others 404.
+ :param timeout:
+ Seconds to wait **per socket operation** (connect, and each read) —
+ this is not a cap on the whole call. That cap is `total_timeout`.
+ :param max_attempts:
+ How many attempts in total, counting the first. `1` disables retrying.
+ The default of `3` covers the common case (one transient failure)
+ without turning a real outage into a long wait.
+ :param backoff_base:
+ Base of the exponential wait, in seconds. The actual wait is drawn
+ from `[0, base * 2**(n-1)]` — *full jitter*. The randomness is not
+ decoration: without it, N clients that fail together come back
+ together and reproduce the spike that took the service down.
+ :param backoff_max:
+ Cap on each individual wait, in seconds. It applies to a server-sent
+ `Retry-After` too, or a `Retry-After: 3600` from a misconfigured proxy
+ would park the thread for an hour.
+ :param total_timeout:
+ Cap on the whole call, attempts and waits included. `None` leaves
+ `max_attempts` as the only bound. Set it in code serving a web
+ request: without it the worst case is `max_attempts * timeout` plus
+ the sum of the backoffs.
+ :param user_agent:
+ Overrides the `User-Agent`. The default identifies the library and its
+ version, which is what lets the server diagnose an old client
+ misbehaving.
+ """
+
+ def __init__(
+ self,
+ base_url: str = DEFAULT_URL,
+ *,
+ timeout: float = 10.0,
+ max_attempts: int = 3,
+ backoff_base: float = 0.5,
+ backoff_max: float = 8.0,
+ total_timeout: float | None = None,
+ user_agent: str | None = None,
+ ) -> None:
+ if max_attempts < 1:
+ raise ValueError("max_attempts must be >= 1")
+ if timeout <= 0:
+ raise ValueError("timeout must be > 0")
+
+ self.base_url = base_url.rstrip("/")
+ self.timeout = timeout
+ self.max_attempts = max_attempts
+ self.backoff_base = backoff_base
+ self.backoff_max = backoff_max
+ self.total_timeout = total_timeout
+ self.user_agent = user_agent or f"cepis-python/{__version__} (+https://cep.is)"
+
+ def lookup(self, cep: str) -> Address:
+ """Look up a single CEP.
+
+ >>> client.lookup("01001-000").localidade
+ 'São Paulo'
+
+ A 400 from the API is surfaced as :class:`InvalidCEP` carrying the
+ API's own message: the format already passed local validation, so the
+ server knows something this client does not.
+
+ :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.
+ :raises ServerError: 5xx after every attempt.
+ :raises NetworkError: the request never completed.
+ """
+ number = normalize_cep(cep)
+ status, body, _ = self._request(f"/{number}")
+
+ if status == 404:
+ raise CEPNotFound(f"CEP {number} does not exist in the DNE database")
+ if status == 400:
+ raise InvalidCEP(self._error_message(body, f"CEP {number} rejected"))
+
+ return Address.from_json(self._json(body))
+
+ def lookup_many(self, ceps: list[str]) -> list[Address | None]:
+ """Look up many CEPs in one batch.
+
+ Returns a list of **the same length and in the same order** as the
+ input. A CEP that does not exist becomes `None` at its position — it
+ does not vanish from the list, and it does not raise. That is the right
+ difference from :meth:`lookup`: in a batch of a thousand, one bad CEP
+ must not cost the other nine hundred and ninety-nine.
+
+ A malformed CEP also becomes `None`, for the same reason, rather than
+ raising `InvalidCEP`. If you need to know *which* one was malformed,
+ validate first with :func:`normalize_cep`.
+
+ Input is chunked into blocks of 500 (the API's limit) automatically.
+
+ .. warning::
+ The batch carries **no coordinate**. The endpoint behind it is CSV,
+ and the CSV has no `geo` column. Every `Address` returned here has
+ `geo=None`, including for CEPs that do have one. Need `geo`? Use
+ :meth:`lookup`, one at a time.
+ """
+ if not ceps:
+ return []
+
+ result: list[Address | None] = []
+ for start in range(0, len(ceps), MAX_BATCH):
+ result.extend(self._lookup_chunk(ceps[start : start + MAX_BATCH]))
+ return result
+
+ def health(self) -> Health:
+ """Query `GET /health`: does the API answer, and with which database.
+
+ Does not raise for an API reporting itself degraded — that arrives in
+ `Health.status`, and checking health is exactly when you want the
+ diagnosis rather than an exception. Network failures still raise.
+ """
+ _, body, _ = self._request("/health", accepted_status=(200, 503))
+ return Health.from_json(self._json(body))
+
+ def _lookup_chunk(self, ceps: list[str]) -> list[Address | None]:
+ """One chunk of at most `MAX_BATCH` CEPs, in a single request.
+
+ A blank CSV row means "not found": the API fills only the `cep` column
+ and leaves the rest empty, precisely so it does not misalign whoever is
+ consuming the batch in a spreadsheet.
+
+ The API guarantees one row per requested CEP. Should that ever fail to
+ hold — a proxy truncating, a cut response — the result is padded to the
+ input length rather than returned short, because a shifted list
+ silently pairs the wrong address with the wrong CEP.
+ """
+ query = urllib.parse.urlencode({"ceps": ",".join(str(c) for c in ceps)})
+ _, body, _ = self._request(f"/csv?{query}")
+
+ text = body.decode("utf-8-sig", errors="replace")
+ reader = csv.DictReader(io.StringIO(text))
+
+ rows: list[Address | None] = []
+ for row in reader:
+ if not (row.get("localidade") or "").strip():
+ rows.append(None)
+ continue
+ rows.append(Address.from_json({k: (v or "") for k, v in row.items()}))
+
+ if len(rows) != len(ceps):
+ rows = (rows + [None] * len(ceps))[: len(ceps)]
+ return rows
+
+ def _json(self, body: bytes) -> dict[str, Any]:
+ """Decode a JSON object body, or raise :class:`InvalidResponse`."""
+ try:
+ data = json.loads(body.decode("utf-8"))
+ except (ValueError, UnicodeDecodeError) as exc:
+ raise InvalidResponse(
+ "response is not valid JSON — something between you and the "
+ f"API is answering instead of it: {body[:120]!r}"
+ ) from exc
+ if not isinstance(data, dict):
+ raise InvalidResponse(f"expected a JSON object, got {type(data).__name__}")
+ return data
+
+ @staticmethod
+ def _error_message(body: bytes, fallback: str) -> str:
+ """Pull `mensagem` out of an error body, falling back to a default."""
+ 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
+
+ def _backoff(self, attempt: int, retry_after: float | None) -> float:
+ """How long to wait before the next attempt.
+
+ The server's `Retry-After` wins over the local calculation when
+ present: it knows when it will be back, we are guessing. It still goes
+ through the `backoff_max` cap. Otherwise the wait is drawn from
+ `[0, ceiling]` rather than being the ceiling — see `backoff_base` on
+ why that randomness matters.
+ """
+ if retry_after is not None:
+ return min(retry_after, self.backoff_max)
+ ceiling = min(self.backoff_base * (2 ** (attempt - 1)), self.backoff_max)
+ return random.uniform(0, ceiling)
+
+ def _request(
+ self,
+ path: str,
+ *,
+ accepted_status: tuple[int, ...] = (200,),
+ ) -> tuple[int, bytes, Any]:
+ """Perform the GET, with retries. Returns `(status, body, headers)`.
+
+ *Definitive* error statuses (400, 404) are returned normally, for the
+ caller to turn into the right exception — only transient failures
+ become exceptions here, and only once the attempts are exhausted.
+
+ The two ``except`` clauses below must stay in this order.
+ ``HTTPError`` subclasses ``URLError``, so catching ``URLError`` first
+ would classify every 404 as a network failure and retry it.
+ """
+ url = f"{self.base_url}{path}"
+ deadline = None if self.total_timeout is None else time.monotonic() + self.total_timeout
+
+ last_status: int | None = None
+ last_retry_after: float | None = None
+ last_network_error: BaseException | None = None
+
+ for attempt in range(1, self.max_attempts + 1):
+ if deadline is not None and time.monotonic() >= deadline:
+ break
+
+ request = urllib.request.Request(
+ url,
+ method="GET",
+ headers={
+ "User-Agent": self.user_agent,
+ "Accept": "application/json, text/csv;q=0.9, */*;q=0.1",
+ "Accept-Encoding": "identity",
+ },
+ )
+
+ try:
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
+ return response.status, response.read(), response.headers
+
+ except urllib.error.HTTPError as exc:
+ body = exc.read()
+ status = exc.code
+ if status in accepted_status or status not in RETRIABLE_STATUS:
+ return status, body, exc.headers
+ last_status = status
+ last_retry_after = _retry_after_seconds(
+ exc.headers.get("Retry-After") if exc.headers else None
+ )
+ last_network_error = None
+
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
+ last_network_error = exc
+ last_status = None
+ last_retry_after = None
+
+ if attempt == self.max_attempts:
+ break
+
+ wait = self._backoff(attempt, last_retry_after)
+ if deadline is not None:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ wait = min(wait, remaining)
+ time.sleep(wait)
+
+ if last_status == 429:
+ raise RateLimited(
+ f"rate limit reached at {url} (after {self.max_attempts} attempt(s))",
+ retry_after=last_retry_after,
+ )
+ if last_status is not None:
+ raise ServerError(
+ f"the API answered {last_status} at {url} (after {self.max_attempts} attempt(s))",
+ status=last_status,
+ )
+ raise NetworkError(
+ f"could not reach {url} (after {self.max_attempts} attempt(s)): {last_network_error}"
+ ) from last_network_error
diff --git a/src/cepis/errors.py b/src/cepis/errors.py
new file mode 100644
index 0000000..1698972
--- /dev/null
+++ b/src/cepis/errors.py
@@ -0,0 +1,91 @@
+"""Exception hierarchy.
+
+Everything derives from :class:`CepisError`, so callers who only care that
+*something* went wrong can catch one type.
+
+Below that, the split is by **the decision the caller has to make**, not by the
+HTTP status that caused it:
+
+- :class:`InvalidCEP` and :class:`CEPNotFound` are problems with the *request*.
+ Retrying is pointless — the same CEP will fail again.
+- :class:`NetworkError`, :class:`ServerError` and :class:`RateLimited` are
+ problems with *transport*. Retrying may help, and the client already retried
+ on its own before raising any of them.
+
+That split is why `CEPNotFound` is not "the HTTP 404 exception": the client
+also raises it without making any request at all, when the CEP is malformed
+before it ever leaves the machine.
+"""
+
+from __future__ import annotations
+
+
+class CepisError(Exception):
+ """Base of every error. Catch this to handle any failure."""
+
+
+class InvalidCEP(CepisError, ValueError):
+ """The CEP is not 8 digits.
+
+ Deliberately also a :class:`ValueError`: this is an argument error, and
+ code that already handles `ValueError` while validating input keeps
+ working without knowing this library exists.
+
+ Usually raised **without** a request ever being made — the client checks
+ the format before going to the network.
+ """
+
+
+class CEPNotFound(CepisError, KeyError):
+ """The CEP is well-formed but does not exist in the DNE database.
+
+ Also a :class:`KeyError`, by the same reasoning as `InvalidCEP`: looking a
+ CEP up is conceptually a key lookup.
+
+ `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.
+ """
+
+ def __str__(self) -> str:
+ return self.args[0] if self.args else ""
+
+
+class NetworkError(CepisError):
+ """The request never completed: DNS, refused connection, timeout, TLS.
+
+ Raised only after every configured attempt has failed.
+ """
+
+
+class ServerError(CepisError):
+ """The API answered 5xx on every attempt.
+
+ `status` holds the HTTP status seen on the last attempt.
+ """
+
+ def __init__(self, message: str, status: int) -> None:
+ super().__init__(message)
+ self.status = status
+
+
+class RateLimited(CepisError):
+ """The API (or the edge in front of it) answered 429.
+
+ `retry_after` is what the server asked for, in seconds, or `None` if it
+ did not say. The client already honoured that value across the attempts it
+ made; it is exposed here for callers who want to reschedule the work.
+ """
+
+ def __init__(self, message: str, retry_after: float | None = None) -> None:
+ super().__init__(message)
+ self.retry_after = retry_after
+
+
+class InvalidResponse(CepisError):
+ """A success status came back with a body that cannot be parsed.
+
+ In practice this 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/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", "")),
+ )
diff --git a/tests/test_cepis.py b/tests/test_cepis.py
new file mode 100644
index 0000000..dc27179
--- /dev/null
+++ b/tests/test_cepis.py
@@ -0,0 +1,434 @@
+"""Test suite.
+
+These bring up a real standard-library HTTP server and point the client at it.
+No test touches the network, and no test monkeypatches `urllib`: the code path
+exercised is the one that runs in production, request construction and error
+body reading included.
+
+It doubles as the test that `base_url` works — if it did not, none of this
+would run.
+
+The fixture field names stay Portuguese because they mirror the API's JSON.
+"""
+
+from __future__ import annotations
+
+import json
+import threading
+import time
+import unittest
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+from cepis import (
+ CEPNotFound,
+ Client,
+ InvalidCEP,
+ InvalidResponse,
+ NetworkError,
+ RateLimited,
+ ServerError,
+ normalize_cep,
+)
+
+SE_ADDRESS = {
+ "cep": "01001000",
+ "logradouro": "Praça da Sé",
+ "complemento": "lado ímpar",
+ "unidade": "",
+ "bairro": "Sé",
+ "localidade": "São Paulo",
+ "uf": "SP",
+ "estado": "São Paulo",
+ "regiao": "Sudeste",
+ "ibge": "3550308",
+ "gia": "1004",
+ "ddd": "11",
+ "siafi": "7107",
+}
+
+CSV_HEADER = (
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"
+)
+
+
+class Router:
+ """Per-test programmable routes, plus a request counter."""
+
+ def __init__(self) -> None:
+ self.routes: dict[str, object] = {}
+ self.calls: list[str] = []
+ self.lock = threading.Lock()
+
+ def respond(self, path: str, response: object) -> None:
+ """Register the response for a path.
+
+ The response may be a `(status, body, headers)` tuple or a list of
+ them — in which case each request consumes the next one, which is how
+ retrying is tested. The last entry repeats once the list runs out.
+ """
+ self.routes[path] = response
+
+ def next(self, path: str) -> tuple[int, str, dict[str, str]]:
+ """Record the call and return the response registered for `path`."""
+ with self.lock:
+ self.calls.append(path)
+ response = self.routes.get(path)
+ if response is None:
+ return 404, json.dumps({"erro": "cep_nao_encontrado"}), {}
+ if isinstance(response, list):
+ return response.pop(0) if len(response) > 1 else response[0]
+ return response # type: ignore[return-value]
+
+ def count(self, prefix: str) -> int:
+ """How many requests so far began with `prefix`."""
+ with self.lock:
+ return sum(1 for c in self.calls if c.startswith(prefix))
+
+
+class FakeServer:
+ """A throwaway HTTP server on a random loopback port.
+
+ `poll_interval` is deliberately low. The default is 0.5s and `shutdown()`
+ waits a full cycle of it, which charged half a second of teardown to every
+ test and put the suite at 12s with nothing actually slow. A slow suite is a
+ suite nobody runs.
+ """
+
+ def __init__(self) -> None:
+ self.router = Router()
+ router = self.router
+
+ class Handler(BaseHTTPRequestHandler):
+ protocol_version = "HTTP/1.1"
+
+ def do_GET(self) -> None:
+ """Serve the router's response. Name required by the base class."""
+ status, body, headers = router.next(self.path)
+ data = body.encode("utf-8")
+ self.send_response(status)
+ for key, value in headers.items():
+ self.send_header(key, value)
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+ def log_message(self, *args: object) -> None:
+ """Silenced, to keep the test output clean."""
+
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ self.thread = threading.Thread(
+ target=self.server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True
+ )
+ self.thread.start()
+
+ @property
+ def url(self) -> str:
+ host, port = self.server.server_address[:2]
+ return f"http://{host}:{port}"
+
+ def stop(self) -> None:
+ self.server.shutdown()
+ self.server.server_close()
+ self.thread.join(timeout=5)
+
+
+class BaseTest(unittest.TestCase):
+ """Fixture with a fake server and a client whose backoff is near zero.
+
+ The backoff is tiny because what is under test is the retry logic, not
+ `time.sleep`'s ability to sleep.
+ """
+
+ def setUp(self) -> None:
+ self.server = FakeServer()
+ self.addCleanup(self.server.stop)
+ self.client = Client(
+ base_url=self.server.url,
+ timeout=5.0,
+ backoff_base=0.001,
+ backoff_max=0.01,
+ )
+
+ @property
+ def routes(self) -> Router:
+ return self.server.router
+
+
+class TestNormalisation(unittest.TestCase):
+ def test_accepts_masked_and_unmasked(self) -> None:
+ for value in ("01001000", "01001-000", " 01001-000 ", "01001.000"):
+ self.assertEqual(normalize_cep(value), "01001000")
+
+ def test_rejects_bad_format(self) -> None:
+ for value in ("", "123", "0100100a", "010010000", "abcdefgh"):
+ with self.assertRaises(InvalidCEP):
+ normalize_cep(value)
+
+ def test_rejects_wrong_type(self) -> None:
+ with self.assertRaises(InvalidCEP):
+ normalize_cep(1001000) # type: ignore[arg-type]
+
+ def test_is_a_valueerror(self) -> None:
+ """Code already handling ValueError keeps working without this library."""
+ with self.assertRaises(ValueError):
+ normalize_cep("123")
+
+
+class TestLookup(BaseTest):
+ def test_successful_lookup(self) -> None:
+ self.routes.respond("/01001000", (200, json.dumps(SE_ADDRESS), {}))
+ address = self.client.lookup("01001-000")
+ self.assertEqual(address.localidade, "São Paulo")
+ self.assertEqual(address.uf, "SP")
+ self.assertEqual(address.formatted_cep, "01001-000")
+ self.assertIsNone(address.geo)
+
+ def test_reads_the_coordinate_when_present(self) -> None:
+ body = dict(SE_ADDRESS)
+ body["geo"] = {"lat": -23.5, "lon": -46.6, "raio_m": 120, "precisao": "logradouro"}
+ self.routes.respond("/01310100", (200, json.dumps(body), {}))
+ address = self.client.lookup("01310100")
+ assert address.geo is not None
+ self.assertAlmostEqual(address.geo.lat, -23.5)
+ self.assertTrue(address.geo.is_precise)
+
+ def test_municipality_coordinate_is_not_precise(self) -> None:
+ body = dict(SE_ADDRESS)
+ body["geo"] = {"lat": -23.5, "lon": -46.6, "raio_m": 22000, "precisao": "localidade"}
+ self.routes.respond("/01001000", (200, json.dumps(body), {}))
+ address = self.client.lookup("01001000")
+ assert address.geo is not None
+ self.assertFalse(address.geo.is_precise)
+
+ def test_malformed_geo_does_not_lose_the_address(self) -> None:
+ body = dict(SE_ADDRESS)
+ body["geo"] = {"lat": "not a number"}
+ self.routes.respond("/01001000", (200, json.dumps(body), {}))
+ address = self.client.lookup("01001000")
+ self.assertIsNone(address.geo)
+ self.assertEqual(address.localidade, "São Paulo")
+
+ def test_new_api_field_does_not_break(self) -> None:
+ body = dict(SE_ADDRESS)
+ body["campo_do_futuro"] = "value"
+ self.routes.respond("/01001000", (200, json.dumps(body), {}))
+ address = self.client.lookup("01001000")
+ self.assertEqual(address.raw["campo_do_futuro"], "value")
+
+ def test_not_found(self) -> None:
+ """KeyError quotes its str() by inheritance; the message must be clean."""
+ self.routes.respond(
+ "/99999999",
+ (404, json.dumps({"erro": "cep_nao_encontrado", "mensagem": "gone"}), {}),
+ )
+ with self.assertRaises(CEPNotFound) as ctx:
+ self.client.lookup("99999999")
+ self.assertNotIn('"', str(ctx.exception))
+
+ def test_not_found_is_not_retried(self) -> None:
+ """404 is definitive; retrying it only burns someone's quota."""
+ self.routes.respond("/99999999", (404, json.dumps({"erro": "x"}), {}))
+ with self.assertRaises(CEPNotFound):
+ self.client.lookup("99999999")
+ self.assertEqual(self.routes.count("/99999999"), 1)
+
+ def test_invalid_cep_never_leaves_the_machine(self) -> None:
+ with self.assertRaises(InvalidCEP):
+ self.client.lookup("123")
+ self.assertEqual(len(self.routes.calls), 0)
+
+ def test_api_400_becomes_invalid_cep(self) -> None:
+ self.routes.respond(
+ "/00000000",
+ (400, json.dumps({"erro": "cep_invalido", "mensagem": "rejected by the API"}), {}),
+ )
+ with self.assertRaises(InvalidCEP) as ctx:
+ self.client.lookup("00000000")
+ self.assertIn("rejected by the API", str(ctx.exception))
+
+ def test_response_that_is_not_json(self) -> None:
+ self.routes.respond("/01001000", (200, "<html>captive portal</html>", {}))
+ with self.assertRaises(InvalidResponse):
+ self.client.lookup("01001000")
+
+
+class TestRetry(BaseTest):
+ def test_retries_on_500_then_succeeds(self) -> None:
+ self.routes.respond(
+ "/01001000",
+ [
+ (500, json.dumps({"erro": "interno"}), {}),
+ (200, json.dumps(SE_ADDRESS), {}),
+ ],
+ )
+ address = self.client.lookup("01001000")
+ self.assertEqual(address.localidade, "São Paulo")
+ self.assertEqual(self.routes.count("/01001000"), 2)
+
+ def test_exhausts_attempts_then_raises(self) -> None:
+ """Three attempts is the documented default."""
+ self.routes.respond("/01001000", (503, json.dumps({"erro": "interno"}), {}))
+ with self.assertRaises(ServerError) as ctx:
+ self.client.lookup("01001000")
+ self.assertEqual(ctx.exception.status, 503)
+ self.assertEqual(self.routes.count("/01001000"), 3)
+
+ def test_max_attempts_one_disables_retry(self) -> None:
+ client = Client(base_url=self.server.url, max_attempts=1, timeout=5.0)
+ self.routes.respond("/01001000", (500, "{}", {}))
+ with self.assertRaises(ServerError):
+ client.lookup("01001000")
+ self.assertEqual(self.routes.count("/01001000"), 1)
+
+ def test_429_becomes_rate_limited_with_retry_after(self) -> None:
+ self.routes.respond("/01001000", (429, "{}", {"Retry-After": "7"}))
+ with self.assertRaises(RateLimited) as ctx:
+ self.client.lookup("01001000")
+ self.assertEqual(ctx.exception.retry_after, 7.0)
+
+ def test_retry_after_honours_the_cap(self) -> None:
+ """An absurd Retry-After from a bad proxy must not park the thread."""
+ self.routes.respond("/01001000", (429, "{}", {"Retry-After": "3600"}))
+ started = time.monotonic()
+ with self.assertRaises(RateLimited):
+ self.client.lookup("01001000")
+ self.assertLess(time.monotonic() - started, 5.0)
+
+ def test_network_error_when_nothing_listens(self) -> None:
+ """Port 1 is closed, so the connection is refused outright."""
+ client = Client(
+ base_url="http://127.0.0.1:1",
+ max_attempts=2,
+ backoff_base=0.001,
+ timeout=1.0,
+ )
+ with self.assertRaises(NetworkError):
+ client.lookup("01001000")
+
+ def test_total_timeout_bounds_the_whole_call(self) -> None:
+ """The deadline must cut the run short of the 50 configured attempts."""
+ client = Client(
+ base_url=self.server.url,
+ max_attempts=50,
+ backoff_base=0.05,
+ backoff_max=0.05,
+ total_timeout=0.3,
+ )
+ self.routes.respond("/01001000", (500, "{}", {}))
+ started = time.monotonic()
+ with self.assertRaises(ServerError):
+ client.lookup("01001000")
+ self.assertLess(time.monotonic() - started, 3.0)
+ self.assertLess(self.routes.count("/01001000"), 50)
+
+
+class TestBatch(BaseTest):
+ def _csv(self, rows: list[str]) -> str:
+ return CSV_HEADER + "\n" + "\n".join(rows) + "\n"
+
+ def test_preserves_order_and_marks_missing(self) -> None:
+ """A missing CEP becomes None at its position; it does not vanish."""
+ body = self._csv(
+ [
+ "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,"
+ "Sudeste,3550308,1004,11,7107",
+ "99999999,,,,,,,,,,,,",
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,"
+ "Rio de Janeiro,Sudeste,3304557,,21,6001",
+ ]
+ )
+ self.routes.respond("/csv?ceps=01001000%2C99999999%2C20040020", (200, body, {}))
+ result = self.client.lookup_many(["01001000", "99999999", "20040020"])
+
+ self.assertEqual(len(result), 3)
+ assert result[0] is not None
+ self.assertEqual(result[0].localidade, "São Paulo")
+ self.assertIsNone(result[1])
+ assert result[2] is not None
+ self.assertEqual(result[2].uf, "RJ")
+
+ def test_field_with_comma_is_parsed(self) -> None:
+ body = self._csv(
+ ['01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1']
+ )
+ self.routes.respond("/csv?ceps=01001000", (200, body, {}))
+ result = self.client.lookup_many(["01001000"])
+ assert result[0] is not None
+ self.assertEqual(result[0].logradouro, "Rua Um, Dois")
+
+ def test_empty_list_makes_no_request(self) -> None:
+ self.assertEqual(self.client.lookup_many([]), [])
+ self.assertEqual(len(self.routes.calls), 0)
+
+ def test_chunks_above_the_api_limit(self) -> None:
+ """501 CEPs must become 2 requests, not 1 the API would reject."""
+ ceps = [f"{i:08d}" for i in range(501)]
+ rows_a = [f"{c},,,,,X,SP,São Paulo,Sudeste,1,,11,1" for c in ceps[:500]]
+ rows_b = [f"{c},,,,,X,SP,São Paulo,Sudeste,1,,11,1" for c in ceps[500:]]
+
+ calls: list[str] = []
+ original = self.routes.next
+
+ def spy(path: str):
+ calls.append(path)
+ return 200, self._csv(rows_a if len(calls) == 1 else rows_b), {}
+
+ self.routes.next = spy # type: ignore[method-assign]
+ try:
+ result = self.client.lookup_many(ceps)
+ finally:
+ self.routes.next = original # type: ignore[method-assign]
+
+ self.assertEqual(len(calls), 2)
+ self.assertEqual(len(result), 501)
+
+ def test_truncated_response_does_not_shift(self) -> None:
+ """Padding beats shifting: a shift pairs the wrong address, silently."""
+ body = self._csv(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"])
+ self.routes.respond("/csv?ceps=01001000%2C20040020", (200, body, {}))
+ result = self.client.lookup_many(["01001000", "20040020"])
+ self.assertEqual(len(result), 2)
+ self.assertIsNone(result[1])
+
+
+class TestHealth(BaseTest):
+ def test_reads_health(self) -> None:
+ body = json.dumps(
+ {
+ "status": "ok",
+ "dne": "26071",
+ "ceps": 1596168,
+ "geo": 894489,
+ "base_gerada_em": "2026-07-24T03:48:28Z",
+ }
+ )
+ self.routes.respond("/health", (200, body, {}))
+ health = self.client.health()
+ self.assertTrue(health.ok)
+ self.assertEqual(health.dne, "26071")
+ self.assertEqual(health.ceps, 1596168)
+
+ def test_degraded_does_not_raise(self) -> None:
+ """Checking health is when you want a diagnosis, not an exception."""
+ body = json.dumps({"status": "degradado", "dne": "", "ceps": 0, "geo": 0})
+ self.routes.respond("/health", (503, body, {}))
+ health = self.client.health()
+ self.assertFalse(health.ok)
+
+
+class TestConfiguration(unittest.TestCase):
+ def test_trailing_slash_is_not_duplicated(self) -> None:
+ self.assertEqual(Client(base_url="http://x/").base_url, "http://x")
+
+ def test_invalid_parameters(self) -> None:
+ with self.assertRaises(ValueError):
+ Client(max_attempts=0)
+ with self.assertRaises(ValueError):
+ Client(timeout=0)
+
+ def test_user_agent_identifies_the_library(self) -> None:
+ self.assertIn("cepis-python/", Client().user_agent)
+
+
+if __name__ == "__main__":
+ unittest.main()