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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ dist/
6
+ build/
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.
@@ -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
@@ -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()