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 /src | |
| download | python-0.1.0.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'src')
| -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 |
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", "")), + ) |