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.
@@ -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
@@ -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()