libredte-lib-sdk 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.
- libredte_lib_sdk/__init__.py +75 -0
- libredte_lib_sdk/billing/__init__.py +31 -0
- libredte_lib_sdk/billing/common.py +29 -0
- libredte_lib_sdk/billing/document/__init__.py +39 -0
- libredte_lib_sdk/billing/document/builder.py +68 -0
- libredte_lib_sdk/billing/document/dispatcher.py +49 -0
- libredte_lib_sdk/billing/document/models.py +169 -0
- libredte_lib_sdk/billing/document/renderer.py +60 -0
- libredte_lib_sdk/billing/enums.py +18 -0
- libredte_lib_sdk/billing/identifier/__init__.py +27 -0
- libredte_lib_sdk/billing/identifier/caf_faker.py +49 -0
- libredte_lib_sdk/billing/identifier/caf_loader.py +31 -0
- libredte_lib_sdk/billing/identifier/caf_validator.py +36 -0
- libredte_lib_sdk/billing/identifier/models.py +37 -0
- libredte_lib_sdk/billing/integration/__init__.py +17 -0
- libredte_lib_sdk/billing/integration/models.py +46 -0
- libredte_lib_sdk/billing/integration/sii_dte.py +83 -0
- libredte_lib_sdk/billing/trading_parties/__init__.py +21 -0
- libredte_lib_sdk/billing/trading_parties/mandatario_manager.py +45 -0
- libredte_lib_sdk/billing/trading_parties/models.py +38 -0
- libredte_lib_sdk/client.py +184 -0
- libredte_lib_sdk/exceptions.py +117 -0
- libredte_lib_sdk/py.typed +0 -0
- libredte_lib_sdk/sdk.py +79 -0
- libredte_lib_sdk-0.1.0.dist-info/METADATA +878 -0
- libredte_lib_sdk-0.1.0.dist-info/RECORD +28 -0
- libredte_lib_sdk-0.1.0.dist-info/WHEEL +4 -0
- libredte_lib_sdk-0.1.0.dist-info/licenses/COPYING +661 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""DTO del componente `billing.identifier`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..common import XmlPayloadMixin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Caf(XmlPayloadMixin):
|
|
13
|
+
"""
|
|
14
|
+
CAF (Código de Autorización de Folios) de un emisor.
|
|
15
|
+
|
|
16
|
+
Expone los campos más usados directamente; `.raw` trae el `dict`
|
|
17
|
+
completo que devuelve la API (incluye además, entre otros, la
|
|
18
|
+
llave RSA del CAF y si está vigente) por si algo más específico
|
|
19
|
+
hace falta más adelante.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
tipo_documento: int
|
|
23
|
+
folio_desde: int
|
|
24
|
+
folio_hasta: int
|
|
25
|
+
xml_base64: str
|
|
26
|
+
raw: dict[str, Any]
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_api(cls, data: dict[str, Any]) -> Caf:
|
|
30
|
+
"""Construye un `Caf` desde el `data` que devuelve la API."""
|
|
31
|
+
return cls(
|
|
32
|
+
tipo_documento=data['tipoDocumento'],
|
|
33
|
+
folio_desde=data['folioDesde'],
|
|
34
|
+
folio_hasta=data['folioHasta'],
|
|
35
|
+
xml_base64=data['xml'],
|
|
36
|
+
raw=data,
|
|
37
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Componente `billing.integration`: integración con el SII."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ...client import ApiClient
|
|
6
|
+
from .models import SendResult, SiiStatus
|
|
7
|
+
from .sii_dte import SiiDteService
|
|
8
|
+
|
|
9
|
+
__all__ = ['IntegrationComponent', 'SendResult', 'SiiDteService', 'SiiStatus']
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IntegrationComponent:
|
|
13
|
+
"""Agrupa los servicios de `billing.integration`."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, client: ApiClient) -> None:
|
|
16
|
+
"""Crea los servicios del componente sobre el `ApiClient` dado."""
|
|
17
|
+
self.sii_dte = SiiDteService(client)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""DTO del componente `billing.integration`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _first_present(data: dict[str, Any], *keys: str) -> Any:
|
|
10
|
+
for key in keys:
|
|
11
|
+
if key in data:
|
|
12
|
+
return data[key]
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class SendResult:
|
|
18
|
+
"""Resultado del envío de un DTE al SII."""
|
|
19
|
+
|
|
20
|
+
track_id: int | None
|
|
21
|
+
raw: dict[str, Any]
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_api(cls, data: dict[str, Any]) -> SendResult:
|
|
25
|
+
"""Construye un `SendResult` desde el `data` que devuelve la API."""
|
|
26
|
+
return cls(
|
|
27
|
+
track_id=_first_present(data, 'trackId', 'track_id'),
|
|
28
|
+
raw=data,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class SiiStatus:
|
|
34
|
+
"""Estado de revisión de un envío al SII."""
|
|
35
|
+
|
|
36
|
+
raw: dict[str, Any]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def status(self) -> Any:
|
|
40
|
+
"""Estado informado por el SII, si la respuesta lo incluyó."""
|
|
41
|
+
return _first_present(self.raw, 'status', 'estado', 'state')
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_api(cls, data: dict[str, Any]) -> SiiStatus:
|
|
45
|
+
"""Construye un `SiiStatus` desde el `data` que devuelve la API."""
|
|
46
|
+
return cls(raw=data)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Servicio para `billing.integration.sii_dte`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ...client import ApiClient
|
|
6
|
+
from ..enums import SiiEnvironment
|
|
7
|
+
from ..trading_parties.models import Certificate
|
|
8
|
+
from .models import SendResult, SiiStatus
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SiiDteService:
|
|
12
|
+
"""
|
|
13
|
+
Envío y consulta de estado ante el SII (`billing.integration.sii_dte`).
|
|
14
|
+
|
|
15
|
+
Solo cubre `sendXmlDocument` y `checkXmlDocumentSentStatus` — el
|
|
16
|
+
worker expone más operaciones (ej. validar un documento ya aceptado,
|
|
17
|
+
o pedir el estado por correo), no incluidas acá.
|
|
18
|
+
|
|
19
|
+
Ambas operaciones autentican contra el SII real con el certificado
|
|
20
|
+
dado; un certificado no válido para el SII (ej. uno autofirmado de
|
|
21
|
+
prueba, como los que genera `TradingPartiesComponent
|
|
22
|
+
.mandatario_manager.create_fake_certificate`) resulta en un
|
|
23
|
+
`LibreDteApiError` (la propia excepción de autenticación del SII,
|
|
24
|
+
accesible vía `.php_class`), no en una excepción distinta.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
_SEND_OPERATION = 'billing.integration.sii_dte::sendXmlDocument'
|
|
28
|
+
_STATUS_OPERATION = (
|
|
29
|
+
'billing.integration.sii_dte::checkXmlDocumentSentStatus'
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def __init__(self, client: ApiClient) -> None:
|
|
33
|
+
"""Guarda el `ApiClient` compartido usado para llamar a la API."""
|
|
34
|
+
self._client = client
|
|
35
|
+
|
|
36
|
+
def send(
|
|
37
|
+
self,
|
|
38
|
+
envelope_xml_base64: str,
|
|
39
|
+
*,
|
|
40
|
+
certificate: Certificate,
|
|
41
|
+
company_rut: str,
|
|
42
|
+
environment: SiiEnvironment = SiiEnvironment.CERTIFICATION,
|
|
43
|
+
compress: bool = False,
|
|
44
|
+
retries: int | None = None,
|
|
45
|
+
) -> SendResult:
|
|
46
|
+
"""
|
|
47
|
+
Envía el sobre `EnvioDTE` al SII y devuelve su Track ID.
|
|
48
|
+
|
|
49
|
+
`envelope_xml_base64` es el XML en base64 del sobre ya armado
|
|
50
|
+
(`Envelope.xml_base64` de `DocumentDispatcherService.create`).
|
|
51
|
+
"""
|
|
52
|
+
data = self._client.call(
|
|
53
|
+
self._SEND_OPERATION,
|
|
54
|
+
request={
|
|
55
|
+
'certificate': certificate.to_payload(),
|
|
56
|
+
'options': {'environment': int(environment)},
|
|
57
|
+
},
|
|
58
|
+
doc=envelope_xml_base64,
|
|
59
|
+
company=company_rut,
|
|
60
|
+
compress=compress,
|
|
61
|
+
retries=retries,
|
|
62
|
+
)
|
|
63
|
+
return SendResult.from_api(data)
|
|
64
|
+
|
|
65
|
+
def check_status(
|
|
66
|
+
self,
|
|
67
|
+
track_id: int,
|
|
68
|
+
*,
|
|
69
|
+
certificate: Certificate,
|
|
70
|
+
company_rut: str,
|
|
71
|
+
environment: SiiEnvironment = SiiEnvironment.CERTIFICATION,
|
|
72
|
+
) -> SiiStatus:
|
|
73
|
+
"""Consulta el estado de un envío al SII mediante su Track ID."""
|
|
74
|
+
data = self._client.call(
|
|
75
|
+
self._STATUS_OPERATION,
|
|
76
|
+
request={
|
|
77
|
+
'certificate': certificate.to_payload(),
|
|
78
|
+
'options': {'environment': int(environment)},
|
|
79
|
+
},
|
|
80
|
+
trackId=track_id,
|
|
81
|
+
company=company_rut,
|
|
82
|
+
)
|
|
83
|
+
return SiiStatus.from_api(data)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Componente `billing.trading_parties`: partes comerciales de un DTE."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ...client import ApiClient
|
|
6
|
+
from .mandatario_manager import MandatarioManagerService
|
|
7
|
+
from .models import Certificate
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'Certificate',
|
|
11
|
+
'MandatarioManagerService',
|
|
12
|
+
'TradingPartiesComponent',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TradingPartiesComponent:
|
|
17
|
+
"""Agrupa los servicios de `billing.trading_parties`."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, client: ApiClient) -> None:
|
|
20
|
+
"""Crea los servicios del componente sobre el `ApiClient` dado."""
|
|
21
|
+
self.mandatario_manager = MandatarioManagerService(client)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Servicio para `billing.trading_parties.mandatario_manager`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ...client import ApiClient
|
|
8
|
+
from .models import Certificate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MandatarioManagerService:
|
|
12
|
+
"""
|
|
13
|
+
Gestión del mandatario (representante) dueño de un certificado.
|
|
14
|
+
|
|
15
|
+
Solo cubre `createFakeCertificate`: generar un certificado ficticio
|
|
16
|
+
para pruebas o desarrollo, sin depender de un certificado real. El
|
|
17
|
+
worker también expone `createFromCertificate` (reconstruir el
|
|
18
|
+
mandatario a partir de un certificado real), no incluida acá.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
_CREATE_FAKE_OPERATION = (
|
|
22
|
+
'billing.trading_parties.mandatario_manager::createFakeCertificate'
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def __init__(self, client: ApiClient) -> None:
|
|
26
|
+
"""Guarda el `ApiClient` compartido usado para llamar a la API."""
|
|
27
|
+
self._client = client
|
|
28
|
+
|
|
29
|
+
def create_fake_certificate(
|
|
30
|
+
self,
|
|
31
|
+
mandatario: dict[str, Any],
|
|
32
|
+
) -> Certificate:
|
|
33
|
+
"""
|
|
34
|
+
Genera un certificado digital ficticio para `mandatario`.
|
|
35
|
+
|
|
36
|
+
Únicamente para pruebas/desarrollo: el certificado resultante es
|
|
37
|
+
autofirmado por una CA de prueba de LibreDTE, no sirve para
|
|
38
|
+
autenticar de verdad ante el SII. `mandatario` es un `dict` con
|
|
39
|
+
`run`/`nombre`/`email`, tal como lo espera la API.
|
|
40
|
+
"""
|
|
41
|
+
data = self._client.call(
|
|
42
|
+
self._CREATE_FAKE_OPERATION,
|
|
43
|
+
mandatario=mandatario,
|
|
44
|
+
)
|
|
45
|
+
return Certificate.from_api(data)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""DTO del componente `billing.trading_parties`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class Certificate:
|
|
11
|
+
"""
|
|
12
|
+
Certificado digital (firma electrónica) de un mandatario/emisor.
|
|
13
|
+
|
|
14
|
+
`to_payload()` arma el `dict` que espera la API en cualquier
|
|
15
|
+
operación que reciba un certificado (`certificate`/`privateKey`).
|
|
16
|
+
`from_api()` hace el camino inverso, para el certificado ficticio
|
|
17
|
+
que devuelve `MandatarioManagerService.create_fake_certificate()`
|
|
18
|
+
(ahí la API usa otros nombres de campo: `cert`/`pkey`).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
certificate: str
|
|
22
|
+
private_key: str
|
|
23
|
+
password: str | None = None
|
|
24
|
+
|
|
25
|
+
def to_payload(self) -> dict[str, Any]:
|
|
26
|
+
"""Payload esperado por la API para un certificado."""
|
|
27
|
+
payload: dict[str, Any] = {
|
|
28
|
+
'certificate': self.certificate,
|
|
29
|
+
'privateKey': self.private_key,
|
|
30
|
+
}
|
|
31
|
+
if self.password is not None:
|
|
32
|
+
payload['password'] = self.password
|
|
33
|
+
return payload
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_api(cls, data: dict[str, Any]) -> Certificate:
|
|
37
|
+
"""Construye un `Certificate` desde un certificado ficticio."""
|
|
38
|
+
return cls(certificate=data['cert'], private_key=data['pkey'])
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Cliente HTTP delgado para la API de LibreDTE Lib (`core.libredte.cl`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Self
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
LibreDteApiError,
|
|
12
|
+
LibreDteConnectionError,
|
|
13
|
+
LibreDteRateLimitError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = 'https://core.libredte.cl/api'
|
|
17
|
+
DEFAULT_TIMEOUT = 30.0
|
|
18
|
+
_HTTP_TOO_MANY_REQUESTS = 429
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ApiClient:
|
|
22
|
+
"""
|
|
23
|
+
Despacha operaciones contra la API de LibreDTE Lib.
|
|
24
|
+
|
|
25
|
+
Traduce un `operation_id` con el formato
|
|
26
|
+
`paquete.componente.worker::operacion` (el mismo que usa
|
|
27
|
+
`libredte-lib-core-dispatcher`) a la ruta HTTP equivalente:
|
|
28
|
+
`POST /{paquete}/{componente}/{worker}/{operacion}`.
|
|
29
|
+
|
|
30
|
+
No sabe nada de DTE ni de reglas de negocio: solo hace la llamada
|
|
31
|
+
HTTP y normaliza la respuesta (el `data` del sobre JSON en éxito,
|
|
32
|
+
los bytes crudos si la respuesta no es JSON, o una excepción tipada
|
|
33
|
+
si la API respondió un error). La orquestación vive en
|
|
34
|
+
`billing/*/*.py`.
|
|
35
|
+
|
|
36
|
+
Por defecto apunta a `core.libredte.cl`. Es configurable (`base_url`
|
|
37
|
+
o la variable de entorno `LIBREDTE_LIB_SDK_BASE_URL`) para apuntar a
|
|
38
|
+
`pro.libredte.cl` u otro ambiente.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
base_url: str | None = None,
|
|
44
|
+
*,
|
|
45
|
+
api_token: str | None = None,
|
|
46
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
47
|
+
http_client: httpx.Client | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Configura la URL base, autenticación opcional y cliente HTTP."""
|
|
50
|
+
self._base_url = (
|
|
51
|
+
base_url
|
|
52
|
+
or os.environ.get('LIBREDTE_LIB_SDK_BASE_URL')
|
|
53
|
+
or DEFAULT_BASE_URL
|
|
54
|
+
).rstrip('/')
|
|
55
|
+
self._api_token = api_token or os.environ.get(
|
|
56
|
+
'LIBREDTE_LIB_SDK_TOKEN',
|
|
57
|
+
)
|
|
58
|
+
self._client = http_client or httpx.Client(timeout=timeout)
|
|
59
|
+
self._owns_client = http_client is None
|
|
60
|
+
|
|
61
|
+
def close(self) -> None:
|
|
62
|
+
"""Cierra la conexión HTTP, si este cliente es dueño de ella."""
|
|
63
|
+
if self._owns_client:
|
|
64
|
+
self._client.close()
|
|
65
|
+
|
|
66
|
+
def __enter__(self) -> Self:
|
|
67
|
+
"""Permite usar `ApiClient` como context manager."""
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
71
|
+
"""Cierra la conexión HTTP al salir del context manager."""
|
|
72
|
+
self.close()
|
|
73
|
+
|
|
74
|
+
def call(self, operation_id: str, **parameters: Any) -> Any:
|
|
75
|
+
"""
|
|
76
|
+
Ejecuta `operation_id` con `parameters` y devuelve su resultado.
|
|
77
|
+
|
|
78
|
+
`parameters` se manda tal cual como el objeto `parameters` del
|
|
79
|
+
body JSON que espera la API (ej. `bag=...`, `request=...`).
|
|
80
|
+
"""
|
|
81
|
+
url = f'{self._base_url}/{self._operation_path(operation_id)}'
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
response = self._client.post(
|
|
85
|
+
url,
|
|
86
|
+
json={'parameters': parameters},
|
|
87
|
+
headers=self._headers(),
|
|
88
|
+
)
|
|
89
|
+
except httpx.RequestError as error:
|
|
90
|
+
raise LibreDteConnectionError(
|
|
91
|
+
f'No fue posible conectar con la API de LibreDTE Lib '
|
|
92
|
+
f'({operation_id}): {error}',
|
|
93
|
+
) from error
|
|
94
|
+
|
|
95
|
+
self._raise_for_rate_limit(operation_id, response)
|
|
96
|
+
return self._parse_response(operation_id, response)
|
|
97
|
+
|
|
98
|
+
def _headers(self) -> dict[str, str]:
|
|
99
|
+
headers = {'Accept': 'application/json'}
|
|
100
|
+
if self._api_token:
|
|
101
|
+
headers['Authorization'] = f'Bearer {self._api_token}'
|
|
102
|
+
return headers
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _operation_path(operation_id: str) -> str:
|
|
106
|
+
try:
|
|
107
|
+
namespace, operation = operation_id.split('::', 1)
|
|
108
|
+
package, component, worker = namespace.split('.', 2)
|
|
109
|
+
except ValueError as error:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f'operation_id invalido: {operation_id!r}. Formato '
|
|
112
|
+
"esperado: 'paquete.componente.worker::operacion'.",
|
|
113
|
+
) from error
|
|
114
|
+
return f'{package}/{component}/{worker}/{operation}'
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _raise_for_rate_limit(
|
|
118
|
+
operation_id: str,
|
|
119
|
+
response: httpx.Response,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Levanta `LibreDteRateLimitError` si la API frenó la solicitud.
|
|
123
|
+
|
|
124
|
+
La API expone `X-RateLimit-Limit`/`X-RateLimit-Remaining` en
|
|
125
|
+
toda respuesta. Se confía solo en el status code (`429`) más los
|
|
126
|
+
headers estándar de HTTP (`Retry-After`, `X-RateLimit-*`), no en
|
|
127
|
+
un shape de body particular.
|
|
128
|
+
"""
|
|
129
|
+
if response.status_code != _HTTP_TOO_MANY_REQUESTS:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
def _int_header(name: str) -> int | None:
|
|
133
|
+
value = response.headers.get(name)
|
|
134
|
+
return int(value) if value and value.isdigit() else None
|
|
135
|
+
|
|
136
|
+
def _retry_after() -> float | None:
|
|
137
|
+
value = response.headers.get('retry-after')
|
|
138
|
+
if value is None:
|
|
139
|
+
return None
|
|
140
|
+
try:
|
|
141
|
+
return float(value)
|
|
142
|
+
except ValueError:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
detail = response.text
|
|
146
|
+
try:
|
|
147
|
+
body = response.json()
|
|
148
|
+
except ValueError:
|
|
149
|
+
body = {}
|
|
150
|
+
if isinstance(body, dict) and body.get('detail'):
|
|
151
|
+
detail = body['detail']
|
|
152
|
+
|
|
153
|
+
raise LibreDteRateLimitError(
|
|
154
|
+
operation_id=operation_id,
|
|
155
|
+
detail=detail,
|
|
156
|
+
retry_after=_retry_after(),
|
|
157
|
+
limit=_int_header('x-ratelimit-limit'),
|
|
158
|
+
remaining=_int_header('x-ratelimit-remaining'),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _parse_response(operation_id: str, response: httpx.Response) -> Any:
|
|
163
|
+
content_type = response.headers.get('content-type', '')
|
|
164
|
+
|
|
165
|
+
if 'application/json' not in content_type:
|
|
166
|
+
if response.is_error:
|
|
167
|
+
raise LibreDteApiError(
|
|
168
|
+
operation_id=operation_id,
|
|
169
|
+
status_code=response.status_code,
|
|
170
|
+
title='Respuesta de error inesperada (no-JSON).',
|
|
171
|
+
detail=response.text,
|
|
172
|
+
)
|
|
173
|
+
return response.content
|
|
174
|
+
|
|
175
|
+
body = response.json()
|
|
176
|
+
|
|
177
|
+
if response.is_success and isinstance(body, dict) and 'data' in body:
|
|
178
|
+
return body['data']
|
|
179
|
+
|
|
180
|
+
raise LibreDteApiError.from_problem_details(
|
|
181
|
+
operation_id=operation_id,
|
|
182
|
+
status_code=response.status_code,
|
|
183
|
+
body=body if isinstance(body, dict) else {},
|
|
184
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Excepciones del SDK de LibreDTE Lib."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LibreDteSdkError(Exception):
|
|
9
|
+
"""Cualquier error propio de `libredte_lib_sdk`."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LibreDteConnectionError(LibreDteSdkError):
|
|
13
|
+
"""No fue posible completar la conexión HTTP con la API."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LibreDteApiError(LibreDteSdkError):
|
|
17
|
+
"""
|
|
18
|
+
La API de LibreDTE Lib respondió un error para una operación.
|
|
19
|
+
|
|
20
|
+
Envuelve el `Problem Details` (RFC 7807) que la API entrega en sus
|
|
21
|
+
respuestas de error: `type`/`title`/`detail`/`instance`/`extensions`.
|
|
22
|
+
`php_class` expone la clase de la excepción PHP real (si la API la
|
|
23
|
+
informó en `extensions.throwable.class`), útil para distinguir
|
|
24
|
+
errores de negocio (ej. `DocumentException`) de errores de
|
|
25
|
+
infraestructura sin tener que parsear `detail`.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
operation_id: str,
|
|
32
|
+
status_code: int,
|
|
33
|
+
title: str,
|
|
34
|
+
detail: str = '',
|
|
35
|
+
problem_type: str = 'about:blank',
|
|
36
|
+
instance: str = '',
|
|
37
|
+
extensions: dict[str, Any] | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Guarda el detalle del error tal como lo informó la API."""
|
|
40
|
+
self.operation_id = operation_id
|
|
41
|
+
self.status_code = status_code
|
|
42
|
+
self.title = title
|
|
43
|
+
self.detail = detail
|
|
44
|
+
self.problem_type = problem_type
|
|
45
|
+
self.instance = instance or operation_id
|
|
46
|
+
self.extensions = extensions or {}
|
|
47
|
+
message = f'[{operation_id}] {title}'
|
|
48
|
+
if detail:
|
|
49
|
+
message = f'{message}: {detail}'
|
|
50
|
+
super().__init__(message)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def php_class(self) -> str | None:
|
|
54
|
+
"""Clase PHP real detrás del error, si la API la informó."""
|
|
55
|
+
throwable = self.extensions.get('throwable')
|
|
56
|
+
if isinstance(throwable, dict):
|
|
57
|
+
return throwable.get('class')
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_problem_details(
|
|
62
|
+
cls,
|
|
63
|
+
*,
|
|
64
|
+
operation_id: str,
|
|
65
|
+
status_code: int,
|
|
66
|
+
body: dict[str, Any],
|
|
67
|
+
) -> LibreDteApiError:
|
|
68
|
+
"""Construye la excepción a partir de un body Problem Details."""
|
|
69
|
+
return cls(
|
|
70
|
+
operation_id=operation_id,
|
|
71
|
+
status_code=status_code,
|
|
72
|
+
title=body.get('title') or 'Error desconocido de la API.',
|
|
73
|
+
detail=body.get('detail') or '',
|
|
74
|
+
problem_type=body.get('type') or 'about:blank',
|
|
75
|
+
instance=body.get('instance') or operation_id,
|
|
76
|
+
extensions=body.get('extensions'),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LibreDteRateLimitError(LibreDteApiError):
|
|
81
|
+
"""
|
|
82
|
+
La API rechazó la llamada por exceso de solicitudes (HTTP 429).
|
|
83
|
+
|
|
84
|
+
`retry_after` trae el valor del header `Retry-After` en segundos
|
|
85
|
+
(`None` si la API no lo informó). `limit`/`remaining` traen
|
|
86
|
+
`X-RateLimit-Limit`/`X-RateLimit-Remaining` cuando la API los
|
|
87
|
+
informa. No hay retry/backoff automático: quien llame decide si
|
|
88
|
+
reintentar, y cuándo, con esta información.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
operation_id: str,
|
|
95
|
+
status_code: int = 429,
|
|
96
|
+
title: str = 'Límite de solicitudes excedido.',
|
|
97
|
+
detail: str = '',
|
|
98
|
+
problem_type: str = 'about:blank',
|
|
99
|
+
instance: str = '',
|
|
100
|
+
extensions: dict[str, Any] | None = None,
|
|
101
|
+
retry_after: float | None = None,
|
|
102
|
+
limit: int | None = None,
|
|
103
|
+
remaining: int | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Guarda el detalle del error de rate limit, con sus headers."""
|
|
106
|
+
super().__init__(
|
|
107
|
+
operation_id=operation_id,
|
|
108
|
+
status_code=status_code,
|
|
109
|
+
title=title,
|
|
110
|
+
detail=detail,
|
|
111
|
+
problem_type=problem_type,
|
|
112
|
+
instance=instance,
|
|
113
|
+
extensions=extensions,
|
|
114
|
+
)
|
|
115
|
+
self.retry_after = retry_after
|
|
116
|
+
self.limit = limit
|
|
117
|
+
self.remaining = remaining
|
|
File without changes
|
libredte_lib_sdk/sdk.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Punto de entrada único del SDK: `LibreDTE`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Self
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .billing import BillingPackage
|
|
10
|
+
from .client import DEFAULT_TIMEOUT, ApiClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LibreDTE:
|
|
14
|
+
"""
|
|
15
|
+
Fachada del SDK: agrupa los paquetes de la API (por ahora, `billing`).
|
|
16
|
+
|
|
17
|
+
Uso típico (ver `README.rst` para el detalle de cada DTO):
|
|
18
|
+
|
|
19
|
+
.. code-block:: python
|
|
20
|
+
|
|
21
|
+
sdk = LibreDTE()
|
|
22
|
+
doc, ident, tp, sii = (
|
|
23
|
+
sdk.billing.document,
|
|
24
|
+
sdk.billing.identifier,
|
|
25
|
+
sdk.billing.trading_parties,
|
|
26
|
+
sdk.billing.integration,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
borrador = doc.builder.build_draft(parsed_data)
|
|
30
|
+
|
|
31
|
+
caf = ident.caf_faker.create(emisor, codigo_documento=33)
|
|
32
|
+
certificate = tp.mandatario_manager.create_fake_certificate(
|
|
33
|
+
mandatario,
|
|
34
|
+
)
|
|
35
|
+
documento = doc.builder.build_signed(
|
|
36
|
+
parsed_data, caf_xml=caf.xml_base64, certificate=certificate,
|
|
37
|
+
)
|
|
38
|
+
sobre = doc.dispatcher.create(
|
|
39
|
+
documento.xml_base64, certificate=certificate, emisor=emisor_dto,
|
|
40
|
+
)
|
|
41
|
+
envio = sii.sii_dte.send(
|
|
42
|
+
sobre.xml_base64, certificate=certificate, company_rut=rut,
|
|
43
|
+
)
|
|
44
|
+
estado = sii.sii_dte.check_status(
|
|
45
|
+
envio.track_id, certificate=certificate, company_rut=rut,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
pdf = doc.renderer.render(documento.xml_base64).first
|
|
49
|
+
pdf_bytes = pdf.content_bytes
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
base_url: str | None = None,
|
|
55
|
+
*,
|
|
56
|
+
api_token: str | None = None,
|
|
57
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
58
|
+
http_client: httpx.Client | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Crea el `ApiClient` compartido y los paquetes que lo usan."""
|
|
61
|
+
self._client = ApiClient(
|
|
62
|
+
base_url,
|
|
63
|
+
api_token=api_token,
|
|
64
|
+
timeout=timeout,
|
|
65
|
+
http_client=http_client,
|
|
66
|
+
)
|
|
67
|
+
self.billing = BillingPackage(self._client)
|
|
68
|
+
|
|
69
|
+
def close(self) -> None:
|
|
70
|
+
"""Cierra la conexión HTTP subyacente."""
|
|
71
|
+
self._client.close()
|
|
72
|
+
|
|
73
|
+
def __enter__(self) -> Self:
|
|
74
|
+
"""Permite usar `LibreDTE` como context manager."""
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
78
|
+
"""Cierra la conexión HTTP al salir del context manager."""
|
|
79
|
+
self.close()
|