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.
- xhandler/__init__.py +10 -0
- xhandler/_core/VERSION +1 -0
- xhandler/_core/__init__.py +132 -0
- xhandler/_core/xhandler.wasm +0 -0
- xhandler/py.typed +0 -0
- xhandler/xbuilder/__init__.py +47 -0
- xhandler/xbuilder/models.py +707 -0
- xhandler/xsender/__init__.py +280 -0
- xhandler/xsender/models.py +197 -0
- xhandler/xsigner/__init__.py +48 -0
- xhandler-6.0.0a1.dist-info/METADATA +93 -0
- xhandler-6.0.0a1.dist-info/RECORD +13 -0
- xhandler-6.0.0a1.dist-info/WHEEL +4 -0
xhandler/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Create, sign and send the electronic documents (UBL XML) of SUNAT Peru.
|
|
2
|
+
|
|
3
|
+
The work is done by the xhandler Rust core, embedded as a WebAssembly module:
|
|
4
|
+
see the ``xhandler.xbuilder``, ``xhandler.xsigner`` and ``xhandler.xsender``
|
|
5
|
+
packages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from xhandler._core import CORE_VERSION, ErrorKind, XhandlerError
|
|
9
|
+
|
|
10
|
+
__all__ = ["CORE_VERSION", "ErrorKind", "XhandlerError"]
|
xhandler/_core/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
6.0.0-alpha.1
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Runs the operations of the xhandler WebAssembly module.
|
|
2
|
+
|
|
3
|
+
The module exports one JSON entry point (see xhandler-rust/xhandler-wasm): the
|
|
4
|
+
request is ``{"op", "input"}`` and the response ``{"ok": ...}`` or
|
|
5
|
+
``{"error": {"kind", "message"}}``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
14
|
+
import threading
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from importlib import resources
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import wasmtime
|
|
20
|
+
|
|
21
|
+
CORE_VERSION = resources.files(__package__).joinpath("VERSION").read_text(encoding="utf-8").strip()
|
|
22
|
+
"""Version of the embedded xhandler core."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ErrorKind(str, Enum):
|
|
26
|
+
"""Classifies the errors reported by the xhandler core."""
|
|
27
|
+
|
|
28
|
+
INVALID_REQUEST = "invalid_request"
|
|
29
|
+
"""The request did not reach an operation."""
|
|
30
|
+
INVALID_INPUT = "invalid_input"
|
|
31
|
+
"""The input has missing fields or wrong types."""
|
|
32
|
+
VALIDATION = "validation"
|
|
33
|
+
"""The document has invalid values, e.g. an unknown catalog code."""
|
|
34
|
+
RENDER = "render"
|
|
35
|
+
"""The XML could not be rendered."""
|
|
36
|
+
INVALID_KEY = "invalid_key"
|
|
37
|
+
"""The private key or certificate can't be read (e.g. a wrong keystore
|
|
38
|
+
password), or the certificate is not the one of the private key."""
|
|
39
|
+
RESPONSE = "response"
|
|
40
|
+
"""The answer of SUNAT can't be read, e.g. an HTML error page."""
|
|
41
|
+
RATE_LIMITED = "rate_limited"
|
|
42
|
+
"""SUNAT's front proxy refused the request; the SunatClient already retried it."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class XhandlerError(Exception):
|
|
46
|
+
"""An error reported by the xhandler core."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, kind: ErrorKind, message: str) -> None:
|
|
49
|
+
super().__init__(f"xhandler {kind.value}: {message}")
|
|
50
|
+
self.kind = kind
|
|
51
|
+
self.message = message
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def invoke(op: str, input: Any = None) -> Any:
|
|
55
|
+
"""Runs ``op`` with ``input`` (JSON-serializable) and returns its result."""
|
|
56
|
+
response = json.loads(invoke_raw(json.dumps({"op": op, "input": input}).encode()))
|
|
57
|
+
if "error" in response:
|
|
58
|
+
error = response["error"]
|
|
59
|
+
raise XhandlerError(ErrorKind(error["kind"]), error["message"])
|
|
60
|
+
return response["ok"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def invoke_raw(request: bytes) -> bytes:
|
|
64
|
+
"""Sends a JSON request to the module and returns its JSON response."""
|
|
65
|
+
instance = _pool.get()
|
|
66
|
+
# On a trap (e.g. a panic in the core) the instance is unusable: it raises
|
|
67
|
+
# and is not returned to the pool.
|
|
68
|
+
response = instance.invoke(request)
|
|
69
|
+
_pool.put(instance)
|
|
70
|
+
return response
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _Instance:
|
|
74
|
+
"""A module instance: runs one call at a time."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, engine: wasmtime.Engine, module: wasmtime.Module) -> None:
|
|
77
|
+
linker = wasmtime.Linker(engine)
|
|
78
|
+
linker.define_wasi()
|
|
79
|
+
self._store = wasmtime.Store(engine)
|
|
80
|
+
self._store.set_wasi(wasmtime.WasiConfig())
|
|
81
|
+
exports = linker.instantiate(self._store, module).exports(self._store)
|
|
82
|
+
exports["_initialize"](self._store) # type: ignore[operator]
|
|
83
|
+
self._memory: wasmtime.Memory = exports["memory"] # type: ignore[assignment]
|
|
84
|
+
self._alloc: wasmtime.Func = exports["xh_alloc"] # type: ignore[assignment]
|
|
85
|
+
self._free: wasmtime.Func = exports["xh_free"] # type: ignore[assignment]
|
|
86
|
+
self._call: wasmtime.Func = exports["xh_invoke"] # type: ignore[assignment]
|
|
87
|
+
|
|
88
|
+
def invoke(self, request: bytes) -> bytes:
|
|
89
|
+
"""Runs one request. After a trap the instance must be discarded, so
|
|
90
|
+
nothing is freed then."""
|
|
91
|
+
store = self._store
|
|
92
|
+
ptr = self._alloc(store, len(request))
|
|
93
|
+
self._memory.write(store, request, ptr)
|
|
94
|
+
# wasmtime returns i64 results as signed integers
|
|
95
|
+
packed = self._call(store, ptr, len(request)) & 0xFFFFFFFFFFFFFFFF
|
|
96
|
+
response_ptr, response_len = packed >> 32, packed & 0xFFFFFFFF
|
|
97
|
+
response = bytes(self._memory.read(store, response_ptr, response_ptr + response_len))
|
|
98
|
+
self._free(store, ptr, len(request))
|
|
99
|
+
self._free(store, response_ptr, response_len)
|
|
100
|
+
return response
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class _Pool:
|
|
104
|
+
"""Idle module instances: an instance parses the XML templates on its first
|
|
105
|
+
use, so it is worth reusing. The module is compiled once, on first use."""
|
|
106
|
+
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
self._lock = threading.Lock()
|
|
109
|
+
self._module: tuple[wasmtime.Engine, wasmtime.Module] | None = None
|
|
110
|
+
self._idle: queue.Queue[_Instance] = queue.Queue(maxsize=os.cpu_count() or 4)
|
|
111
|
+
|
|
112
|
+
def get(self) -> _Instance:
|
|
113
|
+
try:
|
|
114
|
+
return self._idle.get_nowait()
|
|
115
|
+
except queue.Empty:
|
|
116
|
+
return _Instance(*self._compiled())
|
|
117
|
+
|
|
118
|
+
def put(self, instance: _Instance) -> None:
|
|
119
|
+
# When the pool is full the instance is dropped for the garbage collector.
|
|
120
|
+
with contextlib.suppress(queue.Full):
|
|
121
|
+
self._idle.put_nowait(instance)
|
|
122
|
+
|
|
123
|
+
def _compiled(self) -> tuple[wasmtime.Engine, wasmtime.Module]:
|
|
124
|
+
with self._lock:
|
|
125
|
+
if self._module is None:
|
|
126
|
+
engine = wasmtime.Engine()
|
|
127
|
+
wasm = resources.files(__package__).joinpath("xhandler.wasm").read_bytes()
|
|
128
|
+
self._module = (engine, wasmtime.Module(engine, wasm))
|
|
129
|
+
return self._module
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_pool = _Pool()
|
|
Binary file
|
xhandler/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Creates the UBL XML of SUNAT documents: invoices (boletas and facturas),
|
|
2
|
+
credit and debit notes, guias de remision, perceptions, retentions, summaries,
|
|
3
|
+
voided documents and reversions.
|
|
4
|
+
|
|
5
|
+
Only the essential data is required: the xhandler core fills the defaults
|
|
6
|
+
(currency, tax rates, document types...) and computes taxes and totals before
|
|
7
|
+
rendering the XML.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, TypeVar, cast
|
|
13
|
+
|
|
14
|
+
from xhandler._core import invoke
|
|
15
|
+
from xhandler.xbuilder import models as _models
|
|
16
|
+
from xhandler.xbuilder.models import * # noqa: F403 (the models are the API)
|
|
17
|
+
from xhandler.xbuilder.models import Defaults, Document
|
|
18
|
+
|
|
19
|
+
__all__ = [*_models.__all__, "build", "enrich"]
|
|
20
|
+
|
|
21
|
+
D = TypeVar("D", bound=Document)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _input(document: Document, defaults: Defaults | None) -> dict[str, Any]:
|
|
25
|
+
return {
|
|
26
|
+
"document": {
|
|
27
|
+
"kind": document.kind,
|
|
28
|
+
"spec": document.model_dump(mode="json", exclude_none=True),
|
|
29
|
+
},
|
|
30
|
+
"defaults": (defaults or Defaults()).model_dump(mode="json", exclude_none=True),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build(document: Document, defaults: Defaults | None = None) -> str:
|
|
35
|
+
"""Enriches the document and renders its XML. Without ``defaults``, or for
|
|
36
|
+
the fields they leave unset, the core uses today's date in Peru and the
|
|
37
|
+
current SUNAT tax rates. The document itself is not modified: use
|
|
38
|
+
:func:`enrich` to get the computed values."""
|
|
39
|
+
xml: str = invoke("build", _input(document, defaults))
|
|
40
|
+
return xml
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def enrich(document: D, defaults: Defaults | None = None) -> D:
|
|
44
|
+
"""Returns a copy of the document with its defaults filled in and its taxes
|
|
45
|
+
and totals computed, as :func:`build` renders it."""
|
|
46
|
+
spec = invoke("enrich", _input(document, defaults))
|
|
47
|
+
return cast(D, type(document).model_validate(spec))
|