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.
Files changed (79) hide show
  1. euinvoice/__init__.py +64 -0
  2. euinvoice/__main__.py +305 -0
  3. euinvoice/_api.py +154 -0
  4. euinvoice/_syntax.py +16 -0
  5. euinvoice/_xml.py +296 -0
  6. euinvoice/calc/__init__.py +74 -0
  7. euinvoice/calc/_categories.py +369 -0
  8. euinvoice/calc/_check.py +243 -0
  9. euinvoice/calc/_common.py +182 -0
  10. euinvoice/calc/_complete.py +230 -0
  11. euinvoice/detection.py +159 -0
  12. euinvoice/errors.py +101 -0
  13. euinvoice/facturx/__init__.py +10 -0
  14. euinvoice/facturx/_embed.py +231 -0
  15. euinvoice/facturx/_extract.py +215 -0
  16. euinvoice/facturx/_pypdf.py +39 -0
  17. euinvoice/facturx/xmp.py +149 -0
  18. euinvoice/model/__init__.py +94 -0
  19. euinvoice/model/_base.py +127 -0
  20. euinvoice/model/allowances.py +106 -0
  21. euinvoice/model/amounts.py +110 -0
  22. euinvoice/model/bt_index.py +110 -0
  23. euinvoice/model/codes/__init__.py +81 -0
  24. euinvoice/model/codes/_generated.py +559 -0
  25. euinvoice/model/codes/derived.py +16 -0
  26. euinvoice/model/codes/enums.py +67 -0
  27. euinvoice/model/datatypes.py +399 -0
  28. euinvoice/model/delivery.py +61 -0
  29. euinvoice/model/documents.py +21 -0
  30. euinvoice/model/invoice.py +168 -0
  31. euinvoice/model/lines.py +149 -0
  32. euinvoice/model/parties.py +180 -0
  33. euinvoice/model/payment.py +68 -0
  34. euinvoice/model/tax.py +31 -0
  35. euinvoice/model/totals.py +38 -0
  36. euinvoice/profiles/__init__.py +41 -0
  37. euinvoice/profiles/_base.py +162 -0
  38. euinvoice/profiles/en16931.py +23 -0
  39. euinvoice/profiles/facturx.py +198 -0
  40. euinvoice/profiles/peppol.py +360 -0
  41. euinvoice/profiles/registry.py +74 -0
  42. euinvoice/profiles/xrechnung.py +455 -0
  43. euinvoice/py.typed +0 -0
  44. euinvoice/report.py +66 -0
  45. euinvoice/syntax/__init__.py +9 -0
  46. euinvoice/syntax/_marks.py +192 -0
  47. euinvoice/syntax/_read_errors.py +55 -0
  48. euinvoice/syntax/cii/__init__.py +12 -0
  49. euinvoice/syntax/cii/_build.py +188 -0
  50. euinvoice/syntax/cii/_lines.py +137 -0
  51. euinvoice/syntax/cii/_parties.py +142 -0
  52. euinvoice/syntax/cii/_read.py +207 -0
  53. euinvoice/syntax/cii/_read_common.py +69 -0
  54. euinvoice/syntax/cii/_read_lines.py +168 -0
  55. euinvoice/syntax/cii/_read_parties.py +193 -0
  56. euinvoice/syntax/cii/_read_settlement.py +287 -0
  57. euinvoice/syntax/cii/_reader.py +171 -0
  58. euinvoice/syntax/cii/_settlement.py +211 -0
  59. euinvoice/syntax/cii/_write.py +136 -0
  60. euinvoice/syntax/result.py +25 -0
  61. euinvoice/syntax/ubl/__init__.py +13 -0
  62. euinvoice/syntax/ubl/_build.py +182 -0
  63. euinvoice/syntax/ubl/_cursor.py +292 -0
  64. euinvoice/syntax/ubl/_lines.py +186 -0
  65. euinvoice/syntax/ubl/_parties.py +183 -0
  66. euinvoice/syntax/ubl/_read.py +424 -0
  67. euinvoice/syntax/ubl/_read_lines.py +283 -0
  68. euinvoice/syntax/ubl/_read_parties.py +277 -0
  69. euinvoice/syntax/ubl/_write.py +378 -0
  70. euinvoice/validation/__init__.py +8 -0
  71. euinvoice/validation/artifacts.py +446 -0
  72. euinvoice/validation/manifest.toml +127 -0
  73. euinvoice/validation/orchestration.py +190 -0
  74. euinvoice/validation/schematron.py +239 -0
  75. euinvoice/validation/xsd.py +158 -0
  76. euinvoice-0.1.0.dist-info/METADATA +212 -0
  77. euinvoice-0.1.0.dist-info/RECORD +79 -0
  78. euinvoice-0.1.0.dist-info/WHEEL +4 -0
  79. 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"