xhandler 6.0.0a1__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,280 @@
1
+ """Sends signed UBL documents to SUNAT: the SOAP services (billService,
2
+ consult and validation) and the REST API of the guias de remision with its
3
+ OAuth token.
4
+
5
+ The xhandler core builds every request and reads every answer (CDR, tickets,
6
+ faults); this package only sends them over HTTP with httpx.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import threading
13
+ import time
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from xhandler._core import ErrorKind, XhandlerError, invoke
19
+ from xhandler.xsender.models import (
20
+ BILL_CONSULT_SERVICE_URL,
21
+ BILL_VALID_SERVICE_URL,
22
+ CDR,
23
+ ConsultDocument,
24
+ ConsultResult,
25
+ ConsultStatus,
26
+ Credentials,
27
+ Fault,
28
+ File,
29
+ NoCDR,
30
+ SendResult,
31
+ Status,
32
+ Target,
33
+ URLs,
34
+ ValidateCDP,
35
+ VerifyResult,
36
+ )
37
+
38
+ __all__ = [
39
+ "BILL_CONSULT_SERVICE_URL",
40
+ "BILL_VALID_SERVICE_URL",
41
+ "CDR",
42
+ "ConsultDocument",
43
+ "ConsultResult",
44
+ "ConsultStatus",
45
+ "Credentials",
46
+ "Fault",
47
+ "File",
48
+ "FileSender",
49
+ "NoCDR",
50
+ "SendResult",
51
+ "Status",
52
+ "SunatClient",
53
+ "Target",
54
+ "URLs",
55
+ "ValidateCDP",
56
+ "VerifyResult",
57
+ ]
58
+
59
+
60
+ _RATE_LIMIT_PAUSES = (1, 2)
61
+ """Seconds before retrying a request SUNAT's front proxy refused."""
62
+
63
+
64
+ class SunatClient:
65
+ """Sends the requests to SUNAT and keeps the OAuth tokens of the guias de
66
+ remision. Share one between the FileSenders of several companies."""
67
+
68
+ def __init__(
69
+ self, http: httpx.Client | None = None, security_api_url: str | None = None
70
+ ) -> None:
71
+ """``http`` defaults to a client with a 30s connection timeout and a
72
+ 120s timeout for the rest. ``security_api_url`` is where the tokens of
73
+ the guias are requested (SUNAT's by default)."""
74
+ self._http = http or httpx.Client(timeout=httpx.Timeout(120, connect=30))
75
+ self._security_api_url = security_api_url
76
+ self._lock = threading.Lock() # guards _tokens
77
+ self._tokens: dict[str, tuple[str, float]] = {}
78
+ # Held while a token is requested, so concurrent calls don't revoke
79
+ # each other's tokens
80
+ self._requesting = threading.Lock()
81
+
82
+ def close(self) -> None:
83
+ """Closes the HTTP connections."""
84
+ self._http.close()
85
+
86
+ def __enter__(self) -> SunatClient:
87
+ return self
88
+
89
+ def __exit__(self, *_: object) -> None:
90
+ self.close()
91
+
92
+ def get_status(
93
+ self, url: str, document: ConsultDocument, credentials: Credentials
94
+ ) -> ConsultResult:
95
+ """billConsultService getStatus: the status of a document already sent."""
96
+ call = {"get_status": {"url": url, "document": _json(document)}}
97
+ return ConsultResult.model_validate(self._exchange(call, False, credentials))
98
+
99
+ def get_status_cdr(
100
+ self, url: str, document: ConsultDocument, credentials: Credentials
101
+ ) -> ConsultResult:
102
+ """billConsultService getStatusCdr: the CDR of a document already sent."""
103
+ call = {"get_status_cdr": {"url": url, "document": _json(document)}}
104
+ return ConsultResult.model_validate(self._exchange(call, False, credentials))
105
+
106
+ def validate_cdp(self, url: str, cdp: ValidateCDP, credentials: Credentials) -> ConsultResult:
107
+ """billValidService validaCDPcriterios."""
108
+ call = {"validate_cdp": {"url": url, "cdp": _json(cdp)}}
109
+ return ConsultResult.model_validate(self._exchange(call, False, credentials))
110
+
111
+ def validate_file(self, url: str, file: File, credentials: Credentials) -> ConsultResult:
112
+ """billValidService verificaCPEarchivo."""
113
+ call = {"validate_file": {"url": url, "file": _json(file)}}
114
+ return ConsultResult.model_validate(self._exchange(call, False, credentials))
115
+
116
+ def _exchange(self, call: Any, rest: bool, credentials: Credentials) -> Any:
117
+ """Sends a call and returns its answer. SUNAT revokes the tokens of the
118
+ guias before they expire: a REST call rejected with HTTP 401 is retried
119
+ once with a new token. A SOAP call refused by SUNAT's rate limiting is
120
+ retried after a pause."""
121
+ if rest:
122
+ token = self._token(credentials)
123
+ response = self._send(self._request(call, credentials, token))
124
+ if response.status_code == 401 and credentials.token is None:
125
+ self._forget(credentials, token)
126
+ response = self._send(self._request(call, credentials, self._token(credentials)))
127
+ return _parse(call, response)
128
+
129
+ request = self._request(call, credentials, None)
130
+ for pause in (*_RATE_LIMIT_PAUSES, None):
131
+ try:
132
+ return _parse(call, self._send(request))
133
+ except XhandlerError as error:
134
+ if error.kind is not ErrorKind.RATE_LIMITED or pause is None:
135
+ raise
136
+ time.sleep(pause)
137
+
138
+ @staticmethod
139
+ def _request(call: Any, credentials: Credentials, token: str | None) -> Any:
140
+ return invoke("request", {"call": call, "credentials": _json(credentials), "token": token})
141
+
142
+ def _send(self, request: dict[str, Any]) -> httpx.Response:
143
+ def send() -> httpx.Response:
144
+ return self._http.request(
145
+ request["method"],
146
+ request["url"],
147
+ headers=dict(request["headers"]),
148
+ content=request["body"],
149
+ )
150
+
151
+ try:
152
+ return send()
153
+ except (httpx.ConnectError, httpx.ConnectTimeout):
154
+ return send() # once more: the request never reached SUNAT
155
+
156
+ def _token(self, credentials: Credentials) -> str:
157
+ """The token of the credentials, a cached one (reused until a minute
158
+ before it expires), or a new one."""
159
+ if credentials.token is not None:
160
+ return credentials.token
161
+ key = _token_key(credentials)
162
+ cached = self._cached_token(key)
163
+ if cached:
164
+ return cached
165
+ with self._requesting:
166
+ cached = self._cached_token(key)
167
+ if cached:
168
+ return cached
169
+ return self._request_token(key, credentials)
170
+
171
+ def _cached_token(self, key: str) -> str | None:
172
+ with self._lock:
173
+ cached = self._tokens.get(key)
174
+ return cached[0] if cached and time.monotonic() < cached[1] else None
175
+
176
+ def _request_token(self, key: str, credentials: Credentials) -> str:
177
+ call = {"token": {"url": self._security_api_url}}
178
+ token = self._exchange(call, False, credentials)["token"]
179
+ with self._lock:
180
+ reuse_before = time.monotonic() + max(token["expires_in"] - 60, 0)
181
+ self._tokens[key] = (token["access_token"], reuse_before)
182
+ return str(token["access_token"])
183
+
184
+ def _forget(self, credentials: Credentials, rejected: str) -> None:
185
+ """Forgets the rejected token, unless another call already replaced it."""
186
+ key = _token_key(credentials)
187
+ with self._lock:
188
+ if self._tokens.get(key, ("",))[0] == rejected:
189
+ del self._tokens[key]
190
+
191
+
192
+ def _parse(call: Any, response: httpx.Response) -> Any:
193
+ return invoke(
194
+ "parse",
195
+ {
196
+ "call": call,
197
+ "status": response.status_code,
198
+ "headers": list(response.headers.multi_items()),
199
+ "body": response.text,
200
+ },
201
+ )
202
+
203
+
204
+ def _token_key(credentials: Credentials) -> str:
205
+ return f"{credentials.client_id or ''}:{credentials.username}"
206
+
207
+
208
+ def _json(model: Any) -> Any:
209
+ return model.model_dump(mode="json")
210
+
211
+
212
+ def _b64(data: bytes) -> str:
213
+ return base64.b64encode(data).decode()
214
+
215
+
216
+ _default_client: SunatClient | None = None
217
+ _default_client_lock = threading.Lock()
218
+
219
+
220
+ class FileSender:
221
+ """Sends the signed documents of one company. The document decides
222
+ everything else: the file name, the service and the operation."""
223
+
224
+ def __init__(
225
+ self, urls: URLs, credentials: Credentials, client: SunatClient | None = None
226
+ ) -> None:
227
+ self.urls = urls
228
+ self.credentials = credentials
229
+ self._client = client
230
+
231
+ @property
232
+ def client(self) -> SunatClient:
233
+ global _default_client
234
+ if self._client is None:
235
+ with _default_client_lock:
236
+ _default_client = _default_client or SunatClient()
237
+ self._client = _default_client
238
+ return self._client
239
+
240
+ def send_file(self, xml: bytes | str) -> SendResult:
241
+ """Zips the signed XML and sends it to its SUNAT service."""
242
+ prepared = invoke("prepare_send_file", {"xml": _b64(_bytes(xml)), "urls": _json(self.urls)})
243
+ return self._send(prepared)
244
+
245
+ def send_pack(self, name: str, files: list[bytes | str]) -> SendResult:
246
+ """Sends several invoices in one zip called ``name`` (sendPack)."""
247
+ prepared = invoke(
248
+ "prepare_send_pack",
249
+ {"name": name, "files": [_b64(_bytes(f)) for f in files], "urls": _json(self.urls)},
250
+ )
251
+ return self._send(prepared)
252
+
253
+ def _send(self, prepared: dict[str, Any]) -> SendResult:
254
+ target = prepared["send_target"]
255
+ call = {"send_file": {"target": target, "file": prepared["file"]}}
256
+ answer = self.client._exchange(call, target["protocol"] == "rest", self.credentials)
257
+ return SendResult.model_validate(
258
+ {
259
+ **answer,
260
+ "send_target": target,
261
+ "verify_ticket_target": prepared["verify_ticket_target"],
262
+ }
263
+ )
264
+
265
+ def verify_ticket(self, target: Target, ticket: str) -> VerifyResult:
266
+ """Asks the service ``target`` for the status of a ticket."""
267
+ call = {"verify_ticket": {"target": _json(target), "ticket": ticket}}
268
+ answer = self.client._exchange(call, target.protocol == "rest", self.credentials)
269
+ return VerifyResult.model_validate(answer)
270
+
271
+ def verify_file_ticket(self, xml: bytes | str, ticket: str) -> VerifyResult:
272
+ """:meth:`verify_ticket` with the service of the sent document."""
273
+ target = invoke(
274
+ "verify_ticket_target", {"xml": _b64(_bytes(xml)), "urls": _json(self.urls)}
275
+ )
276
+ return self.verify_ticket(Target.model_validate(target), ticket)
277
+
278
+
279
+ def _bytes(xml: bytes | str) -> bytes:
280
+ return xml.encode() if isinstance(xml, str) else xml
@@ -0,0 +1,197 @@
1
+ """Inputs and answers of the SUNAT services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from collections.abc import Iterator
7
+ from enum import Enum
8
+ from typing import Annotated, Any, Literal
9
+
10
+ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, PlainSerializer
11
+
12
+
13
+ def _from_base64(value: object) -> object:
14
+ return base64.b64decode(value) if isinstance(value, str) else value
15
+
16
+
17
+ Bytes = Annotated[
18
+ bytes,
19
+ BeforeValidator(_from_base64),
20
+ PlainSerializer(lambda b: base64.b64encode(b).decode(), return_type=str, when_used="json"),
21
+ ]
22
+ """Raw bytes in Python, base64 in the JSON of the core."""
23
+
24
+
25
+ class _Model(BaseModel):
26
+ model_config = ConfigDict(extra="forbid", frozen=True)
27
+
28
+
29
+ class URLs(_Model):
30
+ """The SUNAT services where a company sends its documents."""
31
+
32
+ invoice: str
33
+ perception_retention: str
34
+ despatch: str = ""
35
+ """The REST API of the guias de remision; SUNAT has no beta for it."""
36
+
37
+ @classmethod
38
+ def beta(cls) -> URLs:
39
+ """SUNAT beta (testing) services; without guias de remision."""
40
+ return cls(
41
+ invoice="https://e-beta.sunat.gob.pe/ol-ti-itcpfegem-beta/billService",
42
+ perception_retention="https://e-beta.sunat.gob.pe/ol-ti-itemision-otroscpe-gem-beta/billService",
43
+ )
44
+
45
+ @classmethod
46
+ def production(cls) -> URLs:
47
+ """SUNAT production services."""
48
+ return cls(
49
+ invoice="https://e-factura.sunat.gob.pe/ol-ti-itcpfegem/billService",
50
+ perception_retention="https://e-factura.sunat.gob.pe/ol-ti-itemision-otroscpe-gem/billService",
51
+ despatch="https://api-cpe.sunat.gob.pe/v1/contribuyente/gem",
52
+ )
53
+
54
+
55
+ BILL_CONSULT_SERVICE_URL = "https://e-factura.sunat.gob.pe/ol-it-wsconscpegem/billConsultService"
56
+ """Looks up documents already sent (get_status, get_status_cdr)."""
57
+ BILL_VALID_SERVICE_URL = "https://e-factura.sunat.gob.pe/ol-it-wsconsvalidcpe/billValidService"
58
+ """Validates documents (validate_cdp, validate_file)."""
59
+
60
+
61
+ class Credentials(_Model):
62
+ """Credentials of the company. They never show their secrets."""
63
+
64
+ model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True)
65
+
66
+ username: str
67
+ """SOL user, e.g. "20123456789MODDATOS"."""
68
+ password: str
69
+ client_id: str | None = None
70
+ """API client of the REST services (guias de remision), from SUNAT's SOL menu."""
71
+ client_secret: str | None = None
72
+ token: str | None = Field(default=None, exclude=True)
73
+ """A token of the guias obtained elsewhere; used as is when set."""
74
+
75
+ def __repr_args__(self) -> Iterator[tuple[str | None, Any]]:
76
+ """For repr and str: only the username."""
77
+ yield "username", self.username
78
+
79
+
80
+ class Target(_Model):
81
+ """A SUNAT service for a file or a ticket."""
82
+
83
+ protocol: Literal["soap", "rest"]
84
+ url: str
85
+ action: Literal["bill", "summary", "pack"] | None = None
86
+ """The operation of the SOAP files."""
87
+
88
+
89
+ class File(_Model):
90
+ """A zip for SUNAT."""
91
+
92
+ filename: str
93
+ zip: Bytes
94
+
95
+
96
+ class ConsultDocument(_Model):
97
+ """A document already sent to SUNAT."""
98
+
99
+ ruc: str
100
+ tipo_comprobante: str
101
+ """Catalog1, e.g. "01"."""
102
+ serie: str
103
+ numero: int
104
+
105
+
106
+ class ValidateCDP(ConsultDocument):
107
+ """The criteria of validaCDPcriterios."""
108
+
109
+ tipo_documento_identidad_receptor: str
110
+ """Catalog6."""
111
+ numero_documento_identidad_receptor: str
112
+ fecha_emision: str
113
+ """dd/MM/yyyy."""
114
+ importe_total: str
115
+ numero_autorizacion: str | None = None
116
+
117
+
118
+ class Status(str, Enum):
119
+ """The SUNAT verdict of a document; prints as SUNAT says it, e.g. ACEPTADO."""
120
+
121
+ ACCEPTED = "ACEPTADO"
122
+ REJECTED = "RECHAZADO"
123
+ EXCEPTION = "EXCEPCION"
124
+ IN_PROGRESS = "EN_PROCESO"
125
+ UNKNOWN = "UNKNOWN"
126
+
127
+ def __str__(self) -> str:
128
+ return self.value
129
+
130
+
131
+ class CDR(_Model):
132
+ """The receipt SUNAT gives back (Constancia de Recepcion)."""
133
+
134
+ zip: Bytes
135
+ response_code: str
136
+ description: str
137
+ notes: list[str]
138
+ status: Status
139
+ status_code: str | None = None
140
+ """The ticket status, only when verifying a ticket."""
141
+
142
+
143
+ class Fault(_Model):
144
+ """An error answered by SUNAT."""
145
+
146
+ code: str
147
+ """SUNAT's code as given: the SOAP faultcode (e.g. "soap-env:Client.0111") or
148
+ the error code of a REST answer (e.g. "1033")."""
149
+ sunat_code: int | None = None
150
+ """The number in ``code`` (111), when it has one."""
151
+ http_status: int | None = None
152
+ """The status of the answer, e.g. 500 for SOAP faults or 422 for REST errors."""
153
+ message: str
154
+ status: Status
155
+
156
+
157
+ class NoCDR(_Model):
158
+ """A ticket status without CDR, e.g. "98" (in progress)."""
159
+
160
+ status_code: str
161
+ status: Status
162
+
163
+
164
+ class SendResult(_Model):
165
+ """The answer to a sent file: a CDR, a ticket to verify later (summaries,
166
+ voided documents, packs, guias) or a fault."""
167
+
168
+ cdr: CDR | None = None
169
+ ticket: str | None = None
170
+ fault: Fault | None = None
171
+ send_target: Target
172
+ verify_ticket_target: Target | None = None
173
+ """Where the ticket is verified."""
174
+
175
+
176
+ class VerifyResult(_Model):
177
+ """The answer to a ticket: a CDR, no CDR yet (e.g. in progress) or a fault."""
178
+
179
+ cdr: CDR | None = None
180
+ no_cdr: NoCDR | None = None
181
+ fault: Fault | None = None
182
+
183
+
184
+ class ConsultStatus(_Model):
185
+ """e.g. "0001" (accepted) for get_status, or "0" for validate_cdp."""
186
+
187
+ status_code: str
188
+ status_message: str | None = None
189
+ cdr_zip: Bytes | None = None
190
+ """The CDR of get_status_cdr."""
191
+
192
+
193
+ class ConsultResult(_Model):
194
+ """The answer of the consult and validation services."""
195
+
196
+ status: ConsultStatus | None = None
197
+ fault: Fault | None = None
@@ -0,0 +1,48 @@
1
+ """Signs UBL documents with the enveloped RSA-SHA256 XML signature SUNAT
2
+ requires. The signature is placed in the first empty ``ext:ExtensionContent``
3
+ of the document, as rendered by :mod:`xhandler.xbuilder`."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ from typing import Any, overload
9
+
10
+ from xhandler._core import invoke
11
+
12
+
13
+ class SigningKey:
14
+ """The private key and certificate that sign the documents. The key is
15
+ checked when signing. It never shows its secrets in ``repr``."""
16
+
17
+ __slots__ = ("_key",)
18
+
19
+ def __init__(self, key: dict[str, Any]) -> None:
20
+ self._key = key
21
+
22
+ @classmethod
23
+ def from_pem(cls, private_key: str, certificate: str) -> SigningKey:
24
+ """A PEM private key (PKCS#8 or PKCS#1) and a PEM X.509 certificate."""
25
+ return cls({"pem": {"private_key": private_key, "certificate": certificate}})
26
+
27
+ @classmethod
28
+ def from_pkcs12(cls, der: bytes, password: str) -> SigningKey:
29
+ """The signing key stored in a PKCS#12 keystore (.pfx, .p12)."""
30
+ return cls({"pkcs12": {"der": base64.b64encode(der).decode(), "password": password}})
31
+
32
+ def __repr__(self) -> str:
33
+ return "SigningKey(<redacted>)"
34
+
35
+
36
+ @overload
37
+ def sign(xml: bytes, key: SigningKey) -> bytes: ...
38
+ @overload
39
+ def sign(xml: str, key: SigningKey) -> str: ...
40
+ def sign(xml: bytes | str, key: SigningKey) -> bytes | str:
41
+ """Returns the XML with its signature, as bytes for bytes and as text for
42
+ text. Bytes may be UTF-8 or ISO-8859-1 (as their XML declaration says);
43
+ the signed bytes are UTF-8."""
44
+ data = xml.encode() if isinstance(xml, str) else xml
45
+ signed = base64.b64decode(
46
+ invoke("sign", {"xml": base64.b64encode(data).decode(), "key": key._key})
47
+ )
48
+ return signed.decode() if isinstance(xml, str) else signed
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.5
2
+ Name: xhandler
3
+ Version: 6.0.0-alpha.1
4
+ Summary: Create, sign and send the electronic documents (UBL XML) of SUNAT Peru
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: pydantic>=2.7
9
+ Requires-Dist: wasmtime>=30
10
+ Description-Content-Type: text/markdown
11
+
12
+ # xhandler-python
13
+
14
+ [![License](https://img.shields.io/github/license/project-openubl/xhandler-python?logo=apache)](https://www.apache.org/licenses/LICENSE-2.0)
15
+ [![CI](https://github.com/project-openubl/xhandler-python/actions/workflows/ci.yml/badge.svg)](https://github.com/project-openubl/xhandler-python/actions/workflows/ci.yml)
16
+
17
+ Crea, firma y envía los comprobantes electrónicos (XML UBL) de la SUNAT del Perú desde Python. Sin
18
+ extensiones nativas que compilar.
19
+
20
+ ## Instalación
21
+
22
+ Hasta la primera versión en PyPI, instálalo desde GitHub:
23
+
24
+ ```shell
25
+ pip install "xhandler @ git+https://github.com/project-openubl/xhandler-python"
26
+ # o
27
+ uv add "xhandler @ git+https://github.com/project-openubl/xhandler-python"
28
+ ```
29
+
30
+ Python 3.10 o superior, en Linux, macOS y Windows.
31
+
32
+ ## Uso
33
+
34
+ ```python
35
+ from decimal import Decimal
36
+ from pathlib import Path
37
+
38
+ from xhandler import xbuilder
39
+ from xhandler.xsender import Credentials, FileSender, URLs
40
+ from xhandler.xsigner import SigningKey, sign
41
+
42
+ # Crear
43
+ invoice = xbuilder.Invoice(
44
+ serie_numero="F001-1",
45
+ proveedor=xbuilder.Proveedor(ruc="12345678912", razon_social="Softgreen S.A.C."),
46
+ cliente=xbuilder.Cliente(
47
+ tipo_documento_identidad="6", # RUC (Catalog6)
48
+ numero_documento_identidad="12121212121",
49
+ nombre="Carlos Feria",
50
+ ),
51
+ detalles=[xbuilder.Detalle(descripcion="Item", cantidad=Decimal(2), precio=Decimal("100.00"))],
52
+ )
53
+ xml = xbuilder.build(invoice) # fecha de hoy en Perú y tasas vigentes
54
+
55
+ # Firmar
56
+ key = SigningKey.from_pkcs12(Path("certificado.pfx").read_bytes(), "password")
57
+ signed = sign(xml.encode(), key)
58
+
59
+ # Enviar
60
+ sender = FileSender(URLs.beta(), Credentials(username="12345678959MODDATOS", password="MODDATOS"))
61
+ result = sender.send_file(signed)
62
+ if result.cdr:
63
+ # ACEPTADO La Factura numero F001-1, ha sido aceptada
64
+ print(result.cdr.status, result.cdr.description)
65
+ elif result.ticket: # resúmenes, bajas y guías: consulta el ticket después
66
+ print(sender.verify_file_ticket(signed, result.ticket))
67
+ else:
68
+ print(result.fault.status, result.fault.code, result.fault.message)
69
+ ```
70
+
71
+ - **Crear**: facturas, boletas, notas de crédito y débito, guías de remisión, percepciones,
72
+ retenciones, resúmenes diarios, bajas y reversiones. `xbuilder.enrich` devuelve el documento con
73
+ los valores calculados (impuestos, totales, leyendas) en lugar del XML. `xbuilder.Defaults`
74
+ cambia la fecha de emisión o las tasas.
75
+ - **Firmar**: `SigningKey.from_pem(llave_privada, certificado)` para archivos PEM. `sign` devuelve
76
+ texto si recibe texto y bytes si recibe bytes.
77
+ - **Enviar**: `send_pack` envía varias facturas en un zip. `SunatClient` también tiene los
78
+ servicios de consulta y validación (`get_status`, `get_status_cdr`, `validate_cdp`,
79
+ `validate_file`). Si envías por varias empresas, comparte un `SunatClient` (conexiones y tokens)
80
+ entre sus `FileSender`.
81
+ - **Guías de remisión**: usan `URLs.production()` (la SUNAT no tiene beta para ellas) y el
82
+ `client_id`/`client_secret` de la API.
83
+ - **Errores**: los del núcleo lanzan `xhandler.XhandlerError` con un `kind`: `ErrorKind.VALIDATION`
84
+ (p. ej. un código de catálogo inválido), `ErrorKind.INVALID_KEY`, `ErrorKind.RESPONSE`... Los
85
+ rechazos de la SUNAT no son errores: llegan en `result.fault`.
86
+
87
+ ## Contribuir
88
+
89
+ Revisa [CONTRIBUTING.md](CONTRIBUTING.md).
90
+
91
+ ## Licencia
92
+
93
+ [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0)
@@ -0,0 +1,13 @@
1
+ xhandler/__init__.py,sha256=dgZM4-fSIja7qJpqd0JuBwR1fd2ZEdnpI3qG9s0do3s,370
2
+ xhandler/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ xhandler/_core/VERSION,sha256=9IGx4GPPQn13Mbt2yoHgxYIXZkqPCFEx32Klaz7caSE,14
4
+ xhandler/_core/__init__.py,sha256=751IUS3fx0T3JPeb7d5jfQA2UC9QkxANSArqfjfxyiY,5115
5
+ xhandler/_core/xhandler.wasm,sha256=DSiAXV0mWEebxT1pvV6xzFJSkfzq6DsORXb4h71O8kU,4371842
6
+ xhandler/xbuilder/__init__.py,sha256=GhZboB6JB_Karx0hr2v8QSoYPz_4X1oW7RdkjfbRT1w,1805
7
+ xhandler/xbuilder/models.py,sha256=PmYjkUN4VHRKNUiLE9i9YnR6gsDh2xaYJ2sLHxFyNRg,24783
8
+ xhandler/xsender/__init__.py,sha256=2C3clKlLfMEPOxpF2ATmaPfWAmeWu_UelrWY7ZO-Gxo,10216
9
+ xhandler/xsender/models.py,sha256=Aj_IppTn3-RSF3rrk5IYRYnYDDm6vibdXcymPCI_bKE,5636
10
+ xhandler/xsigner/__init__.py,sha256=r6hFV0WFmvU1J59g0CkXH6Jx8niEyXWM-O6i0OsvKsE,1791
11
+ xhandler-6.0.0a1.dist-info/METADATA,sha256=wdjFfxb4XMGOXvZFRzLl817eLMe6gdOKsdaO3gd7Y18,3745
12
+ xhandler-6.0.0a1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
13
+ xhandler-6.0.0a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any