euinvoice 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.
- euinvoice/__init__.py +64 -0
- euinvoice/__main__.py +305 -0
- euinvoice/_api.py +154 -0
- euinvoice/_syntax.py +16 -0
- euinvoice/_xml.py +296 -0
- euinvoice/calc/__init__.py +74 -0
- euinvoice/calc/_categories.py +369 -0
- euinvoice/calc/_check.py +243 -0
- euinvoice/calc/_common.py +182 -0
- euinvoice/calc/_complete.py +230 -0
- euinvoice/detection.py +159 -0
- euinvoice/errors.py +101 -0
- euinvoice/facturx/__init__.py +10 -0
- euinvoice/facturx/_embed.py +231 -0
- euinvoice/facturx/_extract.py +215 -0
- euinvoice/facturx/_pypdf.py +39 -0
- euinvoice/facturx/xmp.py +149 -0
- euinvoice/model/__init__.py +94 -0
- euinvoice/model/_base.py +127 -0
- euinvoice/model/allowances.py +106 -0
- euinvoice/model/amounts.py +110 -0
- euinvoice/model/bt_index.py +110 -0
- euinvoice/model/codes/__init__.py +81 -0
- euinvoice/model/codes/_generated.py +559 -0
- euinvoice/model/codes/derived.py +16 -0
- euinvoice/model/codes/enums.py +67 -0
- euinvoice/model/datatypes.py +399 -0
- euinvoice/model/delivery.py +61 -0
- euinvoice/model/documents.py +21 -0
- euinvoice/model/invoice.py +168 -0
- euinvoice/model/lines.py +149 -0
- euinvoice/model/parties.py +180 -0
- euinvoice/model/payment.py +68 -0
- euinvoice/model/tax.py +31 -0
- euinvoice/model/totals.py +38 -0
- euinvoice/profiles/__init__.py +41 -0
- euinvoice/profiles/_base.py +162 -0
- euinvoice/profiles/en16931.py +23 -0
- euinvoice/profiles/facturx.py +198 -0
- euinvoice/profiles/peppol.py +360 -0
- euinvoice/profiles/registry.py +74 -0
- euinvoice/profiles/xrechnung.py +455 -0
- euinvoice/py.typed +0 -0
- euinvoice/report.py +66 -0
- euinvoice/syntax/__init__.py +9 -0
- euinvoice/syntax/_marks.py +192 -0
- euinvoice/syntax/_read_errors.py +55 -0
- euinvoice/syntax/cii/__init__.py +12 -0
- euinvoice/syntax/cii/_build.py +188 -0
- euinvoice/syntax/cii/_lines.py +137 -0
- euinvoice/syntax/cii/_parties.py +142 -0
- euinvoice/syntax/cii/_read.py +207 -0
- euinvoice/syntax/cii/_read_common.py +69 -0
- euinvoice/syntax/cii/_read_lines.py +168 -0
- euinvoice/syntax/cii/_read_parties.py +193 -0
- euinvoice/syntax/cii/_read_settlement.py +287 -0
- euinvoice/syntax/cii/_reader.py +171 -0
- euinvoice/syntax/cii/_settlement.py +211 -0
- euinvoice/syntax/cii/_write.py +136 -0
- euinvoice/syntax/result.py +25 -0
- euinvoice/syntax/ubl/__init__.py +13 -0
- euinvoice/syntax/ubl/_build.py +182 -0
- euinvoice/syntax/ubl/_cursor.py +292 -0
- euinvoice/syntax/ubl/_lines.py +186 -0
- euinvoice/syntax/ubl/_parties.py +183 -0
- euinvoice/syntax/ubl/_read.py +424 -0
- euinvoice/syntax/ubl/_read_lines.py +283 -0
- euinvoice/syntax/ubl/_read_parties.py +277 -0
- euinvoice/syntax/ubl/_write.py +378 -0
- euinvoice/validation/__init__.py +8 -0
- euinvoice/validation/artifacts.py +446 -0
- euinvoice/validation/manifest.toml +127 -0
- euinvoice/validation/orchestration.py +190 -0
- euinvoice/validation/schematron.py +239 -0
- euinvoice/validation/xsd.py +158 -0
- euinvoice-0.1.0.dist-info/METADATA +212 -0
- euinvoice-0.1.0.dist-info/RECORD +79 -0
- euinvoice-0.1.0.dist-info/WHEEL +4 -0
- euinvoice-0.1.0.dist-info/licenses/LICENSE +21 -0
euinvoice/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""EN 16931 e-invoicing: build, serialize (UBL/CII), validate, parse and embed (Factur-X) European e-invoices.
|
|
2
|
+
|
|
3
|
+
The public API (plan §4): :func:`to_xml`, :func:`parse`, :func:`parse_detailed`, :func:`validate` and
|
|
4
|
+
:func:`detect`, the :class:`Invoice` / :class:`InvoiceDraft` models and the ``calc``, ``profiles`` and
|
|
5
|
+
``facturx`` subpackages. ``facturx`` needs the ``[pdf]`` extra and is imported on first access
|
|
6
|
+
(``euinvoice.facturx`` or ``from euinvoice import facturx``); it is not in ``__all__``, so ``from euinvoice import *``
|
|
7
|
+
works without pypdf. ``import euinvoice`` loads neither pypdf nor saxonche (the ``[validate]`` extra, loaded when
|
|
8
|
+
validating).
|
|
9
|
+
|
|
10
|
+
The functions :func:`detect` and :func:`validate` live in the modules ``euinvoice.detection`` and
|
|
11
|
+
``euinvoice.validation`` (renamed in #93 so the functions shadow no module).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
import typing as t
|
|
16
|
+
from importlib.metadata import version
|
|
17
|
+
|
|
18
|
+
from euinvoice import calc, profiles
|
|
19
|
+
from euinvoice._api import parse, parse_detailed, to_xml
|
|
20
|
+
from euinvoice.detection import detect
|
|
21
|
+
from euinvoice.model import Invoice, InvoiceDraft
|
|
22
|
+
from euinvoice.report import ValidationReport
|
|
23
|
+
from euinvoice.syntax import Syntax
|
|
24
|
+
from euinvoice.syntax.result import ParseResult
|
|
25
|
+
from euinvoice.validation import validate
|
|
26
|
+
|
|
27
|
+
if t.TYPE_CHECKING:
|
|
28
|
+
# The redundant alias marks an explicit re-export, so ruff does not add ``facturx`` to ``__all__`` (which would
|
|
29
|
+
# make ``from euinvoice import *`` need pypdf).
|
|
30
|
+
from euinvoice import facturx as facturx
|
|
31
|
+
|
|
32
|
+
__version__ = version("euinvoice")
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Invoice",
|
|
36
|
+
"InvoiceDraft",
|
|
37
|
+
"ParseResult",
|
|
38
|
+
"Syntax",
|
|
39
|
+
"ValidationReport",
|
|
40
|
+
"__version__",
|
|
41
|
+
"calc",
|
|
42
|
+
"detect",
|
|
43
|
+
"parse",
|
|
44
|
+
"parse_detailed",
|
|
45
|
+
"profiles",
|
|
46
|
+
"to_xml",
|
|
47
|
+
"validate",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Hidden from type checkers so that a typo such as ``euinvoice.facturxx`` is a mypy error, not ``Any``; the
|
|
52
|
+
# ``TYPE_CHECKING`` import above gives them ``facturx``.
|
|
53
|
+
if not t.TYPE_CHECKING:
|
|
54
|
+
|
|
55
|
+
def __getattr__(name: str) -> t.Any:
|
|
56
|
+
"""Import ``euinvoice.facturx`` on first access (it needs the ``[pdf]`` extra).
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
AttributeError: ``name`` is not a lazily imported subpackage.
|
|
60
|
+
ImportError: ``name`` is ``"facturx"`` and pypdf is not installed; the message names the extra.
|
|
61
|
+
"""
|
|
62
|
+
if name == "facturx":
|
|
63
|
+
return importlib.import_module("euinvoice.facturx")
|
|
64
|
+
raise AttributeError(f"module 'euinvoice' has no attribute {name!r}")
|
euinvoice/__main__.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Command-line interface: ``python -m euinvoice`` (plan §8, M8.3).
|
|
2
|
+
|
|
3
|
+
Subcommands (each registers its handler with ``set_defaults(run=...)``):
|
|
4
|
+
|
|
5
|
+
* ``validate FILE [--profile ID] [--json]``: :func:`euinvoice.validate` on a UBL / CII document or the invoice
|
|
6
|
+
of a Factur-X / ZUGFeRD PDF; one line per finding, then a verdict line.
|
|
7
|
+
* ``convert FILE --to ubl|cii [--profile ID] [-o OUT]``: :func:`euinvoice.parse_detailed`, then
|
|
8
|
+
:func:`euinvoice.to_xml`; the XML goes to ``OUT`` or stdout. Input that has no business term in the model is
|
|
9
|
+
not converted, and each such XPath is reported on stderr (readers never drop input silently, plan §1).
|
|
10
|
+
* ``info FILE [--json]``: the detected syntax, root, BT-24 and profile, the Factur-X / ZUGFeRD container of a
|
|
11
|
+
PDF, the invoice's BT-1, BT-2, BT-3, BT-5 and document totals (BG-22), and what was not mapped.
|
|
12
|
+
* ``artifacts fetch [--only NAME]``: download and verify the pinned official artifacts.
|
|
13
|
+
|
|
14
|
+
``FILE`` ``-`` reads stdin. ``--profile`` takes a :attr:`euinvoice.profiles.Profile.id`; without it the profile
|
|
15
|
+
comes from BT-24 (``validate`` falls back to EN 16931 core as :func:`euinvoice.validate` documents).
|
|
16
|
+
|
|
17
|
+
Exit codes:
|
|
18
|
+
|
|
19
|
+
* ``0``: success; ``validate`` found nothing ``fatal`` / ``error`` (warnings allowed).
|
|
20
|
+
* ``1``: the document was rejected: ``validate`` found a ``fatal`` / ``error`` finding (``validate --profile ID``
|
|
21
|
+
on a document whose syntax the profile does not support included), ``convert`` was refused by the pre-flight /
|
|
22
|
+
calculation checks (the findings go to stderr), the input is not a readable invoice (malformed XML, unsupported
|
|
23
|
+
root or profile, a PDF without one embedded invoice), or an artifact fails its integrity check
|
|
24
|
+
(``ArtifactIntegrityError``: sha256 mismatch, unsafe archive member).
|
|
25
|
+
* ``2``: no verdict, fix the command or the setup: a usage error (argparse, an unknown ``--profile``, ``convert
|
|
26
|
+
--profile ID --to SYNTAX`` with a syntax the profile does not support), ``FILE`` cannot be read or ``OUT``
|
|
27
|
+
written, an artifact download fails (network / HTTP), the artifacts are missing (the message names
|
|
28
|
+
``artifacts fetch``) or the profile's Schematron is not pinned, or an extra (``[pdf]``) is not installed.
|
|
29
|
+
|
|
30
|
+
Text that the terminal's encoding cannot represent (e.g. German rule messages on an ASCII stdout) is written
|
|
31
|
+
with backslash escapes instead of failing.
|
|
32
|
+
|
|
33
|
+
JSON output (``--json``), one object on stdout:
|
|
34
|
+
|
|
35
|
+
* ``validate``: ``{"ok": bool, "findings": [{"rule_id", "severity", "location", "message", "source"}]}``, the
|
|
36
|
+
fields of :class:`euinvoice.report.Finding` (``location`` may be ``null``).
|
|
37
|
+
* ``info``: ``{"syntax", "root", "specification_identifier", "profile", "pdf", "invoice", "unmapped"}``.
|
|
38
|
+
``profile`` is a profile id or ``null``; ``pdf`` is ``null`` for XML, else ``{"container",
|
|
39
|
+
"conformance_level", "filename"}``; ``invoice`` maps the BT ids above to strings (dates ISO 8601, amounts as
|
|
40
|
+
plain decimals) or ``null`` when absent; ``unmapped`` lists XPaths.
|
|
41
|
+
|
|
42
|
+
Errors are a single ``error: ...`` line on stderr in either mode.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import argparse
|
|
46
|
+
import dataclasses
|
|
47
|
+
import datetime
|
|
48
|
+
import io
|
|
49
|
+
import json
|
|
50
|
+
import logging
|
|
51
|
+
import sys
|
|
52
|
+
import typing as t
|
|
53
|
+
from decimal import Decimal
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
|
|
56
|
+
from euinvoice import profiles
|
|
57
|
+
from euinvoice._api import parse_detailed, to_xml
|
|
58
|
+
from euinvoice.detection import detect, is_pdf
|
|
59
|
+
from euinvoice.errors import ArtifactsNotAvailableError, EuInvoiceError, PreflightError
|
|
60
|
+
from euinvoice.model.bt_index import path_of
|
|
61
|
+
from euinvoice.model.invoice import Invoice
|
|
62
|
+
from euinvoice.report import Finding, Severity, ValidationReport
|
|
63
|
+
from euinvoice.syntax import Syntax
|
|
64
|
+
from euinvoice.validation import artifacts, validate
|
|
65
|
+
|
|
66
|
+
if t.TYPE_CHECKING:
|
|
67
|
+
from euinvoice.facturx import Extracted
|
|
68
|
+
|
|
69
|
+
PROFILES: t.Final[t.Mapping[str, profiles.Profile]] = {
|
|
70
|
+
profile.id: profile for name in profiles.__all__ if isinstance(profile := getattr(profiles, name), profiles.Profile)
|
|
71
|
+
}
|
|
72
|
+
"""Every profile ``--profile`` accepts, by :attr:`~euinvoice.profiles.Profile.id`."""
|
|
73
|
+
|
|
74
|
+
# The terms ``info`` shows (EN 16931-1 §6.4; ids resolved to model paths by euinvoice.model.bt_index): invoice number,
|
|
75
|
+
# issue date, type code, currency, and the document totals BG-22 (BT-106 … BT-115).
|
|
76
|
+
_SUMMARY_TERMS: t.Final = ("BT-1", "BT-2", "BT-3", "BT-5", *(f"BT-{n}" for n in range(106, 116)))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read(file: str) -> bytes:
|
|
80
|
+
"""The bytes of ``file``, or of stdin for ``-``."""
|
|
81
|
+
return sys.stdin.buffer.read() if file == "-" else Path(file).read_bytes()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _xml_of(data: bytes) -> tuple[bytes, "Extracted | None"]:
|
|
85
|
+
"""The invoice XML of ``data`` and, for a Factur-X / ZUGFeRD PDF, what :func:`euinvoice.facturx.extract` found."""
|
|
86
|
+
if not is_pdf(data):
|
|
87
|
+
return data, None
|
|
88
|
+
# Imported here so that XML input never needs pypdf (the ``[pdf]`` extra).
|
|
89
|
+
from euinvoice import facturx
|
|
90
|
+
|
|
91
|
+
found = facturx.extract(data)
|
|
92
|
+
return found.xml, found
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _finding_line(finding: Finding) -> str:
|
|
96
|
+
at = f" at {finding.location}" if finding.location else ""
|
|
97
|
+
return f"{finding.severity} {finding.rule_id} [{finding.source}]{at}: {finding.message}\n"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _validate(args: argparse.Namespace) -> int:
|
|
101
|
+
xml, found = _xml_of(_read(args.file))
|
|
102
|
+
# For a PDF, the Factur-X level in the XMP selects the profile (a level shares its BT-24 with EN 16931 core or
|
|
103
|
+
# XRechnung), as in ``info``. A level whose Factur-X Schematron is not pinned (MINIMUM, BASIC WL, BASIC,
|
|
104
|
+
# EXTENDED; issue #42) then makes validate() raise ArtifactsNotAvailableError (exit 2) instead of a verdict. A
|
|
105
|
+
# ZUGFeRD 2.0 PDF selects no profile; validate() refuses its MINIMUM, BASIC and EXTENDED BT-24s the same way (#98).
|
|
106
|
+
profile = args.profile if args.profile is not None or found is None else found.profile
|
|
107
|
+
try:
|
|
108
|
+
report: ValidationReport = validate(xml, profile)
|
|
109
|
+
except ArtifactsNotAvailableError as exc: # the unpinned Factur-X Schematron (#42) names the Python spelling
|
|
110
|
+
raise ArtifactsNotAvailableError(
|
|
111
|
+
str(exc).replace("pass profile=euinvoice.profiles.EN16931", "pass --profile en16931")
|
|
112
|
+
) from exc
|
|
113
|
+
if args.json:
|
|
114
|
+
payload = {"ok": report.ok, "findings": [dataclasses.asdict(f) for f in report.findings]}
|
|
115
|
+
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
|
116
|
+
else:
|
|
117
|
+
blocking = sum(f.severity in (Severity.FATAL, Severity.ERROR) for f in report.findings)
|
|
118
|
+
for finding in report.findings:
|
|
119
|
+
sys.stdout.write(_finding_line(finding))
|
|
120
|
+
verdict = "ok" if report.ok else "invalid"
|
|
121
|
+
sys.stdout.write(f"{verdict}: {blocking} fatal/error, {len(report.findings) - blocking} warning/information\n")
|
|
122
|
+
return 0 if report.ok else 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _convert(args: argparse.Namespace) -> int:
|
|
126
|
+
if args.profile is not None and Syntax(args.to) not in args.profile.syntaxes:
|
|
127
|
+
supported = ", ".join(sorted(args.profile.syntaxes))
|
|
128
|
+
sys.stderr.write(
|
|
129
|
+
f"error: profile {args.profile.id!r} does not support --to {args.to}; it supports {supported}\n"
|
|
130
|
+
)
|
|
131
|
+
return 2
|
|
132
|
+
result = parse_detailed(_read(args.file))
|
|
133
|
+
for path in result.unmapped:
|
|
134
|
+
sys.stderr.write(f"warning: not converted, no business term: {path}\n")
|
|
135
|
+
try:
|
|
136
|
+
xml = to_xml(result.invoice, profile=args.profile, syntax=args.to)
|
|
137
|
+
except PreflightError as exc:
|
|
138
|
+
sys.stderr.write(
|
|
139
|
+
f"error: invoice fails the {exc.profile_id} pre-flight and calculation checks for {exc.syntax}\n"
|
|
140
|
+
)
|
|
141
|
+
for finding in exc.findings:
|
|
142
|
+
sys.stderr.write(_finding_line(finding))
|
|
143
|
+
return 1
|
|
144
|
+
if args.output is None:
|
|
145
|
+
sys.stdout.buffer.write(xml)
|
|
146
|
+
sys.stdout.buffer.flush()
|
|
147
|
+
else:
|
|
148
|
+
# Written only after to_xml succeeded, so a refusal never touches OUT.
|
|
149
|
+
# ponytail: a write error midway (e.g. disk full) can leave OUT truncated; the upgrade path is a temp file in
|
|
150
|
+
# the same directory + os.replace that keeps OUT's mode and does not replace a symlink.
|
|
151
|
+
Path(args.output).write_bytes(xml)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _plain(value: object) -> str | None:
|
|
156
|
+
"""A business term's value as JSON-safe text: amounts via ``format(v, "f")`` (never exponent notation)."""
|
|
157
|
+
if value is None:
|
|
158
|
+
return None
|
|
159
|
+
if isinstance(value, Decimal):
|
|
160
|
+
return format(value, "f")
|
|
161
|
+
if isinstance(value, datetime.date):
|
|
162
|
+
return value.isoformat()
|
|
163
|
+
return str(value)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _summary(invoice: Invoice) -> dict[str, str | None]:
|
|
167
|
+
summary: dict[str, str | None] = {}
|
|
168
|
+
for ident in _SUMMARY_TERMS:
|
|
169
|
+
value: object = invoice
|
|
170
|
+
for name in path_of(ident).split("."):
|
|
171
|
+
value = getattr(value, name)
|
|
172
|
+
summary[ident] = _plain(value)
|
|
173
|
+
return summary
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _info(args: argparse.Namespace) -> int:
|
|
177
|
+
xml, found = _xml_of(_read(args.file))
|
|
178
|
+
detection = detect(xml)
|
|
179
|
+
result = parse_detailed(xml)
|
|
180
|
+
# For a PDF, extract() picks the profile: a Factur-X level shares its BT-24 with EN 16931 core or XRechnung.
|
|
181
|
+
profile = detection.profile if found is None else found.profile
|
|
182
|
+
container = (
|
|
183
|
+
None
|
|
184
|
+
if found is None
|
|
185
|
+
else {"container": found.container, "conformance_level": found.conformance_level, "filename": found.filename}
|
|
186
|
+
)
|
|
187
|
+
summary = _summary(result.invoice)
|
|
188
|
+
head = {
|
|
189
|
+
"syntax": str(detection.syntax),
|
|
190
|
+
"root": detection.root,
|
|
191
|
+
"specification_identifier": detection.specification_identifier,
|
|
192
|
+
"profile": profile.id if profile is not None else None,
|
|
193
|
+
}
|
|
194
|
+
if args.json:
|
|
195
|
+
payload = {**head, "pdf": container, "invoice": summary, "unmapped": list(result.unmapped)}
|
|
196
|
+
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
|
197
|
+
return 0
|
|
198
|
+
lines = [
|
|
199
|
+
*(f"{key}: {value}" for key, value in head.items()),
|
|
200
|
+
*(f"pdf {key}: {value}" for key, value in (container or {}).items()),
|
|
201
|
+
*(f"{ident}: {term}" for ident, term in summary.items() if term is not None),
|
|
202
|
+
*(f"unmapped: {path}" for path in result.unmapped),
|
|
203
|
+
]
|
|
204
|
+
sys.stdout.write("".join(f"{line}\n" for line in lines))
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _artifacts_fetch(args: argparse.Namespace) -> int:
|
|
209
|
+
names = t.cast(list[artifacts.SourceName] | None, args.only)
|
|
210
|
+
for name, path in artifacts.fetch(names).items():
|
|
211
|
+
sys.stdout.write(f"{name}: {path}\n")
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _profile_arg(value: str) -> profiles.Profile:
|
|
216
|
+
try:
|
|
217
|
+
return PROFILES[value]
|
|
218
|
+
except KeyError:
|
|
219
|
+
raise argparse.ArgumentTypeError(f"unknown profile {value!r}; choose from {', '.join(PROFILES)}") from None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
_EPILOG: t.Final = "exit codes: 0 ok, 1 document rejected (findings, refused, unreadable invoice), 2 usage or setup"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parser() -> argparse.ArgumentParser:
|
|
226
|
+
parser = argparse.ArgumentParser(
|
|
227
|
+
prog="python -m euinvoice",
|
|
228
|
+
description="EN 16931 e-invoicing toolkit (euinvoice).",
|
|
229
|
+
epilog=_EPILOG,
|
|
230
|
+
)
|
|
231
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
232
|
+
profile_help = f"profile id (default: from BT-24): {', '.join(PROFILES)}"
|
|
233
|
+
|
|
234
|
+
check = commands.add_parser(
|
|
235
|
+
"validate", help="validate a UBL / CII invoice or Factur-X / ZUGFeRD PDF", epilog=_EPILOG
|
|
236
|
+
)
|
|
237
|
+
check.add_argument("file", metavar="FILE", help="the invoice; - reads stdin")
|
|
238
|
+
check.add_argument("--profile", type=_profile_arg, metavar="ID", help=profile_help)
|
|
239
|
+
check.add_argument("--json", action="store_true", help="print the report as JSON")
|
|
240
|
+
check.set_defaults(run=_validate)
|
|
241
|
+
|
|
242
|
+
convert = commands.add_parser("convert", help="convert an invoice to UBL or CII", epilog=_EPILOG)
|
|
243
|
+
convert.add_argument("file", metavar="FILE", help="the invoice (XML or Factur-X / ZUGFeRD PDF); - reads stdin")
|
|
244
|
+
convert.add_argument("--to", required=True, choices=[str(s) for s in Syntax], help="target syntax")
|
|
245
|
+
convert.add_argument("--profile", type=_profile_arg, metavar="ID", help=profile_help)
|
|
246
|
+
convert.add_argument("-o", "--output", metavar="OUT", help="write here instead of stdout")
|
|
247
|
+
convert.set_defaults(run=_convert)
|
|
248
|
+
|
|
249
|
+
info = commands.add_parser("info", help="show what an invoice is: syntax, profile, number, totals", epilog=_EPILOG)
|
|
250
|
+
info.add_argument("file", metavar="FILE", help="the invoice (XML or Factur-X / ZUGFeRD PDF); - reads stdin")
|
|
251
|
+
info.add_argument("--json", action="store_true", help="print as JSON")
|
|
252
|
+
info.set_defaults(run=_info)
|
|
253
|
+
|
|
254
|
+
group = commands.add_parser("artifacts", help="manage the official validation artifacts")
|
|
255
|
+
actions = group.add_subparsers(dest="action", required=True)
|
|
256
|
+
fetch = actions.add_parser(
|
|
257
|
+
"fetch",
|
|
258
|
+
help=f"download and verify the pinned artifacts into ${artifacts.ENV_VAR} "
|
|
259
|
+
f"(default {artifacts.DEFAULT_CACHE_DIR})",
|
|
260
|
+
)
|
|
261
|
+
fetch.add_argument(
|
|
262
|
+
"--only",
|
|
263
|
+
action="append",
|
|
264
|
+
metavar="NAME",
|
|
265
|
+
choices=t.get_args(artifacts.SourceName),
|
|
266
|
+
help="fetch only this source (repeatable); choices: %(choices)s",
|
|
267
|
+
)
|
|
268
|
+
fetch.set_defaults(run=_artifacts_fetch)
|
|
269
|
+
return parser
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def main(argv: t.Sequence[str] | None = None) -> int:
|
|
273
|
+
"""Run the CLI.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
argv: Arguments without the program name; defaults to ``sys.argv[1:]``.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
The process exit code (see the module docstring). Usage errors found by argparse exit with 2 through
|
|
280
|
+
``SystemExit``.
|
|
281
|
+
"""
|
|
282
|
+
for stream in (sys.stdout, sys.stderr):
|
|
283
|
+
if isinstance(stream, io.TextIOWrapper):
|
|
284
|
+
stream.reconfigure(errors="backslashreplace")
|
|
285
|
+
args = _parser().parse_args(argv)
|
|
286
|
+
run = t.cast(t.Callable[[argparse.Namespace], int], args.run)
|
|
287
|
+
# pypdf logs recoverable damage (e.g. "EOF marker not found") as warnings; the CLI reports a PdfError as one
|
|
288
|
+
# ``error:`` line instead, so the pypdf logger is quieted for the run and restored afterwards.
|
|
289
|
+
pypdf_logger = logging.getLogger("pypdf")
|
|
290
|
+
level = pypdf_logger.level
|
|
291
|
+
pypdf_logger.setLevel(logging.ERROR)
|
|
292
|
+
try:
|
|
293
|
+
return run(args)
|
|
294
|
+
except (ArtifactsNotAvailableError, OSError, ImportError) as exc:
|
|
295
|
+
sys.stderr.write(f"error: {exc}\n")
|
|
296
|
+
return 2
|
|
297
|
+
except EuInvoiceError as exc:
|
|
298
|
+
sys.stderr.write(f"error: {exc}\n")
|
|
299
|
+
return 1
|
|
300
|
+
finally:
|
|
301
|
+
pypdf_logger.setLevel(level)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
sys.exit(main())
|
euinvoice/_api.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The top-level API: :func:`to_xml`, :func:`parse` and :func:`parse_detailed` (plan §4 "Public API", "Data flow").
|
|
2
|
+
|
|
3
|
+
Writing: ``profile.prepare(invoice)`` → ``profile.preflight(prepared, syntax)`` and
|
|
4
|
+
``calc.check(prepared, syntax=syntax)`` → the syntax writer. Reading:
|
|
5
|
+
(a Factur-X / ZUGFeRD PDF goes through :func:`euinvoice.facturx.extract` first) → :func:`euinvoice._xml.parse`
|
|
6
|
+
(D10) → :func:`euinvoice.detection.detect_root` → the syntax reader. This module sits above every other package;
|
|
7
|
+
nothing below imports it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import typing as t
|
|
11
|
+
|
|
12
|
+
from euinvoice import _xml, calc, profiles
|
|
13
|
+
from euinvoice.detection import detect_root, is_pdf
|
|
14
|
+
from euinvoice.errors import PreflightError, UnsupportedDocumentError
|
|
15
|
+
from euinvoice.model import Invoice
|
|
16
|
+
from euinvoice.profiles._base import FACTURX_RULE_SET
|
|
17
|
+
from euinvoice.report import ValidationReport
|
|
18
|
+
from euinvoice.syntax import Syntax, cii, ubl
|
|
19
|
+
from euinvoice.syntax.result import ParseResult
|
|
20
|
+
|
|
21
|
+
__all__ = ["parse", "parse_detailed", "to_xml"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def to_xml(
|
|
25
|
+
invoice: Invoice,
|
|
26
|
+
*,
|
|
27
|
+
profile: profiles.Profile | None = None,
|
|
28
|
+
syntax: Syntax | t.Literal["ubl", "cii"] | None = None,
|
|
29
|
+
) -> bytes:
|
|
30
|
+
"""Write an invoice as UBL 2.1 or CII D16B XML under a profile, after its pre-flight and calculation checks.
|
|
31
|
+
|
|
32
|
+
The invoice is first set up for the profile with :meth:`~euinvoice.profiles.Profile.prepare` (BT-24 becomes
|
|
33
|
+
the profile's, BT-23 gets its default; see there), so the written document reads back as
|
|
34
|
+
``profile.prepare(invoice)``, not as ``invoice``. Then two checks run on the prepared invoice:
|
|
35
|
+
:attr:`~euinvoice.profiles.Profile.preflight` (the profile's rules) and :func:`euinvoice.calc.check` with the
|
|
36
|
+
target syntax (the CEN calculation, VAT category and period rules, each ``fatal`` exactly when that syntax's
|
|
37
|
+
official CEN binding rejects it; ``tests/conformance/test_calc_oracle.py``). Any ``fatal`` or ``error``
|
|
38
|
+
finding refuses the write with :class:`PreflightError`, because the official rules would reject the document.
|
|
39
|
+
Warnings do not block and are not returned; call ``profile.preflight(prepared, syntax)`` and
|
|
40
|
+
``calc.check(prepared, syntax=syntax)`` to see them. Both checks are early, model-level messages; the official
|
|
41
|
+
Schematron stays the oracle (D8), so run :func:`euinvoice.validate` on the result for the verdict.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
invoice: The invoice, e.g. from :func:`euinvoice.calc.complete`.
|
|
45
|
+
profile: The profile to write under. ``None`` uses the profile registered for the invoice's own BT-24
|
|
46
|
+
(:func:`euinvoice.profiles.get`); a Factur-X level shares its BT-24 with another profile, so pass it
|
|
47
|
+
explicitly (and see :func:`euinvoice.facturx.embed` for the PDF).
|
|
48
|
+
syntax: ``Syntax.UBL`` / ``"ubl"`` or ``Syntax.CII`` / ``"cii"``. ``None`` is allowed only when the
|
|
49
|
+
profile supports a single syntax, which is then used (e.g. ``FACTURX_EN16931`` /
|
|
50
|
+
``FACTURX_XRECHNUNG``: CII).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The serialized XML document.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
PreflightError: The pre-flight or calculation checks report a ``fatal`` or ``error`` finding; ``findings``
|
|
57
|
+
holds every finding of both.
|
|
58
|
+
UnsupportedDocumentError: ``profile`` is ``None`` and no profile is registered for the invoice's BT-24;
|
|
59
|
+
the profile does not support ``syntax``; or it is a Factur-X level that is not generated (MINIMUM,
|
|
60
|
+
BASIC WL, BASIC, EXTENDED, plan §1), whose official Schematron is not pinned (issue #42), so nothing
|
|
61
|
+
could tell whether its rules accept the document.
|
|
62
|
+
ValueError: ``syntax`` is not a syntax, or is ``None`` for a profile that supports more than one.
|
|
63
|
+
"""
|
|
64
|
+
if profile is None:
|
|
65
|
+
profile = profiles.get(invoice.process_control.specification_identifier)
|
|
66
|
+
if FACTURX_RULE_SET in profile.rule_sets:
|
|
67
|
+
raise UnsupportedDocumentError(
|
|
68
|
+
f"profile {profile.id!r} is not generated (plan §1): its Factur-X / ZUGFeRD Schematron is not pinned "
|
|
69
|
+
"(https://github.com/letsrevel/euinvoice/issues/42)"
|
|
70
|
+
)
|
|
71
|
+
target = _target_syntax(profile, syntax)
|
|
72
|
+
prepared = profile.prepare(invoice)
|
|
73
|
+
findings = (*profile.preflight(prepared, target), *calc.check(prepared, syntax=target))
|
|
74
|
+
if not ValidationReport(findings).ok:
|
|
75
|
+
raise PreflightError(profile.id, target, findings)
|
|
76
|
+
return ubl.write(prepared) if target is Syntax.UBL else cii.write(prepared)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse(data: bytes) -> Invoice:
|
|
80
|
+
"""Read a UBL or CII invoice, or the invoice of a Factur-X / ZUGFeRD PDF, into the semantic model.
|
|
81
|
+
|
|
82
|
+
This is :func:`parse_detailed` without its ``unmapped`` list: input that has no business term in the model
|
|
83
|
+
is **discarded** here. Use :func:`parse_detailed` to see it.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
data: UBL 2.1 ``Invoice`` / ``CreditNote`` or CII D16B ``CrossIndustryInvoice`` XML, or a PDF with an
|
|
87
|
+
embedded Factur-X / ZUGFeRD invoice.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The invoice.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
TypeError: ``data`` is not ``bytes``.
|
|
94
|
+
ParseError: See :func:`parse_detailed`.
|
|
95
|
+
UnsupportedDocumentError: See :func:`parse_detailed`.
|
|
96
|
+
PdfError: See :func:`parse_detailed`.
|
|
97
|
+
ImportError: ``data`` is a PDF and the ``[pdf]`` extra (pypdf) is not installed.
|
|
98
|
+
"""
|
|
99
|
+
return parse_detailed(data).invoice
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_detailed(data: bytes) -> ParseResult:
|
|
103
|
+
"""Read a UBL or CII invoice, or the invoice of a Factur-X / ZUGFeRD PDF, and list what was not mapped.
|
|
104
|
+
|
|
105
|
+
The syntax comes from the root element (:func:`euinvoice.detection.detect_root`); the BT-24 profile plays no part
|
|
106
|
+
in reading. A PDF (``%PDF-`` header, see :func:`euinvoice.detection.is_pdf`) is read with
|
|
107
|
+
:func:`euinvoice.facturx.extract`, which needs the ``[pdf]`` extra, and its embedded XML is parsed like any
|
|
108
|
+
other.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
data: UBL 2.1 ``Invoice`` / ``CreditNote`` or CII D16B ``CrossIndustryInvoice`` XML, or a PDF with an
|
|
112
|
+
embedded Factur-X / ZUGFeRD invoice.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The invoice plus the XPath of every element or attribute that has no business term in the model
|
|
116
|
+
(:attr:`~euinvoice.syntax.result.ParseResult.unmapped`).
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
TypeError: ``data`` is not ``bytes``.
|
|
120
|
+
ParseError: The XML is malformed, has a DOCTYPE or exceeds the parser limits (D10), or its content does not
|
|
121
|
+
form a valid invoice (the message names the BT/BG id). Factur-X MINIMUM and BASIC WL documents raise it,
|
|
122
|
+
as they lack terms EN 16931 requires (issue #69).
|
|
123
|
+
UnsupportedDocumentError: The root element is not a UBL 2.1 Invoice / CreditNote or a CII D16B
|
|
124
|
+
CrossIndustryInvoice.
|
|
125
|
+
PdfError: ``data`` is a PDF that does not name exactly one embedded invoice (see
|
|
126
|
+
:func:`euinvoice.facturx.extract`).
|
|
127
|
+
ImportError: ``data`` is a PDF and the ``[pdf]`` extra (pypdf) is not installed.
|
|
128
|
+
"""
|
|
129
|
+
if is_pdf(data):
|
|
130
|
+
# Imported here so that ``import euinvoice`` and XML parsing never need pypdf (the ``[pdf]`` extra);
|
|
131
|
+
# without it, importing ``euinvoice.facturx`` raises an ImportError naming the extra.
|
|
132
|
+
from euinvoice import facturx
|
|
133
|
+
|
|
134
|
+
data = facturx.extract(data).xml
|
|
135
|
+
root = _xml.parse(data)
|
|
136
|
+
return ubl.read(root) if detect_root(root).syntax is Syntax.UBL else cii.read(root)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _target_syntax(profile: profiles.Profile, syntax: Syntax | str | None) -> Syntax:
|
|
140
|
+
"""The syntax to write: ``syntax`` if the profile supports it, else the profile's only one."""
|
|
141
|
+
if syntax is None:
|
|
142
|
+
if len(profile.syntaxes) != 1:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"profile {profile.id!r} supports {', '.join(sorted(profile.syntaxes))}; pass syntax= to choose one"
|
|
145
|
+
)
|
|
146
|
+
(only,) = profile.syntaxes
|
|
147
|
+
return only
|
|
148
|
+
target = Syntax(syntax)
|
|
149
|
+
if target not in profile.syntaxes:
|
|
150
|
+
raise UnsupportedDocumentError(
|
|
151
|
+
f"profile {profile.id!r} does not support {target.upper()}; it supports "
|
|
152
|
+
f"{', '.join(sorted(profile.syntaxes))}"
|
|
153
|
+
)
|
|
154
|
+
return target
|
euinvoice/_syntax.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""The :class:`Syntax` enum, a leaf module so ``calc`` can name a syntax without importing ``syntax``.
|
|
2
|
+
|
|
3
|
+
The dependency direction is ``model`` ← ``calc`` ← ``syntax`` (CLAUDE.md); the public path is
|
|
4
|
+
:class:`euinvoice.syntax.Syntax`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import enum
|
|
8
|
+
|
|
9
|
+
__all__ = ["Syntax"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Syntax(enum.StrEnum):
|
|
13
|
+
"""An XML syntax for EN 16931 invoices."""
|
|
14
|
+
|
|
15
|
+
UBL = "ubl"
|
|
16
|
+
CII = "cii"
|