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 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))