summaryrefslogtreecommitdiffstats
path: root/src
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 /src
downloadpython-a82459630e957a8d94c375394dae66734f3c90c1.tar.gz
Initial importv0.1.0
Diffstat (limited to 'src')
-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
5 files changed, 792 insertions, 0 deletions
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", "")),
+ )