folyo 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.
folyo/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ """SDK oficial de Python para la API de Folyo.
2
+
3
+ Folyo es facturacion electronica chilena (DTE + SII). Este SDK envuelve la API
4
+ REST de ``https://api.folyo.cl`` con un cliente tipado, manejo de errores y
5
+ soporte para el patron de emision asincrona.
6
+
7
+ Ejemplo minimo::
8
+
9
+ from folyo import Folyo, DTERequest, Receptor, DetalleLinea
10
+
11
+ with Folyo(api_key="<tu-api-key>") as folyo:
12
+ req = DTERequest(
13
+ tipo_dte=33,
14
+ receptor=Receptor(rut="12.345.678-9", razon_social="Cliente SpA"),
15
+ detalle=[DetalleLinea(nombre="Servicio", monto=100000)],
16
+ )
17
+ job = folyo.dte.emitir_y_esperar(req)
18
+ print(job.result.folio)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from ._redact import redact
24
+ from .client import DEFAULT_BASE_URL, USER_AGENT, Folyo, __version__
25
+ from .errors import (
26
+ FolyoAuthError,
27
+ FolyoConflictError,
28
+ FolyoConnectionError,
29
+ FolyoError,
30
+ FolyoNotFoundError,
31
+ FolyoQuotaError,
32
+ FolyoRateLimitError,
33
+ FolyoSiiUnavailableError,
34
+ FolyoValidationError,
35
+ )
36
+ from .models import (
37
+ AcuseRequest,
38
+ ApiKey,
39
+ ApiKeyCreated,
40
+ AuthTokens,
41
+ Cliente,
42
+ DescuentoGlobal,
43
+ DetalleLinea,
44
+ Documento,
45
+ DTERequest,
46
+ EmisionEncolada,
47
+ EmisionJob,
48
+ EmisionResult,
49
+ Empresa,
50
+ FoliosInfo,
51
+ Receptor,
52
+ Referencia,
53
+ Transporte,
54
+ Webhook,
55
+ WebhookCreated,
56
+ )
57
+
58
+ __all__ = [
59
+ "__version__",
60
+ "DEFAULT_BASE_URL",
61
+ "USER_AGENT",
62
+ "Folyo",
63
+ "redact",
64
+ # Errores
65
+ "FolyoError",
66
+ "FolyoAuthError",
67
+ "FolyoQuotaError",
68
+ "FolyoValidationError",
69
+ "FolyoNotFoundError",
70
+ "FolyoConflictError",
71
+ "FolyoRateLimitError",
72
+ "FolyoSiiUnavailableError",
73
+ "FolyoConnectionError",
74
+ # Modelos
75
+ "DTERequest",
76
+ "Receptor",
77
+ "DetalleLinea",
78
+ "DescuentoGlobal",
79
+ "Referencia",
80
+ "Transporte",
81
+ "EmisionEncolada",
82
+ "EmisionResult",
83
+ "EmisionJob",
84
+ "Documento",
85
+ "FoliosInfo",
86
+ "Cliente",
87
+ "Empresa",
88
+ "Webhook",
89
+ "WebhookCreated",
90
+ "ApiKey",
91
+ "ApiKeyCreated",
92
+ "AcuseRequest",
93
+ "AuthTokens",
94
+ ]
folyo/_redact.py ADDED
@@ -0,0 +1,26 @@
1
+ """Utilidades de redaccion de secretos.
2
+
3
+ El SDK no debe filtrar credenciales en ``repr``, logs ni telemetria. Esta
4
+ funcion enmascara cualquier valor sensible mostrando solo una pista minima.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ __all__ = ["redact"]
12
+
13
+
14
+ def redact(value: Optional[str]) -> str:
15
+ """Devuelve una version enmascarada y segura de un secreto.
16
+
17
+ - ``None`` o cadena vacia -> ``"None"``.
18
+ - Valores cortos -> ``"***"`` (sin pistas, no hay suficiente entropia).
19
+ - Valores largos -> primeros 4 caracteres + ``"***"`` (ej. ``"stri***"``),
20
+ util para distinguir keys sin revelar el secreto.
21
+ """
22
+ if not value:
23
+ return "None"
24
+ if len(value) <= 8:
25
+ return "***"
26
+ return f"{value[:4]}***"
folyo/client.py ADDED
@@ -0,0 +1,296 @@
1
+ """Cliente HTTP del SDK de Folyo.
2
+
3
+ ``Folyo`` envuelve ``httpx.Client``, arma los headers de autenticacion, parsea
4
+ el envelope ``{ok, data}`` / ``{ok, error, code}``, mapea errores a la
5
+ jerarquia tipada y reintenta con backoff exponencial ante 429/503 cuando es
6
+ seguro hacerlo.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import random
12
+ import time
13
+ from types import TracebackType
14
+ from typing import Any, Dict, Mapping, NoReturn, Optional, Type
15
+
16
+ import httpx
17
+
18
+ from ._redact import redact
19
+ from .errors import (
20
+ FolyoAuthError,
21
+ FolyoConflictError,
22
+ FolyoConnectionError,
23
+ FolyoError,
24
+ FolyoNotFoundError,
25
+ FolyoQuotaError,
26
+ FolyoRateLimitError,
27
+ FolyoSiiUnavailableError,
28
+ FolyoValidationError,
29
+ )
30
+ from .resources import (
31
+ AcuseResource,
32
+ ApiKeysResource,
33
+ ClientesResource,
34
+ DteResource,
35
+ EmpresaResource,
36
+ FoliosResource,
37
+ RcvResource,
38
+ WebhooksResource,
39
+ )
40
+
41
+ __version__ = "0.1.0"
42
+
43
+ DEFAULT_BASE_URL = "https://api.folyo.cl"
44
+ USER_AGENT = f"folyo-sdk-python/{__version__}"
45
+
46
+ # Codigos HTTP que reintentamos cuando la operacion es segura.
47
+ _RETRYABLE_STATUS = (429, 503)
48
+
49
+
50
+ class Folyo:
51
+ """Cliente sincrono de la API de Folyo.
52
+
53
+ Exige exactamente uno de ``api_key`` o ``token`` (XOR):
54
+
55
+ - ``api_key``: se envia como header ``X-API-Key`` (valor opaco, sin asumir
56
+ prefijo). Fija tenant y empresa. Recomendada para integraciones server-side.
57
+ - ``token``: JWT que se envia como ``Authorization: Bearer <token>``.
58
+
59
+ Ejemplo::
60
+
61
+ with Folyo(api_key="<tu-api-key>") as folyo:
62
+ job = folyo.dte.emitir(req)
63
+ resultado = folyo.dte.emitir_y_esperar(req)
64
+
65
+ Args:
66
+ api_key: API key opaca (XOR con ``token``).
67
+ token: JWT de sesion (XOR con ``api_key``).
68
+ base_url: URL base de la API (default produccion).
69
+ timeout: timeout por request en segundos.
70
+ max_retries: reintentos ante 429/503 en operaciones seguras.
71
+ transport: ``httpx`` transport custom (util para tests con respx/mock).
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ api_key: Optional[str] = None,
77
+ token: Optional[str] = None,
78
+ *,
79
+ base_url: str = DEFAULT_BASE_URL,
80
+ timeout: float = 30.0,
81
+ max_retries: int = 2,
82
+ transport: Optional[httpx.BaseTransport] = None,
83
+ ) -> None:
84
+ if bool(api_key) == bool(token):
85
+ raise ValueError("Debe entregar exactamente uno de 'api_key' o 'token'.")
86
+
87
+ self._api_key = api_key
88
+ self._token = token
89
+ self.base_url = base_url.rstrip("/")
90
+ self.max_retries = max(0, int(max_retries))
91
+
92
+ self._client = httpx.Client(
93
+ base_url=self.base_url,
94
+ timeout=timeout,
95
+ transport=transport,
96
+ headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
97
+ )
98
+
99
+ # Recursos
100
+ self.dte = DteResource(self)
101
+ self.folios = FoliosResource(self)
102
+ self.rcv = RcvResource(self)
103
+ self.clientes = ClientesResource(self)
104
+ self.empresa = EmpresaResource(self)
105
+ self.acuse = AcuseResource(self)
106
+ self.webhooks = WebhooksResource(self)
107
+ self.api_keys = ApiKeysResource(self)
108
+
109
+ # -- Context manager -----------------------------------------------------
110
+
111
+ def __enter__(self) -> "Folyo":
112
+ return self
113
+
114
+ def __exit__(
115
+ self,
116
+ exc_type: Optional[Type[BaseException]],
117
+ exc: Optional[BaseException],
118
+ tb: Optional[TracebackType],
119
+ ) -> None:
120
+ self.close()
121
+
122
+ def close(self) -> None:
123
+ """Cierra el cliente HTTP subyacente."""
124
+ self._client.close()
125
+
126
+ # -- Repr seguro ---------------------------------------------------------
127
+
128
+ def __repr__(self) -> str:
129
+ auth = (
130
+ f"api_key={redact(self._api_key)}"
131
+ if self._api_key is not None
132
+ else f"token={redact(self._token)}"
133
+ )
134
+ return f"Folyo(base_url={self.base_url!r}, {auth})"
135
+
136
+ # -- Headers -------------------------------------------------------------
137
+
138
+ def _auth_headers(self) -> Dict[str, str]:
139
+ if self._api_key is not None:
140
+ return {"X-API-Key": self._api_key}
141
+ return {"Authorization": f"Bearer {self._token}"}
142
+
143
+ # -- Request core --------------------------------------------------------
144
+
145
+ def _request(
146
+ self,
147
+ method: str,
148
+ path: str,
149
+ *,
150
+ params: Optional[Mapping[str, Any]] = None,
151
+ json: Optional[Any] = None,
152
+ extra_headers: Optional[Mapping[str, str]] = None,
153
+ idempotency_key: Optional[str] = None,
154
+ sii_ambiente: Optional[str] = None,
155
+ expect_binary: bool = False,
156
+ ) -> Any:
157
+ """Ejecuta un request, parsea el envelope y mapea errores.
158
+
159
+ Devuelve el contenido de ``data`` (dict/list/escalar) o, si
160
+ ``expect_binary`` es True, los bytes crudos de la respuesta. Los
161
+ recursos hacen el cast al modelo concreto.
162
+ """
163
+ headers: Dict[str, str] = dict(self._auth_headers())
164
+ if extra_headers:
165
+ headers.update(extra_headers)
166
+ if idempotency_key is not None:
167
+ headers["Idempotency-Key"] = idempotency_key
168
+ if sii_ambiente is not None:
169
+ headers["X-SII-Ambiente"] = sii_ambiente
170
+
171
+ # Solo reintentamos operaciones idempotentes: GET, o emision con
172
+ # Idempotency-Key (reintentar es seguro, no quema folio nuevo).
173
+ is_safe = method.upper() == "GET" or idempotency_key is not None
174
+
175
+ attempt = 0
176
+ while True:
177
+ try:
178
+ response = self._client.request(
179
+ method,
180
+ path,
181
+ params=params,
182
+ json=json,
183
+ headers=headers,
184
+ )
185
+ except httpx.TimeoutException as exc:
186
+ raise FolyoConnectionError(
187
+ "Timeout al conectar con la API de Folyo."
188
+ ) from exc
189
+ except httpx.TransportError as exc:
190
+ # No incluimos str(exc): puede traer la URL con datos sensibles.
191
+ raise FolyoConnectionError(
192
+ "No se pudo conectar con la API de Folyo."
193
+ ) from exc
194
+
195
+ if response.status_code in _RETRYABLE_STATUS and is_safe and attempt < self.max_retries:
196
+ delay = self._retry_delay(response, attempt)
197
+ attempt += 1
198
+ time.sleep(delay)
199
+ continue
200
+
201
+ return self._handle_response(response, expect_binary=expect_binary)
202
+
203
+ def _retry_delay(self, response: httpx.Response, attempt: int) -> float:
204
+ """Calcula el backoff: respeta ``Retry-After`` o usa backoff exponencial."""
205
+ retry_after = self._parse_retry_after(response)
206
+ if retry_after is not None:
207
+ return retry_after
208
+ # Backoff exponencial con jitter: 0.5, 1, 2, ... (+- jitter).
209
+ base: float = 0.5 * float(2**attempt)
210
+ jitter: float = random.uniform(0, 0.25)
211
+ return base + jitter
212
+
213
+ @staticmethod
214
+ def _parse_retry_after(response: httpx.Response) -> Optional[float]:
215
+ raw = response.headers.get("Retry-After")
216
+ if raw is None:
217
+ return None
218
+ try:
219
+ return float(raw)
220
+ except (TypeError, ValueError):
221
+ return None
222
+
223
+ def _handle_response(self, response: httpx.Response, *, expect_binary: bool) -> Any:
224
+ request_id = response.headers.get("X-Request-Id") or response.headers.get(
225
+ "X-Request-ID"
226
+ )
227
+
228
+ if expect_binary and response.is_success:
229
+ return response.content
230
+
231
+ # Intentamos parsear el envelope JSON.
232
+ try:
233
+ body = response.json()
234
+ except ValueError:
235
+ body = None
236
+
237
+ if response.is_success:
238
+ if isinstance(body, dict) and "data" in body:
239
+ return body["data"]
240
+ # Algunas respuestas (ej. /health) no usan envelope.
241
+ return body if isinstance(body, dict) else {}
242
+
243
+ self._raise_for_error(response, body, request_id)
244
+
245
+ def _raise_for_error(
246
+ self,
247
+ response: httpx.Response,
248
+ body: Optional[Any],
249
+ request_id: Optional[str],
250
+ ) -> NoReturn:
251
+ status = response.status_code
252
+ code: Optional[str] = None
253
+ message = ""
254
+ if isinstance(body, dict):
255
+ raw_code = body.get("code")
256
+ code = raw_code if isinstance(raw_code, str) else None
257
+ raw_msg = body.get("error")
258
+ message = raw_msg if isinstance(raw_msg, str) else ""
259
+ if not message:
260
+ message = f"La API respondio con HTTP {status}."
261
+
262
+ retry_after = self._parse_retry_after(response)
263
+
264
+ if status == 429:
265
+ raise FolyoRateLimitError(
266
+ message,
267
+ status=status,
268
+ code=code or "RATE_LIMITED",
269
+ request_id=request_id,
270
+ retry_after=retry_after,
271
+ )
272
+ if status in (502, 503, 504):
273
+ raise FolyoSiiUnavailableError(
274
+ message,
275
+ status=status,
276
+ code=code,
277
+ request_id=request_id,
278
+ retry_after=retry_after,
279
+ )
280
+ if status in (401, 403):
281
+ raise FolyoAuthError(message, status=status, code=code, request_id=request_id)
282
+ if status == 402:
283
+ raise FolyoQuotaError(message, status=status, code=code, request_id=request_id)
284
+ if status == 404:
285
+ raise FolyoNotFoundError(message, status=status, code=code, request_id=request_id)
286
+ if status == 409:
287
+ raise FolyoConflictError(message, status=status, code=code, request_id=request_id)
288
+ if status in (400, 422):
289
+ raise FolyoValidationError(message, status=status, code=code, request_id=request_id)
290
+
291
+ # PLAN_LIMIT/PLAN_REQUIRED pueden llegar como 403 (ya cubierto) pero por
292
+ # si el server cambia, mapeamos por codigo a quota.
293
+ if code in ("PLAN_LIMIT", "PLAN_REQUIRED", "PAYMENT_REQUIRED"):
294
+ raise FolyoQuotaError(message, status=status, code=code, request_id=request_id)
295
+
296
+ raise FolyoError(message, status=status, code=code, request_id=request_id)
folyo/errors.py ADDED
@@ -0,0 +1,144 @@
1
+ """Jerarquia de errores del SDK de Folyo.
2
+
3
+ Los errores NUNCA incluyen el cuerpo de la respuesta: este puede contener datos
4
+ sensibles (la clave tributaria del SII, el contenido de un .pfx, el XML firmado
5
+ o el secret de un webhook). Solo se exponen el mensaje sanitizado del servidor,
6
+ el codigo estable, el status HTTP y el request id (si el servidor lo entrega).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional
12
+
13
+ __all__ = [
14
+ "FolyoError",
15
+ "FolyoAuthError",
16
+ "FolyoQuotaError",
17
+ "FolyoValidationError",
18
+ "FolyoNotFoundError",
19
+ "FolyoConflictError",
20
+ "FolyoRateLimitError",
21
+ "FolyoSiiUnavailableError",
22
+ "FolyoConnectionError",
23
+ ]
24
+
25
+
26
+ class FolyoError(Exception):
27
+ """Error base de todos los errores del SDK.
28
+
29
+ Atributos:
30
+ message: mensaje legible y sanitizado del servidor (sin datos sensibles).
31
+ status: codigo HTTP de la respuesta (None en errores de conexion).
32
+ code: codigo estable de error del servidor (ej. ``PLAN_LIMIT``).
33
+ request_id: identificador de la peticion para soporte (si el server lo da).
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ message: str,
39
+ *,
40
+ status: Optional[int] = None,
41
+ code: Optional[str] = None,
42
+ request_id: Optional[str] = None,
43
+ ) -> None:
44
+ super().__init__(message)
45
+ self.message = message
46
+ self.status = status
47
+ self.code = code
48
+ self.request_id = request_id
49
+
50
+ def __str__(self) -> str:
51
+ parts = [self.message or self.__class__.__name__]
52
+ meta = []
53
+ if self.status is not None:
54
+ meta.append(f"status={self.status}")
55
+ if self.code:
56
+ meta.append(f"code={self.code}")
57
+ if self.request_id:
58
+ meta.append(f"request_id={self.request_id}")
59
+ if meta:
60
+ parts.append(f"({', '.join(meta)})")
61
+ return " ".join(parts)
62
+
63
+ def __repr__(self) -> str:
64
+ # No exponemos el cuerpo de la respuesta: solo metadatos seguros.
65
+ return (
66
+ f"{self.__class__.__name__}(message={self.message!r}, "
67
+ f"status={self.status!r}, code={self.code!r}, request_id={self.request_id!r})"
68
+ )
69
+
70
+
71
+ class FolyoAuthError(FolyoError):
72
+ """Fallo de autenticacion/autorizacion (401/403).
73
+
74
+ Cubre ``UNAUTHORIZED``, ``INVALID_TOKEN``, ``TOKEN_EXPIRED``,
75
+ ``PASSWORD_CHANGED``, ``FORBIDDEN``, ``INSUFFICIENT_SCOPE``,
76
+ ``ACCOUNT_SUSPENDED``.
77
+ """
78
+
79
+
80
+ class FolyoQuotaError(FolyoError):
81
+ """Limite de plan o pago requerido (402/403).
82
+
83
+ Cubre ``PLAN_LIMIT``, ``PLAN_REQUIRED`` y ``PAYMENT_REQUIRED``.
84
+ """
85
+
86
+
87
+ class FolyoValidationError(FolyoError):
88
+ """Peticion invalida (400): body, params o headers mal formados."""
89
+
90
+
91
+ class FolyoNotFoundError(FolyoError):
92
+ """Recurso no encontrado (404)."""
93
+
94
+
95
+ class FolyoConflictError(FolyoError):
96
+ """Conflicto (409).
97
+
98
+ Incluye ``IDEMPOTENCY_KEY_CONFLICT`` (misma Idempotency-Key con cuerpo
99
+ distinto) y ``TIMBRAJE_NO_AUTORIZADO``.
100
+ """
101
+
102
+
103
+ class FolyoRateLimitError(FolyoError):
104
+ """Demasiadas peticiones (429).
105
+
106
+ Expone ``retry_after`` (segundos sugeridos por el header ``Retry-After``).
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ message: str,
112
+ *,
113
+ status: Optional[int] = None,
114
+ code: Optional[str] = None,
115
+ request_id: Optional[str] = None,
116
+ retry_after: Optional[float] = None,
117
+ ) -> None:
118
+ super().__init__(message, status=status, code=code, request_id=request_id)
119
+ self.retry_after = retry_after
120
+
121
+
122
+ class FolyoSiiUnavailableError(FolyoError):
123
+ """SII o un servicio aguas abajo no disponible (502/503/504).
124
+
125
+ Cubre ``SII_UNAVAILABLE``, ``SII_TIMEOUT``, ``SII_AUTH_FAILED``,
126
+ ``SII_ERROR``, ``SII_CAPTCHA_REQUIRED``, ``FOLIOS_REQUESTING`` y
127
+ ``QUEUE_UNAVAILABLE``. Expone ``retry_after`` cuando el server lo entrega.
128
+ """
129
+
130
+ def __init__(
131
+ self,
132
+ message: str,
133
+ *,
134
+ status: Optional[int] = None,
135
+ code: Optional[str] = None,
136
+ request_id: Optional[str] = None,
137
+ retry_after: Optional[float] = None,
138
+ ) -> None:
139
+ super().__init__(message, status=status, code=code, request_id=request_id)
140
+ self.retry_after = retry_after
141
+
142
+
143
+ class FolyoConnectionError(FolyoError):
144
+ """No se pudo conectar con la API (timeout de red, DNS, TLS, etc.)."""