postali-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
postali/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """Cliente oficial de la API gratuita de códigos postales de Postali.
2
+
3
+ México, Colombia y España. Sin API key, sin dependencias.
4
+
5
+ >>> from postali import Postali
6
+ >>> Postali("mx").cp("06700").asentamientos[0].nombre
7
+ 'Roma Norte'
8
+
9
+ Documentación de la API: https://postali.app/api/docs
10
+ """
11
+
12
+ from ._client import (
13
+ BULK_MAX,
14
+ DEFAULT_BASE_URL,
15
+ DEFAULT_TIMEOUT,
16
+ DEFAULT_USER_AGENT,
17
+ SEARCH_MAX_LENGTH,
18
+ Postali,
19
+ )
20
+ from ._errors import DOCS_URL, PostaliError
21
+ from ._models import (
22
+ Asentamiento,
23
+ BulkItem,
24
+ BulkResult,
25
+ Colonia,
26
+ CpResult,
27
+ Estado,
28
+ EstadosResult,
29
+ MunicipioItem,
30
+ MunicipioResult,
31
+ MunicipiosResult,
32
+ SearchHit,
33
+ SearchResult,
34
+ ValidateResult,
35
+ )
36
+ from ._normalize import COUNTRIES, CP_LENGTH, normalize_cp
37
+ from ._version import __version__
38
+
39
+ __all__ = [
40
+ "Postali",
41
+ "PostaliError",
42
+ "normalize_cp",
43
+ "COUNTRIES",
44
+ "CP_LENGTH",
45
+ "BULK_MAX",
46
+ "SEARCH_MAX_LENGTH",
47
+ "DEFAULT_BASE_URL",
48
+ "DEFAULT_TIMEOUT",
49
+ "DEFAULT_USER_AGENT",
50
+ "DOCS_URL",
51
+ "Asentamiento",
52
+ "BulkItem",
53
+ "BulkResult",
54
+ "Colonia",
55
+ "CpResult",
56
+ "Estado",
57
+ "EstadosResult",
58
+ "MunicipioItem",
59
+ "MunicipioResult",
60
+ "MunicipiosResult",
61
+ "SearchHit",
62
+ "SearchResult",
63
+ "ValidateResult",
64
+ "__version__",
65
+ ]
postali/_client.py ADDED
@@ -0,0 +1,236 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import socket
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
9
+
10
+ from ._errors import API_ERROR_CODES, PostaliError
11
+ from ._models import (
12
+ BulkItem,
13
+ BulkResult,
14
+ CpResult,
15
+ Estado,
16
+ EstadosResult,
17
+ MunicipioResult,
18
+ MunicipiosResult,
19
+ SearchResult,
20
+ ValidateResult,
21
+ )
22
+ from ._normalize import COUNTRIES, normalize_cp
23
+ from ._version import __version__
24
+
25
+ DEFAULT_BASE_URL = "https://postali.app"
26
+ DEFAULT_TIMEOUT = 10.0
27
+ #: Máximo de códigos que acepta ``POST /bulk`` por petición.
28
+ BULK_MAX = 100
29
+ #: Longitud máxima de ``q`` en ``search``.
30
+ SEARCH_MAX_LENGTH = 100
31
+ # Cloudflare rechaza (403) el User-Agent por defecto de urllib ("Python-urllib/x.y").
32
+ DEFAULT_USER_AGENT = f"postali-py/{__version__} (+https://postali.app)"
33
+
34
+ _Timeout = Union[float, None]
35
+
36
+
37
+ class Postali:
38
+ """Cliente síncrono de la API de Postali.
39
+
40
+ >>> from postali import Postali
41
+ >>> postali = Postali(country="mx")
42
+ >>> postali.cp("06700").municipio
43
+ 'Cuauhtémoc'
44
+
45
+ :param country: ``"mx"`` (por defecto), ``"co"`` o ``"es"``.
46
+ :param base_url: URL base de la API. Por defecto ``https://postali.app``.
47
+ :param timeout: segundos por petición (``None`` = sin límite). Por defecto 10.
48
+ :param user_agent: cabecera ``User-Agent`` a enviar.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ country: str = "mx",
54
+ *,
55
+ base_url: str = DEFAULT_BASE_URL,
56
+ timeout: _Timeout = DEFAULT_TIMEOUT,
57
+ user_agent: str = DEFAULT_USER_AGENT,
58
+ ) -> None:
59
+ if country not in COUNTRIES:
60
+ raise ValueError(f"País no soportado: {country!r}. Usa 'mx', 'co' o 'es'.")
61
+ self.country = country
62
+ self.base_url = base_url.rstrip("/")
63
+ self.timeout = timeout
64
+ self.user_agent = user_agent
65
+ self._root = f"{self.base_url}/api/v1/{country}"
66
+
67
+ def __repr__(self) -> str:
68
+ return f"Postali(country={self.country!r}, base_url={self.base_url!r})"
69
+
70
+ # ------------------------------------------------------------------ lookup
71
+
72
+ def cp(self, codigo: Union[str, int]) -> CpResult:
73
+ """Estado, municipio y asentamientos de un código postal.
74
+
75
+ Lanza ``PostaliError`` con ``code="not_found"`` si el CP no existe.
76
+ """
77
+ return CpResult.from_dict(self._get(f"/cp/{self._cp(codigo)}"))
78
+
79
+ def validate(self, codigo: Union[str, int]) -> ValidateResult:
80
+ """Comprueba si un CP existe. El resultado es *truthy* si es válido."""
81
+ return ValidateResult.from_dict(self._get(f"/validate/{self._cp(codigo)}"))
82
+
83
+ # ------------------------------------------------------------------ search
84
+
85
+ def search(self, q: str, *, limit: Optional[int] = None) -> SearchResult:
86
+ """Búsqueda difusa por colonia, municipio o CP (para autocompletar)."""
87
+ query = q.strip() if isinstance(q, str) else ""
88
+ if not query:
89
+ raise PostaliError("invalid_query", "La búsqueda está vacía.", status=400)
90
+ if len(query) > SEARCH_MAX_LENGTH:
91
+ raise PostaliError(
92
+ "invalid_query", f"La búsqueda excede {SEARCH_MAX_LENGTH} caracteres.", status=400
93
+ )
94
+ params: List[Tuple[str, str]] = [("q", query)]
95
+ if limit is not None:
96
+ params.append(("limit", str(int(limit))))
97
+ return SearchResult.from_dict(self._get(f"/search?{urllib.parse.urlencode(params)}"))
98
+
99
+ # --------------------------------------------------------------- geografía
100
+
101
+ def estados(self) -> EstadosResult:
102
+ """Regiones de nivel 1 (estados / departamentos / provincias)."""
103
+ return EstadosResult.from_dict(self._get("/estados"))
104
+
105
+ def estado(self, slug: str) -> Estado:
106
+ """Detalle de una región."""
107
+ return Estado.from_dict(self._get(f"/estado/{_slug(slug, 'estado')}"))
108
+
109
+ def municipios(self, estado_slug: str) -> MunicipiosResult:
110
+ """Municipios de una región, ordenados por nombre."""
111
+ return MunicipiosResult.from_dict(
112
+ self._get(f"/estado/{_slug(estado_slug, 'estado')}/municipios")
113
+ )
114
+
115
+ def municipio(self, estado_slug: str, municipio_slug: str) -> MunicipioResult:
116
+ """Asentamientos de un municipio (máx. 1000; ver ``truncated``)."""
117
+ return MunicipioResult.from_dict(
118
+ self._get(
119
+ f"/municipio/{_slug(estado_slug, 'estado')}/{_slug(municipio_slug, 'municipio')}"
120
+ )
121
+ )
122
+
123
+ # -------------------------------------------------------------------- bulk
124
+
125
+ def bulk(self, codigos: Sequence[Union[str, int]]) -> BulkResult:
126
+ """Resuelve muchos CPs a la vez.
127
+
128
+ Normaliza cada código, marca como ``valid=False`` los que no tienen
129
+ formato válido (sin enviarlos) y parte la lista en lotes de 100.
130
+ Los resultados conservan el orden de entrada.
131
+ """
132
+ if isinstance(codigos, (str, bytes)) or not codigos:
133
+ raise PostaliError("invalid_query", "`codigos` debe ser una lista no vacía.", status=400)
134
+ results: List[Optional[BulkItem]] = [None] * len(codigos)
135
+ pending: List[Tuple[int, str]] = []
136
+ for i, raw in enumerate(codigos):
137
+ cp = normalize_cp(raw, self.country)
138
+ if cp is None:
139
+ results[i] = BulkItem(cp=str(raw), valid=False)
140
+ else:
141
+ pending.append((i, cp))
142
+ for start in range(0, len(pending), BULK_MAX):
143
+ chunk = pending[start : start + BULK_MAX]
144
+ data = self._request("POST", "/bulk", {"cps": [cp for _, cp in chunk]})
145
+ items = data.get("results", []) if isinstance(data, dict) else []
146
+ for j, (i, cp) in enumerate(chunk):
147
+ results[i] = BulkItem.from_dict(items[j]) if j < len(items) else BulkItem(cp=cp, valid=False)
148
+ final = [r if r is not None else BulkItem(cp="", valid=False) for r in results]
149
+ return BulkResult(total=len(final), results=final)
150
+
151
+ # ---------------------------------------------------------------- internos
152
+
153
+ def _cp(self, codigo: Union[str, int]) -> str:
154
+ cp = normalize_cp(codigo, self.country)
155
+ if cp is None:
156
+ raise PostaliError(
157
+ "invalid_cp",
158
+ f"{codigo!r} no tiene el formato de CP de {self.country.upper()}.",
159
+ status=400,
160
+ )
161
+ return cp
162
+
163
+ def _get(self, path: str) -> Dict[str, Any]:
164
+ return self._request("GET", path)
165
+
166
+ def _request(self, method: str, path: str, body: Any = None) -> Dict[str, Any]:
167
+ headers = {"User-Agent": self.user_agent, "Accept": "application/json"}
168
+ data: Optional[bytes] = None
169
+ if body is not None:
170
+ data = json.dumps(body).encode("utf-8")
171
+ headers["Content-Type"] = "application/json"
172
+ req = urllib.request.Request(self._root + path, data=data, headers=headers, method=method)
173
+ try:
174
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
175
+ status = resp.status
176
+ raw = resp.read()
177
+ except urllib.error.HTTPError as e:
178
+ try:
179
+ raw_err = e.read()
180
+ except Exception:
181
+ raw_err = b""
182
+ raise _to_error(e.code, raw_err) from None
183
+ except urllib.error.URLError as e:
184
+ if isinstance(e.reason, (socket.timeout, TimeoutError)):
185
+ raise self._timeout_error() from e
186
+ raise PostaliError(
187
+ "network_error", f"No se pudo contactar con Postali: {e.reason}"
188
+ ) from e
189
+ except (socket.timeout, TimeoutError) as e:
190
+ raise self._timeout_error() from e
191
+ except OSError as e:
192
+ raise PostaliError("network_error", f"No se pudo contactar con Postali: {e}") from e
193
+
194
+ parsed = _parse_json(raw)
195
+ if not isinstance(parsed, dict):
196
+ raise PostaliError("http_error", "Respuesta de Postali vacía o no es JSON.", status=status)
197
+ return parsed
198
+
199
+ def _timeout_error(self) -> PostaliError:
200
+ return PostaliError("timeout", f"La petición a Postali superó {self.timeout} s.")
201
+
202
+
203
+ def _slug(value: str, name: str) -> str:
204
+ s = value.strip() if isinstance(value, str) else ""
205
+ if not s:
206
+ raise PostaliError("invalid_query", f"Falta `{name}`.", status=400)
207
+ return urllib.parse.quote(s, safe="")
208
+
209
+
210
+ def _parse_json(raw: bytes) -> Any:
211
+ try:
212
+ return json.loads(raw.decode("utf-8")) if raw else None
213
+ except (ValueError, UnicodeDecodeError):
214
+ return None
215
+
216
+
217
+ def _to_error(status: int, raw: bytes) -> PostaliError:
218
+ data = _parse_json(raw)
219
+ err = data.get("error") if isinstance(data, dict) else None
220
+ if isinstance(err, dict) and isinstance(err.get("code"), str):
221
+ code = err["code"] if err["code"] in API_ERROR_CODES else "http_error"
222
+ raw_msg, raw_docs = err.get("message"), err.get("docs_url")
223
+ message = raw_msg if isinstance(raw_msg, str) else f"HTTP {status}"
224
+ docs = raw_docs if isinstance(raw_docs, str) else None
225
+ return PostaliError(code, message, status=status, docs_url=docs)
226
+ if status == 404:
227
+ code = "not_found"
228
+ elif status == 429:
229
+ code = "rate_limited"
230
+ elif status >= 500:
231
+ code = "internal_error"
232
+ else:
233
+ code = "http_error"
234
+ snippet = raw[:200].decode("utf-8", "replace").strip()
235
+ msg = f"HTTP {status}" + (f": {snippet}" if snippet and not snippet.startswith("<") else "")
236
+ return PostaliError(code, msg, status=status)
postali/_errors.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ DOCS_URL = "https://postali.app/api/docs#errors"
6
+
7
+ #: Códigos que devuelve la API en ``{"error": {"code": ...}}``.
8
+ API_ERROR_CODES = frozenset(
9
+ {"invalid_cp", "invalid_query", "not_found", "rate_limited", "internal_error"}
10
+ )
11
+
12
+
13
+ class PostaliError(Exception):
14
+ """Error de la API de Postali o del transporte.
15
+
16
+ ``code`` es uno de:
17
+
18
+ - ``invalid_cp``, ``invalid_query``, ``not_found``, ``rate_limited``,
19
+ ``internal_error``: los devuelve la API.
20
+ - ``timeout``: se superó el timeout.
21
+ - ``network_error``: no se pudo conectar (DNS, TLS, conexión rechazada…).
22
+ - ``http_error``: respuesta no exitosa sin cuerpo de error reconocible.
23
+
24
+ ``status`` es el status HTTP, o ``0`` si no hubo respuesta.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ code: str,
30
+ message: str,
31
+ *,
32
+ status: int = 0,
33
+ docs_url: Optional[str] = None,
34
+ ) -> None:
35
+ super().__init__(message)
36
+ self.code = code
37
+ self.message = message
38
+ self.status = status
39
+ self.docs_url = docs_url or DOCS_URL
40
+
41
+ def __repr__(self) -> str:
42
+ return f"PostaliError(code={self.code!r}, status={self.status}, message={self.message!r})"
postali/_models.py ADDED
@@ -0,0 +1,256 @@
1
+ """Modelos de respuesta.
2
+
3
+ Son ``dataclasses`` inmutables con los mismos nombres de campo que el JSON de
4
+ la API, así que ``r.asentamientos[0].nombre`` funciona igual que en la
5
+ documentación. ``from_dict`` ignora campos desconocidos para que una versión
6
+ nueva de la API no rompa clientes antiguos.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, List, Mapping, Optional
13
+
14
+
15
+ def _opt_str(d: Mapping[str, Any], key: str) -> Optional[str]:
16
+ v = d.get(key)
17
+ return v if isinstance(v, str) else None
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Asentamiento:
22
+ """Colonia, fraccionamiento, barrio o zona postal dentro de un CP."""
23
+
24
+ nombre: str
25
+ tipo: str
26
+ asenta_slug: str
27
+ ciudad: Optional[str] = None #: ``None`` en CO y ES.
28
+ zona: Optional[str] = None #: ``"Urbano"`` / ``"Rural"``; ``None`` en CO y ES.
29
+
30
+ @classmethod
31
+ def from_dict(cls, d: Mapping[str, Any]) -> "Asentamiento":
32
+ return cls(
33
+ nombre=d["nombre"],
34
+ tipo=d["tipo"],
35
+ asenta_slug=d["asenta_slug"],
36
+ ciudad=_opt_str(d, "ciudad"),
37
+ zona=_opt_str(d, "zona"),
38
+ )
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class CpResult:
43
+ """Respuesta de ``GET /api/v1/{country}/cp/{codigo}``."""
44
+
45
+ cp: str
46
+ estado: str
47
+ estado_slug: str
48
+ municipio: str
49
+ municipio_slug: str
50
+ asentamientos: List[Asentamiento] = field(default_factory=list)
51
+
52
+ @classmethod
53
+ def from_dict(cls, d: Mapping[str, Any]) -> "CpResult":
54
+ return cls(
55
+ cp=d["cp"],
56
+ estado=d["estado"],
57
+ estado_slug=d["estado_slug"],
58
+ municipio=d["municipio"],
59
+ municipio_slug=d["municipio_slug"],
60
+ asentamientos=[Asentamiento.from_dict(a) for a in d.get("asentamientos", [])],
61
+ )
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ValidateResult:
66
+ """Respuesta de ``GET /api/v1/{country}/validate/{codigo}``."""
67
+
68
+ cp: str
69
+ valid: bool
70
+ asentamientos: int #: Número de asentamientos que cubre el CP.
71
+
72
+ def __bool__(self) -> bool:
73
+ return self.valid
74
+
75
+ @classmethod
76
+ def from_dict(cls, d: Mapping[str, Any]) -> "ValidateResult":
77
+ return cls(cp=d["cp"], valid=bool(d["valid"]), asentamientos=int(d["asentamientos"]))
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class SearchHit:
82
+ cp: str
83
+ nombre: str
84
+ tipo: str
85
+ municipio: str
86
+ estado: str
87
+ estado_slug: str
88
+ municipio_slug: str
89
+ asenta_slug: str
90
+
91
+ @classmethod
92
+ def from_dict(cls, d: Mapping[str, Any]) -> "SearchHit":
93
+ return cls(
94
+ cp=d["cp"],
95
+ nombre=d["nombre"],
96
+ tipo=d["tipo"],
97
+ municipio=d["municipio"],
98
+ estado=d["estado"],
99
+ estado_slug=d["estado_slug"],
100
+ municipio_slug=d["municipio_slug"],
101
+ asenta_slug=d["asenta_slug"],
102
+ )
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class SearchResult:
107
+ """Respuesta de ``GET /api/v1/{country}/search``."""
108
+
109
+ query: str
110
+ results: List[SearchHit] = field(default_factory=list)
111
+
112
+ @classmethod
113
+ def from_dict(cls, d: Mapping[str, Any]) -> "SearchResult":
114
+ return cls(query=d["query"], results=[SearchHit.from_dict(h) for h in d.get("results", [])])
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class Estado:
119
+ """Región de nivel 1: estado (MX), departamento (CO) o provincia (ES)."""
120
+
121
+ nombre: str
122
+ slug: str
123
+ total_asentamientos: int
124
+ total_municipios: int
125
+
126
+ @classmethod
127
+ def from_dict(cls, d: Mapping[str, Any]) -> "Estado":
128
+ return cls(
129
+ nombre=d["nombre"],
130
+ slug=d["slug"],
131
+ total_asentamientos=int(d["total_asentamientos"]),
132
+ total_municipios=int(d["total_municipios"]),
133
+ )
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class EstadosResult:
138
+ """Respuesta de ``GET /api/v1/{country}/estados``."""
139
+
140
+ total: int
141
+ estados: List[Estado] = field(default_factory=list)
142
+
143
+ @classmethod
144
+ def from_dict(cls, d: Mapping[str, Any]) -> "EstadosResult":
145
+ return cls(total=int(d["total"]), estados=[Estado.from_dict(e) for e in d.get("estados", [])])
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class MunicipioItem:
150
+ nombre: str
151
+ slug: str
152
+ total_asentamientos: int
153
+
154
+ @classmethod
155
+ def from_dict(cls, d: Mapping[str, Any]) -> "MunicipioItem":
156
+ return cls(nombre=d["nombre"], slug=d["slug"], total_asentamientos=int(d["total_asentamientos"]))
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class MunicipiosResult:
161
+ """Respuesta de ``GET /api/v1/{country}/estado/{slug}/municipios``."""
162
+
163
+ estado: str
164
+ estado_slug: str
165
+ total: int
166
+ municipios: List[MunicipioItem] = field(default_factory=list)
167
+
168
+ @classmethod
169
+ def from_dict(cls, d: Mapping[str, Any]) -> "MunicipiosResult":
170
+ return cls(
171
+ estado=d["estado"],
172
+ estado_slug=d["estado_slug"],
173
+ total=int(d["total"]),
174
+ municipios=[MunicipioItem.from_dict(m) for m in d.get("municipios", [])],
175
+ )
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class Colonia:
180
+ """Asentamiento dentro de un municipio (incluye su CP)."""
181
+
182
+ cp: str
183
+ nombre: str
184
+ tipo: str
185
+ asenta_slug: str
186
+ ciudad: Optional[str] = None
187
+ zona: Optional[str] = None
188
+
189
+ @classmethod
190
+ def from_dict(cls, d: Mapping[str, Any]) -> "Colonia":
191
+ return cls(
192
+ cp=d["cp"],
193
+ nombre=d["nombre"],
194
+ tipo=d["tipo"],
195
+ asenta_slug=d["asenta_slug"],
196
+ ciudad=_opt_str(d, "ciudad"),
197
+ zona=_opt_str(d, "zona"),
198
+ )
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class MunicipioResult:
203
+ """Respuesta de ``GET /api/v1/{country}/municipio/{estado}/{municipio}``."""
204
+
205
+ estado: str
206
+ estado_slug: str
207
+ municipio: str
208
+ municipio_slug: str
209
+ total_asentamientos: int
210
+ truncated: bool #: ``True`` si hay más de 1000 asentamientos y la lista se recortó.
211
+ colonias: List[Colonia] = field(default_factory=list)
212
+
213
+ @classmethod
214
+ def from_dict(cls, d: Mapping[str, Any]) -> "MunicipioResult":
215
+ return cls(
216
+ estado=d["estado"],
217
+ estado_slug=d["estado_slug"],
218
+ municipio=d["municipio"],
219
+ municipio_slug=d["municipio_slug"],
220
+ total_asentamientos=int(d["total_asentamientos"]),
221
+ truncated=bool(d["truncated"]),
222
+ colonias=[Colonia.from_dict(c) for c in d.get("colonias", [])],
223
+ )
224
+
225
+
226
+ @dataclass(frozen=True)
227
+ class BulkItem:
228
+ """Resultado individual de ``bulk``."""
229
+
230
+ cp: str
231
+ valid: bool
232
+ asentamientos: int = 0
233
+ estado: Optional[str] = None
234
+ estado_slug: Optional[str] = None
235
+ municipio: Optional[str] = None
236
+ municipio_slug: Optional[str] = None
237
+
238
+ @classmethod
239
+ def from_dict(cls, d: Mapping[str, Any]) -> "BulkItem":
240
+ return cls(
241
+ cp=d["cp"],
242
+ valid=bool(d["valid"]),
243
+ asentamientos=int(d.get("asentamientos") or 0),
244
+ estado=_opt_str(d, "estado"),
245
+ estado_slug=_opt_str(d, "estado_slug"),
246
+ municipio=_opt_str(d, "municipio"),
247
+ municipio_slug=_opt_str(d, "municipio_slug"),
248
+ )
249
+
250
+
251
+ @dataclass(frozen=True)
252
+ class BulkResult:
253
+ """Respuesta de ``POST /api/v1/{country}/bulk`` (mismo orden que la entrada)."""
254
+
255
+ total: int
256
+ results: List[BulkItem] = field(default_factory=list)
postali/_normalize.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Dict, Optional, Tuple, Union
5
+
6
+ #: Países soportados (ISO 3166-1 alfa-2, minúsculas).
7
+ COUNTRIES: Tuple[str, ...] = ("mx", "co", "es")
8
+
9
+ #: Longitud del código postal por país.
10
+ CP_LENGTH: Dict[str, int] = {"mx": 5, "co": 6, "es": 5}
11
+
12
+ _WS = re.compile(r"\s+")
13
+
14
+
15
+ def normalize_cp(value: Union[str, int], country: str = "mx") -> Optional[str]:
16
+ """Normaliza un código postal.
17
+
18
+ - Acepta ``str`` o ``int``.
19
+ - Quita espacios.
20
+ - Restaura el cero inicial que Excel, un CSV o ``int()`` suelen comerse:
21
+ ``"6700"`` → ``"06700"`` (MX), ``"8001"`` → ``"08001"`` (ES),
22
+ ``"50001"`` → ``"050001"`` (CO).
23
+
24
+ Se añade como máximo **un** cero y nunca delante de otro cero: en los tres
25
+ países ningún CP empieza por ``00``, así que una entrada más corta es un CP
26
+ incompleto, no uno recortado.
27
+
28
+ Devuelve ``None`` si el resultado no tiene el formato del país.
29
+ """
30
+ if country not in CP_LENGTH:
31
+ raise ValueError(f"País no soportado: {country!r}. Usa 'mx', 'co' o 'es'.")
32
+ if isinstance(value, bool) or not isinstance(value, (str, int)):
33
+ return None
34
+ length = CP_LENGTH[country]
35
+ cp = _WS.sub("", str(value))
36
+ if not cp.isascii() or not cp.isdigit():
37
+ return None
38
+ if len(cp) == length - 1 and not cp.startswith("0"):
39
+ cp = "0" + cp
40
+ return cp if len(cp) == length else None
postali/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
postali/py.typed ADDED
File without changes
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.5
2
+ Name: postali-api
3
+ Version: 0.1.0
4
+ Summary: Cliente oficial de la API gratuita de códigos postales de Postali (México, Colombia, España). Sin dependencias.
5
+ Project-URL: Homepage, https://postali.app
6
+ Project-URL: Documentation, https://postali.app/api/docs
7
+ Project-URL: Repository, https://github.com/gomflo/postali-py
8
+ Project-URL: Issues, https://github.com/gomflo/postali-py/issues
9
+ Author-email: Postali <hi@postali.app>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api,codigo postal,colombia,colonias,cp,códigos postales,geonames,mexico,postal code,sepomex,spain,zip code
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Natural Language :: Spanish
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Topic :: Internet :: WWW/HTTP
26
+ Classifier: Topic :: Software Development :: Libraries
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.9
29
+ Description-Content-Type: text/markdown
30
+
31
+ # postali
32
+
33
+ Cliente oficial para Python de la **API gratuita de códigos postales de [Postali](https://postali.app)**: México, Colombia y España.
34
+
35
+ - Sin API key, sin registro, sin cuota mensual.
36
+ - Cero dependencias: sólo la biblioteca estándar (`urllib`). Python 3.9+.
37
+ - Respuestas tipadas como `dataclasses` inmutables (`py.typed` incluido).
38
+ - Restaura el cero inicial que se pierde en Excel, un CSV o `int()` (`6700` → `"06700"`).
39
+
40
+ **Documentación de la API:** https://postali.app/api/docs · **Sitio:** https://postali.app
41
+
42
+ ## Instalación
43
+
44
+ ```bash
45
+ pip install postali-api
46
+ ```
47
+
48
+ El paquete se instala como `postali-api` y se importa como `postali`.
49
+
50
+ ## Uso rápido
51
+
52
+ ```python
53
+ from postali import Postali
54
+
55
+ postali = Postali("mx")
56
+ print(postali.cp("06700").asentamientos[0].nombre) # Roma Norte
57
+ ```
58
+
59
+ ## Ejemplos
60
+
61
+ ### Autocompletar la colonia en un formulario de dirección
62
+
63
+ Un endpoint de tu backend (aquí con Flask) que el formulario llama cuando el usuario termina de escribir el CP:
64
+
65
+ ```python
66
+ from flask import Flask, jsonify
67
+ from postali import Postali, PostaliError
68
+
69
+ app = Flask(__name__)
70
+ postali = Postali("mx")
71
+
72
+ @app.get("/direccion/<cp>")
73
+ def direccion(cp: str):
74
+ try:
75
+ r = postali.cp(cp) # "6700" también funciona
76
+ except PostaliError as e:
77
+ if e.code in ("not_found", "invalid_cp"):
78
+ return jsonify(error="CP no encontrado"), 404
79
+ raise
80
+ return jsonify(
81
+ estado=r.estado,
82
+ municipio=r.municipio,
83
+ colonias=[a.nombre for a in r.asentamientos],
84
+ )
85
+ ```
86
+
87
+ ¿Buscas por nombre de colonia en lugar de CP?
88
+
89
+ ```python
90
+ for hit in postali.search("roma norte", limit=5).results:
91
+ print(hit.cp, hit.nombre, hit.municipio, hit.estado)
92
+ ```
93
+
94
+ ### Validar un CP
95
+
96
+ ```python
97
+ if not postali.validate("06700"): # ValidateResult es truthy si el CP existe
98
+ raise ValueError("Ese código postal no existe.")
99
+ ```
100
+
101
+ `validate` lanza `PostaliError` con `code == "invalid_cp"` si el texto ni siquiera tiene formato de CP (p. ej. `"abc"`), sin hacer la petición.
102
+
103
+ ### Limpiar una columna de CPs (CSV / pandas)
104
+
105
+ ```python
106
+ r = postali.bulk(["06700", "44100", 6700, "abc"])
107
+ for item in r.results: # mismo orden que la entrada
108
+ print(item.cp, item.valid, item.municipio)
109
+ # 06700 True Cuauhtémoc · 44100 True Guadalajara · 06700 True Cuauhtémoc · abc False None
110
+ ```
111
+
112
+ `bulk` normaliza cada código, marca los que no tienen formato válido como `valid=False` y parte la lista en lotes de 100.
113
+
114
+ ### Colombia y España
115
+
116
+ ```python
117
+ Postali("co").cp("050001").municipio # 'Medellín'
118
+ Postali("es").cp(8001).municipio # "08001" → 'Barcelona'
119
+ ```
120
+
121
+ ## API
122
+
123
+ `Postali(country="mx", *, base_url="https://postali.app", timeout=10.0, user_agent=...)`
124
+
125
+ | Método | Endpoint | Devuelve |
126
+ |---|---|---|
127
+ | `cp(codigo)` | `GET /api/v1/{country}/cp/{codigo}` | `CpResult` |
128
+ | `validate(codigo)` | `GET /api/v1/{country}/validate/{codigo}` | `ValidateResult` |
129
+ | `search(q, limit=None)` | `GET /api/v1/{country}/search?q=` | `SearchResult` |
130
+ | `estados()` | `GET /api/v1/{country}/estados` | `EstadosResult` |
131
+ | `estado(slug)` | `GET /api/v1/{country}/estado/{slug}` | `Estado` |
132
+ | `municipios(estado_slug)` | `GET /api/v1/{country}/estado/{slug}/municipios` | `MunicipiosResult` |
133
+ | `municipio(estado_slug, municipio_slug)` | `GET /api/v1/{country}/municipio/{estado}/{municipio}` | `MunicipioResult` |
134
+ | `bulk(codigos)` | `POST /api/v1/{country}/bulk` | `BulkResult` |
135
+
136
+ "Estado" es el nivel 1 de cada país: estado en México, departamento en Colombia, provincia en España. Los campos de cada resultado se llaman igual que en el JSON de la API.
137
+
138
+ ### Errores
139
+
140
+ Todo error de la API o de red se lanza como `PostaliError`, con `code`, `status`, `message` y `docs_url`:
141
+
142
+ ```python
143
+ from postali import PostaliError
144
+
145
+ try:
146
+ postali.cp("00000")
147
+ except PostaliError as e:
148
+ print(e.code, e.status, e.message) # not_found 404 No se encontró el recurso solicitado.
149
+ ```
150
+
151
+ | `code` | Cuándo |
152
+ |---|---|
153
+ | `invalid_cp` | El CP no tiene el formato del país |
154
+ | `invalid_query` | Falta `q`, está vacía o hay un parámetro inválido |
155
+ | `not_found` | El CP, estado o municipio no existe |
156
+ | `rate_limited` | Demasiadas peticiones desde tu IP |
157
+ | `internal_error` | Error del servidor (5xx) |
158
+ | `timeout` | Se superó el `timeout` |
159
+ | `network_error` | No se pudo conectar |
160
+ | `http_error` | Otra respuesta no exitosa |
161
+
162
+ ### Normalización de CPs
163
+
164
+ ```python
165
+ from postali import normalize_cp
166
+
167
+ normalize_cp("6700") # '06700'
168
+ normalize_cp(" 76 148 ") # '76148'
169
+ normalize_cp("50001", "co") # '050001'
170
+ normalize_cp("670") # None (incompleto)
171
+ ```
172
+
173
+ Se añade como máximo un cero, y nunca delante de otro cero: ningún CP de México, Colombia o España empieza por `00`.
174
+
175
+ ## Datos y atribución
176
+
177
+ Datos: Sepomex vía [Postali](https://postali.app) / GeoNames ([CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)) para CO y ES.
178
+
179
+ ## English
180
+
181
+ `postali` is the official zero-dependency Python client (stdlib `urllib`, Python 3.9+) for the free [Postali](https://postali.app) postal-code API covering Mexico, Colombia and Spain. No API key required.
182
+
183
+ ```python
184
+ from postali import Postali
185
+ r = Postali("mx").cp("06700") # "mx" | "co" | "es"
186
+ print(r.estado, r.municipio, [a.nombre for a in r.asentamientos])
187
+ ```
188
+
189
+ Responses are typed frozen dataclasses; errors raise `PostaliError` with `code` (`invalid_cp`, `not_found`, `rate_limited`, `timeout`…), `status` and `docs_url`. Postal codes that lost their leading zero (`6700`) are restored automatically. API reference: https://postali.app/api/docs
190
+
191
+ Data: Sepomex via Postali / GeoNames (CC BY 4.0) for CO and ES.
192
+
193
+ ## Licencia
194
+
195
+ MIT © Postali
@@ -0,0 +1,11 @@
1
+ postali/__init__.py,sha256=Pi0KqTUCct3L5wbS4GxD8ckLfTLo-vNMuxtxTk_Ckvc,1330
2
+ postali/_client.py,sha256=e-_hLEzxP8wbm0xQl6uWWJ8DtUnjzEx-wZTl1UqP3fM,9577
3
+ postali/_errors.py,sha256=R3A0WfW2_W_nL85rjXuihAzoHp7--csw-QDlCgSLO-c,1258
4
+ postali/_models.py,sha256=lZvFNylY_mlBOVqHyLeU540qZZWB91OkdROIEt8GjiM,7224
5
+ postali/_normalize.py,sha256=lcWHbmLV1MbZVrZeqg_M8iRnUz36us9dcsHCr2ZxnCA,1426
6
+ postali/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
7
+ postali/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ postali_api-0.1.0.dist-info/METADATA,sha256=TjwKRWptH8CmPDOuXlpfumFi5fTEAaAaK4FA_OAKskY,7060
9
+ postali_api-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ postali_api-0.1.0.dist-info/licenses/LICENSE,sha256=2D5CDjLSink4hYeKI8tJ0V9UnEdloIjpsY0crBHYX7s,1081
11
+ postali_api-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Postali <hi@postali.app>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.