diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 11:44:58 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 11:44:58 -0300 |
| commit | a82459630e957a8d94c375394dae66734f3c90c1 (patch) | |
| tree | d9d1b9e1249ccd8cd1d989f532c6368a70dc9cc2 | |
| download | python-0.1.0.tar.gz | |
Initial importv0.1.0
| -rw-r--r-- | .gitignore | 11 | ||||
| -rw-r--r-- | LICENSE | 15 | ||||
| -rw-r--r-- | Makefile | 69 | ||||
| -rw-r--r-- | README.md | 206 | ||||
| -rw-r--r-- | pyproject.toml | 53 | ||||
| -rw-r--r-- | src/cepis/__init__.py | 104 | ||||
| -rw-r--r-- | src/cepis/__main__.py | 61 | ||||
| -rw-r--r-- | src/cepis/client.py | 365 | ||||
| -rw-r--r-- | src/cepis/errors.py | 91 | ||||
| -rw-r--r-- | src/cepis/models.py | 171 | ||||
| -rw-r--r-- | tests/test_cepis.py | 434 |
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 @@ -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() |