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 +65 -0
- postali/_client.py +236 -0
- postali/_errors.py +42 -0
- postali/_models.py +256 -0
- postali/_normalize.py +40 -0
- postali/_version.py +1 -0
- postali/py.typed +0 -0
- postali_api-0.1.0.dist-info/METADATA +195 -0
- postali_api-0.1.0.dist-info/RECORD +11 -0
- postali_api-0.1.0.dist-info/WHEEL +4 -0
- postali_api-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|