ruraldte 0.1.0__tar.gz
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.
- ruraldte-0.1.0/.gitignore +6 -0
- ruraldte-0.1.0/LICENSE +21 -0
- ruraldte-0.1.0/PKG-INFO +68 -0
- ruraldte-0.1.0/README.md +52 -0
- ruraldte-0.1.0/pyproject.toml +27 -0
- ruraldte-0.1.0/ruraldte/__init__.py +19 -0
- ruraldte-0.1.0/ruraldte/client.py +162 -0
- ruraldte-0.1.0/ruraldte/constants.py +22 -0
- ruraldte-0.1.0/ruraldte/errors.py +28 -0
- ruraldte-0.1.0/tests/__init__.py +0 -0
- ruraldte-0.1.0/tests/test_client.py +129 -0
ruraldte-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Comunidad Rural SpA (RuralDTE)
|
|
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.
|
ruraldte-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ruraldte
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK Python del API v1 de RuralDTE — emisión de DTE ante el SII de Chile (emitir = encolar), folios/CAF, webhooks y API keys.
|
|
5
|
+
Project-URL: Homepage, https://ruraldte.cl
|
|
6
|
+
Project-URL: Documentation, https://ruraldte.cl/docs
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: boleta-electronica,chile,dte,factura-electronica,facturacion,sii
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# ruraldte (Python)
|
|
18
|
+
|
|
19
|
+
SDK Python del API v1 de [RuralDTE](https://ruraldte.cl) — emisión de documentos
|
|
20
|
+
tributarios electrónicos (DTE) ante el SII de Chile. Cero dependencias (solo stdlib).
|
|
21
|
+
|
|
22
|
+
## Instalación
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install ruraldte
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
> Cobertura actual (MVP): `documents.emit`, `documents.list`, `documents.states`,
|
|
29
|
+
> folios, emisores, webhooks y keys. El SDK TypeScript
|
|
30
|
+
> ([`@ruraldte/sdk`](https://www.npmjs.com/package/@ruraldte/sdk)) cubre además
|
|
31
|
+
> pagos, cesiones, conectores, intercambio, reportes y más — la API REST los
|
|
32
|
+
> soporta todos aunque este SDK aún no.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import os
|
|
36
|
+
from ruraldte import RuralDte, RuralDteError
|
|
37
|
+
|
|
38
|
+
rd = RuralDte(api_key=os.environ["RURALDTE_API_KEY"])
|
|
39
|
+
|
|
40
|
+
# Emitir = encolar (202 { "status": "queued" }). El SII confirma asíncrono.
|
|
41
|
+
try:
|
|
42
|
+
doc = rd.documents.emit({
|
|
43
|
+
"emisorId": "…",
|
|
44
|
+
"tipo": 33, # factura electrónica
|
|
45
|
+
"ambiente": "cert",
|
|
46
|
+
"receptor": {"rut": "11111111-1", "razonSocial": "ACME SpA"},
|
|
47
|
+
"montos": {"total": 1190, "neto": 1000, "iva": 190}, # CLP enteros
|
|
48
|
+
}, idempotency_key="orden-123")
|
|
49
|
+
print(doc["id"], doc["status"])
|
|
50
|
+
|
|
51
|
+
# Pollear el desenlace (o suscribir un webhook a dte.aceptado/dte.rechazado).
|
|
52
|
+
print(rd.documents.states(doc["id"]))
|
|
53
|
+
except RuralDteError as e:
|
|
54
|
+
print(e.status, e.code, e.detail, "retryable:", e.is_retryable)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Recursos: `documents` (emit/list/states), `folios` (stock/request),
|
|
58
|
+
`emisores` (list/create/upload_credential), `webhooks` (list/register/disable),
|
|
59
|
+
`keys` (list/create/revoke).
|
|
60
|
+
|
|
61
|
+
Config: `RuralDte(api_key, base_url=…, timeout=30)`. La key fija el ambiente
|
|
62
|
+
permitido (`rdte_test_*` → test/cert, `rdte_live_*` → prod).
|
|
63
|
+
|
|
64
|
+
Docs y OpenAPI: <https://ruraldte.cl/docs>
|
|
65
|
+
|
|
66
|
+
Tests: `python3 -m unittest discover -s tests -t .`
|
|
67
|
+
|
|
68
|
+
MIT © Comunidad Rural SpA
|
ruraldte-0.1.0/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# ruraldte (Python)
|
|
2
|
+
|
|
3
|
+
SDK Python del API v1 de [RuralDTE](https://ruraldte.cl) — emisión de documentos
|
|
4
|
+
tributarios electrónicos (DTE) ante el SII de Chile. Cero dependencias (solo stdlib).
|
|
5
|
+
|
|
6
|
+
## Instalación
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install ruraldte
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
> Cobertura actual (MVP): `documents.emit`, `documents.list`, `documents.states`,
|
|
13
|
+
> folios, emisores, webhooks y keys. El SDK TypeScript
|
|
14
|
+
> ([`@ruraldte/sdk`](https://www.npmjs.com/package/@ruraldte/sdk)) cubre además
|
|
15
|
+
> pagos, cesiones, conectores, intercambio, reportes y más — la API REST los
|
|
16
|
+
> soporta todos aunque este SDK aún no.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import os
|
|
20
|
+
from ruraldte import RuralDte, RuralDteError
|
|
21
|
+
|
|
22
|
+
rd = RuralDte(api_key=os.environ["RURALDTE_API_KEY"])
|
|
23
|
+
|
|
24
|
+
# Emitir = encolar (202 { "status": "queued" }). El SII confirma asíncrono.
|
|
25
|
+
try:
|
|
26
|
+
doc = rd.documents.emit({
|
|
27
|
+
"emisorId": "…",
|
|
28
|
+
"tipo": 33, # factura electrónica
|
|
29
|
+
"ambiente": "cert",
|
|
30
|
+
"receptor": {"rut": "11111111-1", "razonSocial": "ACME SpA"},
|
|
31
|
+
"montos": {"total": 1190, "neto": 1000, "iva": 190}, # CLP enteros
|
|
32
|
+
}, idempotency_key="orden-123")
|
|
33
|
+
print(doc["id"], doc["status"])
|
|
34
|
+
|
|
35
|
+
# Pollear el desenlace (o suscribir un webhook a dte.aceptado/dte.rechazado).
|
|
36
|
+
print(rd.documents.states(doc["id"]))
|
|
37
|
+
except RuralDteError as e:
|
|
38
|
+
print(e.status, e.code, e.detail, "retryable:", e.is_retryable)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Recursos: `documents` (emit/list/states), `folios` (stock/request),
|
|
42
|
+
`emisores` (list/create/upload_credential), `webhooks` (list/register/disable),
|
|
43
|
+
`keys` (list/create/revoke).
|
|
44
|
+
|
|
45
|
+
Config: `RuralDte(api_key, base_url=…, timeout=30)`. La key fija el ambiente
|
|
46
|
+
permitido (`rdte_test_*` → test/cert, `rdte_live_*` → prod).
|
|
47
|
+
|
|
48
|
+
Docs y OpenAPI: <https://ruraldte.cl/docs>
|
|
49
|
+
|
|
50
|
+
Tests: `python3 -m unittest discover -s tests -t .`
|
|
51
|
+
|
|
52
|
+
MIT © Comunidad Rural SpA
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ruraldte"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "SDK Python del API v1 de RuralDTE — emisión de DTE ante el SII de Chile (emitir = encolar), folios/CAF, webhooks y API keys."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.9"
|
|
9
|
+
dependencies = [] # solo stdlib (urllib): cero dependencias en runtime
|
|
10
|
+
keywords = ["dte", "sii", "chile", "factura-electronica", "boleta-electronica", "facturacion"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Topic :: Office/Business :: Financial",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://ruraldte.cl"
|
|
20
|
+
Documentation = "https://ruraldte.cl/docs"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["ruraldte"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# ruraldte · SDK Python del API v1 de RuralDTE.
|
|
2
|
+
# from ruraldte import RuralDte
|
|
3
|
+
# rd = RuralDte(api_key=os.environ["RURALDTE_API_KEY"])
|
|
4
|
+
# doc = rd.documents.emit({...}) # 202 {"status": "queued"}
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from .client import DEFAULT_BASE_URL, RuralDte
|
|
8
|
+
from .constants import CERTIFIED_TIPOS, SCOPES, WEBHOOK_EVENTS
|
|
9
|
+
from .errors import RuralDteError
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"RuralDte",
|
|
13
|
+
"RuralDteError",
|
|
14
|
+
"DEFAULT_BASE_URL",
|
|
15
|
+
"CERTIFIED_TIPOS",
|
|
16
|
+
"WEBHOOK_EVENTS",
|
|
17
|
+
"SCOPES",
|
|
18
|
+
]
|
|
19
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# ruraldte · cliente del API v1 (stdlib, sin dependencias)
|
|
3
|
+
# ----------------------------------------------------------------------------
|
|
4
|
+
# Cliente fino sobre urllib. Auth por API key: Authorization: Bearer rdte_*.
|
|
5
|
+
# La key NUNCA se loguea. "emitir = encolar": documents.emit() devuelve 202
|
|
6
|
+
# {"status": "queued"} — pollea documents.states() o usa webhooks para el cierre.
|
|
7
|
+
# Montos: SIEMPRE pesos CLP enteros (sin centavos).
|
|
8
|
+
# ============================================================================
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from .errors import RuralDteError
|
|
18
|
+
|
|
19
|
+
# Backend en vivo. En producción, apúntalo a https://api.ruraldte.cl cuando el
|
|
20
|
+
# dominio enrute /functions/v1/* a las edge functions.
|
|
21
|
+
DEFAULT_BASE_URL = "https://wfaijmddlkrwkqhlmcwm.supabase.co/functions/v1"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RuralDte:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
api_key: str,
|
|
28
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
29
|
+
timeout: float = 30.0,
|
|
30
|
+
opener: Any = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
if not api_key:
|
|
33
|
+
raise RuralDteError(0, "config", "api_key es requerido")
|
|
34
|
+
self._api_key = api_key
|
|
35
|
+
self._base_url = base_url.rstrip("/")
|
|
36
|
+
self._timeout = timeout
|
|
37
|
+
# opener inyectable (tests). Default: el de urllib.
|
|
38
|
+
self._opener = opener or urllib.request.build_opener()
|
|
39
|
+
self.documents = _Documents(self)
|
|
40
|
+
self.folios = _Folios(self)
|
|
41
|
+
self.emisores = _Emisores(self)
|
|
42
|
+
self.webhooks = _Webhooks(self)
|
|
43
|
+
self.keys = _Keys(self)
|
|
44
|
+
|
|
45
|
+
def _request(
|
|
46
|
+
self,
|
|
47
|
+
method: str,
|
|
48
|
+
fn: str,
|
|
49
|
+
path: str = "",
|
|
50
|
+
query: Optional[dict] = None,
|
|
51
|
+
body: Optional[Any] = None,
|
|
52
|
+
idempotency_key: Optional[str] = None,
|
|
53
|
+
) -> Any:
|
|
54
|
+
url = f"{self._base_url}/{fn}{path}"
|
|
55
|
+
if query:
|
|
56
|
+
items = {k: v for k, v in query.items() if v is not None}
|
|
57
|
+
if items:
|
|
58
|
+
url += "?" + urllib.parse.urlencode(items)
|
|
59
|
+
|
|
60
|
+
headers = {"authorization": f"Bearer {self._api_key}"}
|
|
61
|
+
data = None
|
|
62
|
+
if body is not None:
|
|
63
|
+
data = json.dumps(body).encode("utf-8")
|
|
64
|
+
headers["content-type"] = "application/json"
|
|
65
|
+
if idempotency_key:
|
|
66
|
+
headers["idempotency-key"] = idempotency_key
|
|
67
|
+
|
|
68
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
69
|
+
try:
|
|
70
|
+
with self._opener.open(req, timeout=self._timeout) as resp:
|
|
71
|
+
text = resp.read().decode("utf-8")
|
|
72
|
+
return json.loads(text) if text else {}
|
|
73
|
+
except urllib.error.HTTPError as e:
|
|
74
|
+
raw = e.read().decode("utf-8") if e.fp is not None else ""
|
|
75
|
+
try:
|
|
76
|
+
payload = json.loads(raw) if raw else {}
|
|
77
|
+
except ValueError:
|
|
78
|
+
payload = {}
|
|
79
|
+
raise RuralDteError(
|
|
80
|
+
e.code, payload.get("error", "error"), payload.get("detail", str(e.reason))
|
|
81
|
+
) from None
|
|
82
|
+
except urllib.error.URLError as e:
|
|
83
|
+
raise RuralDteError(0, "network_error", str(e.reason)) from None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _Documents:
|
|
87
|
+
def __init__(self, c: "RuralDte") -> None:
|
|
88
|
+
self._c = c
|
|
89
|
+
|
|
90
|
+
def emit(self, input: dict, idempotency_key: Optional[str] = None) -> Any:
|
|
91
|
+
"""Emitir = encolar (202). Pasa idempotency_key para reintentos seguros."""
|
|
92
|
+
return self._c._request("POST", "v1-documents", body=input, idempotency_key=idempotency_key)
|
|
93
|
+
|
|
94
|
+
def list(self, limit: Optional[int] = None) -> Any:
|
|
95
|
+
return self._c._request("GET", "v1-documents", query={"limit": limit})
|
|
96
|
+
|
|
97
|
+
def states(self, id: str) -> Any:
|
|
98
|
+
return self._c._request("GET", "v1-documents", path=f"/{id}/states")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _Folios:
|
|
102
|
+
def __init__(self, c: "RuralDte") -> None:
|
|
103
|
+
self._c = c
|
|
104
|
+
|
|
105
|
+
def stock(self, emisor_id: Optional[str] = None) -> Any:
|
|
106
|
+
return self._c._request("GET", "v1-folios", query={"emisorId": emisor_id})
|
|
107
|
+
|
|
108
|
+
def request(self, emisor_id: str, tipo: int, cantidad: int) -> Any:
|
|
109
|
+
return self._c._request(
|
|
110
|
+
"POST", "v1-folios", path="/request",
|
|
111
|
+
body={"emisorId": emisor_id, "tipo": tipo, "cantidad": cantidad},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def gaps(self, emisor_id: Optional[str] = None) -> Any:
|
|
115
|
+
"""Folios QUEMADOS sin que el SII los viera (huecos a declarar — Declaración de Avance)."""
|
|
116
|
+
return self._c._request("GET", "v1-folios", path="/gaps", query={"emisorId": emisor_id})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _Emisores:
|
|
120
|
+
def __init__(self, c: "RuralDte") -> None:
|
|
121
|
+
self._c = c
|
|
122
|
+
|
|
123
|
+
def list(self) -> Any:
|
|
124
|
+
return self._c._request("GET", "v1-emisores")
|
|
125
|
+
|
|
126
|
+
def create(self, input: dict) -> Any:
|
|
127
|
+
return self._c._request("POST", "v1-emisores", body=input)
|
|
128
|
+
|
|
129
|
+
def upload_credential(self, emisor_id: str, input: dict) -> Any:
|
|
130
|
+
return self._c._request("POST", "v1-emisores", path=f"/{emisor_id}/credentials", body=input)
|
|
131
|
+
|
|
132
|
+
def revoke_credential(self, emisor_id: str) -> Any:
|
|
133
|
+
"""Revoca el certificado activo del emisor (deja de descifrarse para firmar)."""
|
|
134
|
+
return self._c._request("POST", "v1-emisores", path=f"/{emisor_id}/credentials/revoke")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _Webhooks:
|
|
138
|
+
def __init__(self, c: "RuralDte") -> None:
|
|
139
|
+
self._c = c
|
|
140
|
+
|
|
141
|
+
def list(self) -> Any:
|
|
142
|
+
return self._c._request("GET", "v1-webhooks")
|
|
143
|
+
|
|
144
|
+
def register(self, url: str, events: Optional[list] = None) -> Any:
|
|
145
|
+
return self._c._request("POST", "v1-webhooks", body={"url": url, "events": events or []})
|
|
146
|
+
|
|
147
|
+
def disable(self, id: str) -> Any:
|
|
148
|
+
return self._c._request("POST", "v1-webhooks", path=f"/{id}/disable")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _Keys:
|
|
152
|
+
def __init__(self, c: "RuralDte") -> None:
|
|
153
|
+
self._c = c
|
|
154
|
+
|
|
155
|
+
def list(self) -> Any:
|
|
156
|
+
return self._c._request("GET", "v1-keys")
|
|
157
|
+
|
|
158
|
+
def create(self, input: Optional[dict] = None) -> Any:
|
|
159
|
+
return self._c._request("POST", "v1-keys", body=input or {})
|
|
160
|
+
|
|
161
|
+
def revoke(self, id: str) -> Any:
|
|
162
|
+
return self._c._request("POST", "v1-keys", path=f"/{id}/revoke")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# ruraldte · catálogos (espejo del backend, para referencia/validación local)
|
|
3
|
+
# ============================================================================
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
# Tipos de DTE certificados que el API acepta emitir.
|
|
7
|
+
CERTIFIED_TIPOS = (39, 41, 33, 34, 52, 56, 61, 110, 111, 112)
|
|
8
|
+
|
|
9
|
+
# Eventos de webhook (suscríbete a un subconjunto, o a todos con []).
|
|
10
|
+
WEBHOOK_EVENTS = (
|
|
11
|
+
"dte.encolado",
|
|
12
|
+
"dte.aceptado",
|
|
13
|
+
"dte.con_reparos",
|
|
14
|
+
"dte.rechazado",
|
|
15
|
+
"dte.manual_pending",
|
|
16
|
+
"folios.bajos",
|
|
17
|
+
"sii.incidente",
|
|
18
|
+
"sii.recuperado",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Scopes finos que una API key puede pedir ([] = sin restricción).
|
|
22
|
+
SCOPES = ("dte:create", "dte:read", "caf:read", "emisores:read", "webhooks:write")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# ruraldte · errores tipados
|
|
3
|
+
# ----------------------------------------------------------------------------
|
|
4
|
+
# Toda falla del API se levanta como RuralDteError. El envelope del servidor es
|
|
5
|
+
# {"error": <código>, "detail": <mensaje>} + el status HTTP. status=0 = falla de
|
|
6
|
+
# cliente (red/timeout).
|
|
7
|
+
# ============================================================================
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RuralDteError(Exception):
|
|
12
|
+
"""Error del API de RuralDTE."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, status: int, code: str, detail: str) -> None:
|
|
15
|
+
super().__init__(f"[{code}] {detail}")
|
|
16
|
+
self.status = status # HTTP status (0 = red/timeout/cliente)
|
|
17
|
+
self.code = code # código estable del API (campo "error")
|
|
18
|
+
self.detail = detail # mensaje legible (campo "detail")
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def is_auth(self) -> bool:
|
|
22
|
+
"""401/403: key inválida/revocada o sin scope/ambiente."""
|
|
23
|
+
return self.status in (401, 403)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def is_retryable(self) -> bool:
|
|
27
|
+
"""Conviene reintentar: red (0), rate-limit (429) o 5xx."""
|
|
28
|
+
return self.status == 0 or self.status == 429 or self.status >= 500
|
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# ruraldte · tests OFFLINE (opener mockeado, sin red). Corre con:
|
|
3
|
+
# python3 -m unittest discover -s tests -t .
|
|
4
|
+
# ============================================================================
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import unittest
|
|
10
|
+
import urllib.error
|
|
11
|
+
|
|
12
|
+
from ruraldte import RuralDte, RuralDteError
|
|
13
|
+
|
|
14
|
+
BASE = "https://api.test/v1"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FakeResp:
|
|
18
|
+
def __init__(self, body: str) -> None:
|
|
19
|
+
self._b = body.encode("utf-8")
|
|
20
|
+
|
|
21
|
+
def read(self) -> bytes:
|
|
22
|
+
return self._b
|
|
23
|
+
|
|
24
|
+
def __enter__(self) -> "FakeResp":
|
|
25
|
+
return self
|
|
26
|
+
|
|
27
|
+
def __exit__(self, *a: object) -> bool:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FakeOpener:
|
|
32
|
+
"""Reemplaza urllib opener: captura los requests y responde/levanta canned."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, body: str = "{}", error: Exception | None = None) -> None:
|
|
35
|
+
self.body = body
|
|
36
|
+
self.error = error
|
|
37
|
+
self.requests: list = []
|
|
38
|
+
|
|
39
|
+
def open(self, req, timeout=None): # noqa: ANN001
|
|
40
|
+
self.requests.append(req)
|
|
41
|
+
if self.error is not None:
|
|
42
|
+
raise self.error
|
|
43
|
+
return FakeResp(self.body)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestClient(unittest.TestCase):
|
|
47
|
+
def test_requires_api_key(self):
|
|
48
|
+
with self.assertRaises(RuralDteError):
|
|
49
|
+
RuralDte("")
|
|
50
|
+
|
|
51
|
+
def test_emit_post_headers_body(self):
|
|
52
|
+
op = FakeOpener(json.dumps({"id": "doc-1", "folio": 7, "status": "queued"}))
|
|
53
|
+
rd = RuralDte("rdte_test_abc", base_url=BASE, opener=op)
|
|
54
|
+
res = rd.documents.emit({"emisorId": "em-1", "tipo": 33}, idempotency_key="k1")
|
|
55
|
+
self.assertEqual(res["id"], "doc-1")
|
|
56
|
+
req = op.requests[0]
|
|
57
|
+
self.assertEqual(req.method, "POST")
|
|
58
|
+
self.assertEqual(req.full_url, f"{BASE}/v1-documents")
|
|
59
|
+
self.assertEqual(req.headers["Authorization"], "Bearer rdte_test_abc")
|
|
60
|
+
self.assertEqual(req.headers["Idempotency-key"], "k1")
|
|
61
|
+
self.assertIn(b'"emisorId"', req.data)
|
|
62
|
+
|
|
63
|
+
def test_base_url_trailing_slash(self):
|
|
64
|
+
op = FakeOpener(json.dumps({"documents": []}))
|
|
65
|
+
rd = RuralDte("k", base_url=BASE + "/", opener=op)
|
|
66
|
+
rd.documents.list()
|
|
67
|
+
self.assertEqual(op.requests[0].full_url, f"{BASE}/v1-documents")
|
|
68
|
+
|
|
69
|
+
def test_list_limit_query(self):
|
|
70
|
+
op = FakeOpener(json.dumps({"documents": []}))
|
|
71
|
+
rd = RuralDte("k", base_url=BASE, opener=op)
|
|
72
|
+
rd.documents.list(limit=50)
|
|
73
|
+
self.assertEqual(op.requests[0].full_url, f"{BASE}/v1-documents?limit=50")
|
|
74
|
+
|
|
75
|
+
def test_states_path(self):
|
|
76
|
+
op = FakeOpener(json.dumps({"id": "doc-1", "states": []}))
|
|
77
|
+
rd = RuralDte("k", base_url=BASE, opener=op)
|
|
78
|
+
rd.documents.states("doc-1")
|
|
79
|
+
self.assertEqual(op.requests[0].full_url, f"{BASE}/v1-documents/doc-1/states")
|
|
80
|
+
|
|
81
|
+
def test_folios_stock_and_request(self):
|
|
82
|
+
op = FakeOpener(json.dumps({"stock": []}))
|
|
83
|
+
rd = RuralDte("k", base_url=BASE, opener=op)
|
|
84
|
+
rd.folios.stock(emisor_id="em-9")
|
|
85
|
+
self.assertEqual(op.requests[0].full_url, f"{BASE}/v1-folios?emisorId=em-9")
|
|
86
|
+
|
|
87
|
+
op2 = FakeOpener(json.dumps({"tipo": 33, "cantidad": 50}))
|
|
88
|
+
rd2 = RuralDte("k", base_url=BASE, opener=op2)
|
|
89
|
+
rd2.folios.request(emisor_id="em-9", tipo=33, cantidad=50)
|
|
90
|
+
self.assertEqual(op2.requests[0].method, "POST")
|
|
91
|
+
self.assertEqual(op2.requests[0].full_url, f"{BASE}/v1-folios/request")
|
|
92
|
+
|
|
93
|
+
def test_http_error_maps_to_ruraldteerror(self):
|
|
94
|
+
err = urllib.error.HTTPError(
|
|
95
|
+
f"{BASE}/v1-documents", 409, "Conflict", {},
|
|
96
|
+
io.BytesIO(b'{"error":"caf_missing","detail":"no hay CAF"}'),
|
|
97
|
+
)
|
|
98
|
+
rd = RuralDte("k", base_url=BASE, opener=FakeOpener(error=err))
|
|
99
|
+
with self.assertRaises(RuralDteError) as ctx:
|
|
100
|
+
rd.documents.emit({})
|
|
101
|
+
self.assertEqual(ctx.exception.status, 409)
|
|
102
|
+
self.assertEqual(ctx.exception.code, "caf_missing")
|
|
103
|
+
self.assertEqual(ctx.exception.detail, "no hay CAF")
|
|
104
|
+
self.assertFalse(ctx.exception.is_auth)
|
|
105
|
+
self.assertFalse(ctx.exception.is_retryable)
|
|
106
|
+
|
|
107
|
+
def test_auth_and_retryable_flags(self):
|
|
108
|
+
err401 = urllib.error.HTTPError(BASE, 401, "Unauthorized", {}, io.BytesIO(b'{"error":"unauthorized","detail":"x"}'))
|
|
109
|
+
rd = RuralDte("k", base_url=BASE, opener=FakeOpener(error=err401))
|
|
110
|
+
with self.assertRaises(RuralDteError) as c1:
|
|
111
|
+
rd.documents.list()
|
|
112
|
+
self.assertTrue(c1.exception.is_auth)
|
|
113
|
+
|
|
114
|
+
err503 = urllib.error.HTTPError(BASE, 503, "Unavailable", {}, io.BytesIO(b'{"error":"x","detail":"y"}'))
|
|
115
|
+
rd2 = RuralDte("k", base_url=BASE, opener=FakeOpener(error=err503))
|
|
116
|
+
with self.assertRaises(RuralDteError) as c2:
|
|
117
|
+
rd2.documents.list()
|
|
118
|
+
self.assertTrue(c2.exception.is_retryable)
|
|
119
|
+
|
|
120
|
+
def test_network_error(self):
|
|
121
|
+
rd = RuralDte("k", base_url=BASE, opener=FakeOpener(error=urllib.error.URLError("refused")))
|
|
122
|
+
with self.assertRaises(RuralDteError) as ctx:
|
|
123
|
+
rd.documents.list()
|
|
124
|
+
self.assertEqual(ctx.exception.status, 0)
|
|
125
|
+
self.assertEqual(ctx.exception.code, "network_error")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
unittest.main()
|