xrechnung 0.1.0__tar.gz

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,5 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ build/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xrechnung.dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.5
2
+ Name: xrechnung
3
+ Version: 0.1.0
4
+ Summary: Python client for the xrechnung.dev API: generate, validate (official KoSIT rules) and parse XRechnung and ZUGFeRD e-invoices. Zero dependencies, no JVM required.
5
+ Project-URL: Homepage, https://xrechnung.dev
6
+ Project-URL: Documentation, https://xrechnung.dev/xrechnung-api/
7
+ Project-URL: API reference, https://xrechnung.dev/docs
8
+ Project-URL: Error codes, https://xrechnung.dev/xrechnung-fehlercodes/
9
+ Author-email: "xrechnung.dev" <dev@xrechnung.dev>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: e-rechnung,einvoice,en16931,factur-x,kosit,validator,xrechnung,zugferd
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Natural Language :: English
16
+ Classifier: Natural Language :: German
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+
24
+ # xrechnung
25
+
26
+ Python client for the [xrechnung.dev](https://xrechnung.dev) REST API: **generate, validate and parse**
27
+ German EN 16931 e-invoices — XRechnung 3.0 XML and ZUGFeRD 2.x PDF/A-3 — with one function call.
28
+
29
+ - **No JVM required.** Validation runs server-side; you don't install the KoSIT validator or Java.
30
+ - **Always validated with the current official KoSIT rules (XRechnung 3.0.2, Schematron 2.6.0).**
31
+ Every generated document is checked before it is returned, so you never receive an invalid invoice.
32
+ - **Zero dependencies.** Standard library only (`urllib`), Python 3.9+, fully typed.
33
+ - **Free tier:** 10 documents per month without an API key.
34
+ - Every error links to a plain-language explanation, e.g. `BR-DE-15` → https://xrechnung.dev/fehler/br-de-15/
35
+
36
+ ```bash
37
+ pip install xrechnung
38
+ ```
39
+
40
+ ## Validate
41
+
42
+ ```python
43
+ import xrechnung
44
+
45
+ result = xrechnung.validate("rechnung.xml") # path, or raw bytes; XML or ZUGFeRD PDF
46
+ print(result.valid, result.scenario)
47
+ for e in result.errors:
48
+ print(e.code, e.message, e.location)
49
+ print(" how to fix:", e.doc_url) # https://xrechnung.dev/fehler/br-de-15/
50
+ ```
51
+
52
+ `result.warnings` has the same shape; `result.raw` is the full JSON report.
53
+
54
+ ## Generate
55
+
56
+ ```python
57
+ invoice = {
58
+ "number": "RE-2026-0001", "issue_date": "2026-09-07",
59
+ "seller": {"name": "Beispiel GmbH", "vat_id": "DE123456789", "email": "rechnung@beispiel.de",
60
+ "phone": "+49 30 1234567", "contact_name": "Erika Muster",
61
+ "address": {"street": "Musterstr. 1", "postcode": "10115", "city": "Berlin"}},
62
+ "buyer": {"name": "Kunde AG", "address": {"street": "Kundenweg 9", "postcode": "80331", "city": "München"}},
63
+ "buyer_reference": "04011000-12345-67",
64
+ "lines": [{"description": "Beratung", "quantity": "10", "unit": "HUR", "unit_price": "120.00", "vat_rate": "19"}],
65
+ "payment": {"iban": "DE02120300000000202051", "due_date": "2026-09-21", "terms": "14 Tage netto"},
66
+ }
67
+
68
+ xml = xrechnung.generate(invoice) # XRechnung (CII XML) as bytes
69
+ pdf = xrechnung.generate(invoice, format="zugferd", language="en") # ZUGFeRD PDF/A-3 as bytes
70
+ report = xrechnung.generate(invoice, output="json") # dict: base64 file + validation report
71
+ ```
72
+
73
+ `Decimal` and `date` values are serialised as strings, so you can pass them directly.
74
+ The invoice fields follow the [OpenAPI schema](https://xrechnung.dev/docs) (tax categories S/Z/E/AE/K/G/O,
75
+ `small_business` for §19 UStG, credit notes via `type_code` 381 and `preceding_invoice_number`).
76
+
77
+ ## Parse
78
+
79
+ ```python
80
+ data = xrechnung.parse("eingang.pdf") # {"syntax": "CII", "number": ..., "seller": {...}, "lines": [...], ...}
81
+ ```
82
+
83
+ ## API key, usage, errors
84
+
85
+ ```python
86
+ client = xrechnung.Client(api_key="xr_live_...") # or set XRECHNUNG_API_KEY
87
+ client.validate(b"<Invoice ...>")
88
+ client.usage() # this month's usage
89
+ ```
90
+
91
+ | HTTP | Exception | Meaning |
92
+ |------|-----------|---------|
93
+ | 401 | `xrechnung.AuthenticationError` | invalid or inactive API key |
94
+ | 402 | `xrechnung.PaymentRequiredError` | payment / plan required |
95
+ | 429 | `xrechnung.RateLimitError` | monthly limit reached or too many requests |
96
+ | other | `xrechnung.XRechnungError` | base class; `.status`, `.message` (the server's text), `.body` |
97
+
98
+ Limits: 5 MB per document, 60 s timeout. Plans: Free 10 / Developer 200 / Business 1,000 / Unlimited
99
+ documents per month — see https://xrechnung.dev/#pricing.
100
+
101
+ The same client is also published as [`zugferd`](https://pypi.org/project/zugferd/) (`import zugferd`).
102
+
103
+ ---
104
+
105
+ ## Deutsch
106
+
107
+ Python-Client für die [xrechnung.dev](https://xrechnung.dev)-API: **E-Rechnungen erzeugen, prüfen und lesen**
108
+ — XRechnung 3.0 (XML) und ZUGFeRD 2.x (PDF/A-3).
109
+
110
+ - **Keine JVM nötig.** Sie installieren weder Java noch den KoSIT-Validator.
111
+ - **Immer mit den aktuellen offiziellen KoSIT-Regeln geprüft (XRechnung 3.0.2, Schematron 2.6.0).**
112
+ Jede erzeugte Rechnung wird vor der Auslieferung validiert.
113
+ - **Keine Abhängigkeiten**, nur Standardbibliothek, Python ab 3.9.
114
+ - **Kostenlos:** 10 Dokumente pro Monat ohne API-Schlüssel.
115
+ - Zu jedem Fehlercode gibt es eine Erklärung mit Lösung, z. B. https://xrechnung.dev/fehler/br-de-15/
116
+
117
+ ```python
118
+ import xrechnung
119
+
120
+ ergebnis = xrechnung.validate("rechnung.xml")
121
+ if not ergebnis.valid:
122
+ for f in ergebnis.errors:
123
+ print(f.code, f.message, f.doc_url)
124
+
125
+ pdf = xrechnung.generate(rechnung, format="zugferd") # ZUGFeRD-PDF
126
+ open("RE-2026-0001.pdf", "wb").write(pdf)
127
+ ```
128
+
129
+ Fehler 401 (Schlüssel ungültig), 402 (Zahlung erforderlich) und 429 (Monatslimit erreicht) werden als
130
+ eigene Exceptions mit der Meldung des Servers ausgelöst.
131
+
132
+ ## License
133
+
134
+ MIT
@@ -0,0 +1,111 @@
1
+ # xrechnung
2
+
3
+ Python client for the [xrechnung.dev](https://xrechnung.dev) REST API: **generate, validate and parse**
4
+ German EN 16931 e-invoices — XRechnung 3.0 XML and ZUGFeRD 2.x PDF/A-3 — with one function call.
5
+
6
+ - **No JVM required.** Validation runs server-side; you don't install the KoSIT validator or Java.
7
+ - **Always validated with the current official KoSIT rules (XRechnung 3.0.2, Schematron 2.6.0).**
8
+ Every generated document is checked before it is returned, so you never receive an invalid invoice.
9
+ - **Zero dependencies.** Standard library only (`urllib`), Python 3.9+, fully typed.
10
+ - **Free tier:** 10 documents per month without an API key.
11
+ - Every error links to a plain-language explanation, e.g. `BR-DE-15` → https://xrechnung.dev/fehler/br-de-15/
12
+
13
+ ```bash
14
+ pip install xrechnung
15
+ ```
16
+
17
+ ## Validate
18
+
19
+ ```python
20
+ import xrechnung
21
+
22
+ result = xrechnung.validate("rechnung.xml") # path, or raw bytes; XML or ZUGFeRD PDF
23
+ print(result.valid, result.scenario)
24
+ for e in result.errors:
25
+ print(e.code, e.message, e.location)
26
+ print(" how to fix:", e.doc_url) # https://xrechnung.dev/fehler/br-de-15/
27
+ ```
28
+
29
+ `result.warnings` has the same shape; `result.raw` is the full JSON report.
30
+
31
+ ## Generate
32
+
33
+ ```python
34
+ invoice = {
35
+ "number": "RE-2026-0001", "issue_date": "2026-09-07",
36
+ "seller": {"name": "Beispiel GmbH", "vat_id": "DE123456789", "email": "rechnung@beispiel.de",
37
+ "phone": "+49 30 1234567", "contact_name": "Erika Muster",
38
+ "address": {"street": "Musterstr. 1", "postcode": "10115", "city": "Berlin"}},
39
+ "buyer": {"name": "Kunde AG", "address": {"street": "Kundenweg 9", "postcode": "80331", "city": "München"}},
40
+ "buyer_reference": "04011000-12345-67",
41
+ "lines": [{"description": "Beratung", "quantity": "10", "unit": "HUR", "unit_price": "120.00", "vat_rate": "19"}],
42
+ "payment": {"iban": "DE02120300000000202051", "due_date": "2026-09-21", "terms": "14 Tage netto"},
43
+ }
44
+
45
+ xml = xrechnung.generate(invoice) # XRechnung (CII XML) as bytes
46
+ pdf = xrechnung.generate(invoice, format="zugferd", language="en") # ZUGFeRD PDF/A-3 as bytes
47
+ report = xrechnung.generate(invoice, output="json") # dict: base64 file + validation report
48
+ ```
49
+
50
+ `Decimal` and `date` values are serialised as strings, so you can pass them directly.
51
+ The invoice fields follow the [OpenAPI schema](https://xrechnung.dev/docs) (tax categories S/Z/E/AE/K/G/O,
52
+ `small_business` for §19 UStG, credit notes via `type_code` 381 and `preceding_invoice_number`).
53
+
54
+ ## Parse
55
+
56
+ ```python
57
+ data = xrechnung.parse("eingang.pdf") # {"syntax": "CII", "number": ..., "seller": {...}, "lines": [...], ...}
58
+ ```
59
+
60
+ ## API key, usage, errors
61
+
62
+ ```python
63
+ client = xrechnung.Client(api_key="xr_live_...") # or set XRECHNUNG_API_KEY
64
+ client.validate(b"<Invoice ...>")
65
+ client.usage() # this month's usage
66
+ ```
67
+
68
+ | HTTP | Exception | Meaning |
69
+ |------|-----------|---------|
70
+ | 401 | `xrechnung.AuthenticationError` | invalid or inactive API key |
71
+ | 402 | `xrechnung.PaymentRequiredError` | payment / plan required |
72
+ | 429 | `xrechnung.RateLimitError` | monthly limit reached or too many requests |
73
+ | other | `xrechnung.XRechnungError` | base class; `.status`, `.message` (the server's text), `.body` |
74
+
75
+ Limits: 5 MB per document, 60 s timeout. Plans: Free 10 / Developer 200 / Business 1,000 / Unlimited
76
+ documents per month — see https://xrechnung.dev/#pricing.
77
+
78
+ The same client is also published as [`zugferd`](https://pypi.org/project/zugferd/) (`import zugferd`).
79
+
80
+ ---
81
+
82
+ ## Deutsch
83
+
84
+ Python-Client für die [xrechnung.dev](https://xrechnung.dev)-API: **E-Rechnungen erzeugen, prüfen und lesen**
85
+ — XRechnung 3.0 (XML) und ZUGFeRD 2.x (PDF/A-3).
86
+
87
+ - **Keine JVM nötig.** Sie installieren weder Java noch den KoSIT-Validator.
88
+ - **Immer mit den aktuellen offiziellen KoSIT-Regeln geprüft (XRechnung 3.0.2, Schematron 2.6.0).**
89
+ Jede erzeugte Rechnung wird vor der Auslieferung validiert.
90
+ - **Keine Abhängigkeiten**, nur Standardbibliothek, Python ab 3.9.
91
+ - **Kostenlos:** 10 Dokumente pro Monat ohne API-Schlüssel.
92
+ - Zu jedem Fehlercode gibt es eine Erklärung mit Lösung, z. B. https://xrechnung.dev/fehler/br-de-15/
93
+
94
+ ```python
95
+ import xrechnung
96
+
97
+ ergebnis = xrechnung.validate("rechnung.xml")
98
+ if not ergebnis.valid:
99
+ for f in ergebnis.errors:
100
+ print(f.code, f.message, f.doc_url)
101
+
102
+ pdf = xrechnung.generate(rechnung, format="zugferd") # ZUGFeRD-PDF
103
+ open("RE-2026-0001.pdf", "wb").write(pdf)
104
+ ```
105
+
106
+ Fehler 401 (Schlüssel ungültig), 402 (Zahlung erforderlich) und 429 (Monatslimit erreicht) werden als
107
+ eigene Exceptions mit der Meldung des Servers ausgelöst.
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "xrechnung"
7
+ version = "0.1.0"
8
+ description = "Python client for the xrechnung.dev API: generate, validate (official KoSIT rules) and parse XRechnung and ZUGFeRD e-invoices. Zero dependencies, no JVM required."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "xrechnung.dev", email = "dev@xrechnung.dev" }]
14
+ keywords = ["xrechnung", "zugferd", "factur-x", "e-rechnung", "einvoice", "en16931", "kosit", "validator"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Natural Language :: English",
19
+ "Natural Language :: German",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Office/Business :: Financial :: Accounting",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.urls]
28
+ Homepage = "https://xrechnung.dev"
29
+ Documentation = "https://xrechnung.dev/xrechnung-api/"
30
+ "API reference" = "https://xrechnung.dev/docs"
31
+ "Error codes" = "https://xrechnung.dev/xrechnung-fehlercodes/"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/xrechnung"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["src/xrechnung", "tests", "README.md", "LICENSE"]
@@ -0,0 +1,186 @@
1
+ """Python client for the xrechnung.dev REST API.
2
+
3
+ Generate, validate (official KoSIT rules) and parse German EN 16931 e-invoices:
4
+ XRechnung 3.0 XML and ZUGFeRD 2.x PDF/A-3. Zero dependencies, no JVM required.
5
+
6
+ import xrechnung
7
+ result = xrechnung.validate("rechnung.xml")
8
+ for e in result.errors:
9
+ print(e.code, e.message, e.doc_url)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ import uuid
17
+ import urllib.error
18
+ import urllib.request
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Optional, Union
22
+
23
+ __version__ = "0.1.0"
24
+ __all__ = [
25
+ "Client", "Finding", "ValidationResult", "XRechnungError", "AuthenticationError",
26
+ "PaymentRequiredError", "RateLimitError", "validate", "parse", "generate", "usage", "doc_url",
27
+ ]
28
+
29
+ DEFAULT_BASE_URL = "https://xrechnung.dev"
30
+ FileInput = Union[bytes, bytearray, str, "os.PathLike[str]"]
31
+
32
+
33
+ class XRechnungError(Exception):
34
+ """Any non-2xx answer from the API. `message` is the server's own text."""
35
+
36
+ def __init__(self, status: int, message: str, body: Any = None):
37
+ super().__init__(f"HTTP {status}: {message}")
38
+ self.status, self.message, self.body = status, message, body
39
+
40
+
41
+ class AuthenticationError(XRechnungError):
42
+ """401: invalid or inactive API key."""
43
+
44
+
45
+ class PaymentRequiredError(XRechnungError):
46
+ """402: plan or payment required."""
47
+
48
+
49
+ class RateLimitError(XRechnungError):
50
+ """429: monthly document limit reached or too many requests per second."""
51
+
52
+
53
+ _ERRORS = {401: AuthenticationError, 402: PaymentRequiredError, 429: RateLimitError}
54
+
55
+
56
+ def doc_url(code: str) -> str:
57
+ """Explanation page for a rule code, e.g. BR-DE-15 -> https://xrechnung.dev/fehler/br-de-15/"""
58
+ return f"https://xrechnung.dev/fehler/{re.sub(r'-[ab]$', '', code.strip().lower())}/"
59
+
60
+
61
+ @dataclass
62
+ class Finding:
63
+ code: str
64
+ message: str
65
+ location: str = ""
66
+
67
+ @property
68
+ def doc_url(self) -> str:
69
+ return doc_url(self.code)
70
+
71
+
72
+ def _findings(items: Any) -> List[Finding]:
73
+ return [Finding(str(i.get("code", "")), str(i.get("message", "")), str(i.get("location") or ""))
74
+ for i in items or []]
75
+
76
+
77
+ @dataclass
78
+ class ValidationResult:
79
+ valid: bool
80
+ scenario: str = ""
81
+ profile: str = ""
82
+ errors: List[Finding] = field(default_factory=list)
83
+ warnings: List[Finding] = field(default_factory=list)
84
+ raw: Dict[str, Any] = field(default_factory=dict, repr=False)
85
+
86
+ def __bool__(self) -> bool:
87
+ return self.valid
88
+
89
+ @classmethod
90
+ def from_dict(cls, d: Dict[str, Any]) -> "ValidationResult":
91
+ return cls(bool(d.get("valid")), d.get("scenario", ""), d.get("profile", ""),
92
+ _findings(d.get("errors")), _findings(d.get("warnings")), d)
93
+
94
+
95
+ def _read(file: FileInput) -> "tuple[bytes, str]":
96
+ if isinstance(file, (bytes, bytearray)):
97
+ data = bytes(file)
98
+ return data, "invoice.pdf" if data[:5] == b"%PDF-" else "invoice.xml"
99
+ p = Path(file)
100
+ return p.read_bytes(), p.name
101
+
102
+
103
+ def _multipart(data: bytes, filename: str) -> "tuple[bytes, str]":
104
+ boundary = uuid.uuid4().hex
105
+ name = filename.replace('"', "").replace("\r", "").replace("\n", "")
106
+ body = (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
107
+ f"Content-Type: application/octet-stream\r\n\r\n").encode() + data + f"\r\n--{boundary}--\r\n".encode()
108
+ return body, f"multipart/form-data; boundary={boundary}"
109
+
110
+
111
+ class Client:
112
+ """API client. `api_key` defaults to $XRECHNUNG_API_KEY; without a key the free tier
113
+ allows 10 documents per month per IP."""
114
+
115
+ def __init__(self, api_key: Optional[str] = None, base_url: str = DEFAULT_BASE_URL, timeout: float = 90):
116
+ self.api_key = api_key if api_key is not None else os.environ.get("XRECHNUNG_API_KEY") or None
117
+ self.base_url = base_url.rstrip("/")
118
+ self.timeout = timeout
119
+
120
+ def _request(self, method: str, path: str, body: Optional[bytes] = None,
121
+ content_type: Optional[str] = None) -> "tuple[bytes, str]":
122
+ headers = {"Accept": "application/json, application/xml, application/pdf",
123
+ "User-Agent": f"xrechnung-python/{__version__}"}
124
+ if content_type:
125
+ headers["Content-Type"] = content_type
126
+ if self.api_key:
127
+ headers["X-Api-Key"] = self.api_key
128
+ req = urllib.request.Request(self.base_url + path, data=body, headers=headers, method=method)
129
+ try:
130
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
131
+ return r.read(), r.headers.get("Content-Type", "")
132
+ except urllib.error.HTTPError as e:
133
+ raw = e.read()
134
+ try:
135
+ payload: Any = json.loads(raw)
136
+ detail = payload.get("detail", payload) if isinstance(payload, dict) else payload
137
+ except ValueError:
138
+ payload = detail = raw.decode("utf-8", "replace")
139
+ if isinstance(detail, list): # 422 from request validation
140
+ detail = "; ".join(f"{'.'.join(map(str, d.get('loc', [])))}: {d.get('msg')}"
141
+ if isinstance(d, dict) else str(d) for d in detail)
142
+ raise _ERRORS.get(e.code, XRechnungError)(e.code, str(detail) or str(e.reason), payload) from None
143
+
144
+ def _json(self, method: str, path: str, body: Optional[bytes] = None, ct: Optional[str] = None) -> Any:
145
+ return json.loads(self._request(method, path, body, ct)[0])
146
+
147
+ def validate(self, file: FileInput) -> ValidationResult:
148
+ """Validate an XRechnung / ZUGFeRD / EN 16931 document (XML or PDF): bytes or a path."""
149
+ return ValidationResult.from_dict(self._json("POST", "/v1/validate", *_multipart(*_read(file))))
150
+
151
+ def parse(self, file: FileInput) -> Dict[str, Any]:
152
+ """Read an e-invoice (XML or ZUGFeRD PDF) into a dict."""
153
+ return self._json("POST", "/v1/parse", *_multipart(*_read(file)))
154
+
155
+ def generate(self, invoice: Dict[str, Any], format: str = "xrechnung", language: str = "de",
156
+ output: str = "binary") -> Any:
157
+ """Generate a KoSIT-validated invoice.
158
+
159
+ format: "xrechnung" (CII XML) or "zugferd" (PDF/A-3); language: "de"/"en" (zugferd PDF only).
160
+ output="binary" returns the file as bytes; output="json" returns a dict with base64 + validation report.
161
+ """
162
+ body = json.dumps({"invoice": invoice, "format": format, "language": language, "output": output},
163
+ default=str).encode()
164
+ data, _ = self._request("POST", "/v1/invoices", body, "application/json")
165
+ return json.loads(data) if output == "json" else data
166
+
167
+ def usage(self) -> Dict[str, Any]:
168
+ """Current month usage for your key (or your IP on the free tier)."""
169
+ return self._json("GET", "/v1/usage")
170
+
171
+
172
+ def validate(file: FileInput, api_key: Optional[str] = None) -> ValidationResult:
173
+ return Client(api_key).validate(file)
174
+
175
+
176
+ def parse(file: FileInput, api_key: Optional[str] = None) -> Dict[str, Any]:
177
+ return Client(api_key).parse(file)
178
+
179
+
180
+ def generate(invoice: Dict[str, Any], format: str = "xrechnung", language: str = "de",
181
+ output: str = "binary", api_key: Optional[str] = None) -> Any:
182
+ return Client(api_key).generate(invoice, format, language, output)
183
+
184
+
185
+ def usage(api_key: Optional[str] = None) -> Dict[str, Any]:
186
+ return Client(api_key).usage()
File without changes
@@ -0,0 +1,167 @@
1
+ """Offline tests: urllib.request.urlopen is mocked, no network calls."""
2
+ import io
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import unittest
7
+ import urllib.error
8
+ from email.message import Message
9
+ from unittest import mock
10
+
11
+ import xrechnung
12
+
13
+ REPORT = {"valid": False, "scenario": "EN16931 XRechnung (CII)",
14
+ "errors": [{"code": "BR-DE-15", "message": "Buyer reference fehlt", "location": "/rsm:CII"},
15
+ {"code": "BR-CO-10-a", "message": "Summe", "location": "/x"}],
16
+ "warnings": [{"code": "PEPPOL-EN16931-R005", "message": "w", "location": None}],
17
+ "counts": {"errors": 2, "warnings": 1}}
18
+
19
+
20
+ class FakeResp(io.BytesIO):
21
+ def __init__(self, data, ctype="application/json"):
22
+ super().__init__(data)
23
+ self.headers = {"Content-Type": ctype}
24
+
25
+ def __enter__(self): return self
26
+ def __exit__(self, *a): self.close()
27
+
28
+
29
+ def http_error(code, body):
30
+ return urllib.error.HTTPError("https://xrechnung.dev/x", code, "err", Message(), io.BytesIO(body))
31
+
32
+
33
+ class Base(unittest.TestCase):
34
+ def setUp(self):
35
+ p = mock.patch("urllib.request.urlopen")
36
+ self.urlopen = p.start()
37
+ self.addCleanup(p.stop)
38
+ os.environ.pop("XRECHNUNG_API_KEY", None)
39
+
40
+ def reply(self, data, ctype="application/json"):
41
+ self.urlopen.return_value = FakeResp(json.dumps(data).encode() if not isinstance(data, bytes) else data, ctype)
42
+
43
+ @property
44
+ def req(self):
45
+ return self.urlopen.call_args[0][0]
46
+
47
+
48
+ class TestValidate(Base):
49
+ def test_bytes_result_and_doc_urls(self):
50
+ self.reply(REPORT)
51
+ r = xrechnung.validate(b"<Invoice/>")
52
+ self.assertFalse(r.valid)
53
+ self.assertFalse(r)
54
+ self.assertEqual(r.scenario, "EN16931 XRechnung (CII)")
55
+ self.assertEqual([e.code for e in r.errors], ["BR-DE-15", "BR-CO-10-a"])
56
+ self.assertEqual(r.errors[0].location, "/rsm:CII")
57
+ self.assertEqual(r.errors[0].doc_url, "https://xrechnung.dev/fehler/br-de-15/")
58
+ self.assertEqual(r.errors[1].doc_url, "https://xrechnung.dev/fehler/br-co-10/")
59
+ self.assertEqual(r.warnings[0].location, "")
60
+ self.assertEqual(r.raw["counts"]["errors"], 2)
61
+ req = self.req
62
+ self.assertEqual(req.full_url, "https://xrechnung.dev/v1/validate")
63
+ self.assertEqual(req.get_method(), "POST")
64
+ self.assertIsNone(req.get_header("X-api-key")) # free tier: no key sent
65
+ ct = req.get_header("Content-type")
66
+ self.assertTrue(ct.startswith("multipart/form-data; boundary="))
67
+ boundary = ct.split("=", 1)[1]
68
+ self.assertIn(b'name="file"; filename="invoice.xml"', req.data)
69
+ self.assertIn(b"\r\n\r\n<Invoice/>\r\n--" + boundary.encode() + b"--\r\n", req.data)
70
+
71
+ def test_path_and_pdf_sniff_and_key(self):
72
+ self.reply({"valid": True, "errors": [], "warnings": []})
73
+ with tempfile.TemporaryDirectory() as d:
74
+ p = os.path.join(d, "rechnung.pdf")
75
+ with open(p, "wb") as f:
76
+ f.write(b"%PDF-1.7 x")
77
+ r = xrechnung.Client(api_key="xr_live_k").validate(p)
78
+ self.assertTrue(r.valid)
79
+ self.assertIn(b'filename="rechnung.pdf"', self.req.data)
80
+ self.assertEqual(self.req.get_header("X-api-key"), "xr_live_k")
81
+ self.reply({"valid": True})
82
+ xrechnung.validate(b"%PDF-1.7")
83
+ self.assertIn(b'filename="invoice.pdf"', self.req.data)
84
+
85
+ def test_env_key(self):
86
+ os.environ["XRECHNUNG_API_KEY"] = "envkey"
87
+ self.addCleanup(os.environ.pop, "XRECHNUNG_API_KEY", None)
88
+ self.reply({"valid": True})
89
+ xrechnung.validate(b"<x/>")
90
+ self.assertEqual(self.req.get_header("X-api-key"), "envkey")
91
+
92
+
93
+ class TestOther(Base):
94
+ def test_parse(self):
95
+ self.reply({"syntax": "CII", "number": "RE-1"})
96
+ self.assertEqual(xrechnung.parse(b"<x/>")["number"], "RE-1")
97
+ self.assertEqual(self.req.full_url, "https://xrechnung.dev/v1/parse")
98
+
99
+ def test_generate_binary(self):
100
+ self.reply(b"%PDF-1.7 data", "application/pdf")
101
+ inv = {"number": "RE-1", "issue_date": "2026-09-07"}
102
+ out = xrechnung.generate(inv, format="zugferd", language="en")
103
+ self.assertEqual(out, b"%PDF-1.7 data")
104
+ self.assertEqual(self.req.full_url, "https://xrechnung.dev/v1/invoices")
105
+ self.assertEqual(self.req.get_header("Content-type"), "application/json")
106
+ self.assertEqual(json.loads(self.req.data),
107
+ {"invoice": inv, "format": "zugferd", "language": "en", "output": "binary"})
108
+
109
+ def test_generate_json_and_decimal(self):
110
+ from decimal import Decimal
111
+ self.reply({"file": "PD94", "validation": {"valid": True}})
112
+ out = xrechnung.generate({"lines": [{"unit_price": Decimal("120.00")}]}, output="json")
113
+ self.assertEqual(out["validation"]["valid"], True)
114
+ self.assertEqual(json.loads(self.req.data)["invoice"]["lines"][0]["unit_price"], "120.00")
115
+
116
+ def test_usage_and_base_url(self):
117
+ self.reply({"used": 3, "limit": 10})
118
+ self.assertEqual(xrechnung.Client(base_url="http://localhost:8000/").usage()["used"], 3)
119
+ self.assertEqual(self.req.full_url, "http://localhost:8000/v1/usage")
120
+ self.assertEqual(self.req.get_method(), "GET")
121
+
122
+
123
+ class TestErrors(Base):
124
+ def check(self, code, cls, body, msg):
125
+ self.urlopen.side_effect = http_error(code, body)
126
+ with self.assertRaises(cls) as cm:
127
+ xrechnung.validate(b"<x/>")
128
+ self.assertIsInstance(cm.exception, xrechnung.XRechnungError)
129
+ self.assertEqual(cm.exception.status, code)
130
+ self.assertEqual(cm.exception.message, msg)
131
+
132
+ def test_401(self):
133
+ self.check(401, xrechnung.AuthenticationError, b'{"detail":"invalid or inactive API key"}',
134
+ "invalid or inactive API key")
135
+
136
+ def test_402(self):
137
+ self.check(402, xrechnung.PaymentRequiredError, b'{"detail":"payment required"}', "payment required")
138
+
139
+ def test_429(self):
140
+ m = "monthly limit reached (10 documents on the Free plan). Upgrade at https://xrechnung.dev/#pricing"
141
+ self.check(429, xrechnung.RateLimitError, json.dumps({"detail": m}).encode(), m)
142
+
143
+ def test_422_list_and_non_json(self):
144
+ self.check(422, xrechnung.XRechnungError,
145
+ b'{"detail":[{"loc":["body","invoice","number"],"msg":"Field required","type":"missing"}]}',
146
+ "body.invoice.number: Field required")
147
+ self.check(500, xrechnung.XRechnungError, b"Internal Server Error", "Internal Server Error")
148
+
149
+
150
+ class TestDocUrl(unittest.TestCase):
151
+ def test_slugs(self):
152
+ self.assertEqual(xrechnung.doc_url("BR-DE-15"), "https://xrechnung.dev/fehler/br-de-15/")
153
+ self.assertEqual(xrechnung.doc_url("BR-CO-10-b"), "https://xrechnung.dev/fehler/br-co-10/")
154
+ self.assertEqual(xrechnung.doc_url("BR-AE-10"), "https://xrechnung.dev/fehler/br-ae-10/")
155
+
156
+
157
+ class TestZugferdAlias(unittest.TestCase):
158
+ def test_reexport(self):
159
+ import sys
160
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "zugferd", "src"))
161
+ import zugferd
162
+ self.assertIs(zugferd.validate, xrechnung.validate)
163
+ self.assertIs(zugferd.Client, xrechnung.Client)
164
+
165
+
166
+ if __name__ == "__main__":
167
+ unittest.main()
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xrechnung.dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,26 @@
1
+ # zugferd
2
+
3
+ Generate, validate and parse **ZUGFeRD 2.x / Factur-X** (PDF/A-3) and **XRechnung** e-invoices from Python
4
+ through the [xrechnung.dev](https://xrechnung.dev) API.
5
+
6
+ This package is a thin alias of [`xrechnung`](https://pypi.org/project/xrechnung/) — same API, same code.
7
+ Zero dependencies beyond it, **no JVM required**, and every document is always validated with the current
8
+ official KoSIT rules (XRechnung 3.0.2, Schematron 2.6.0).
9
+
10
+ ```bash
11
+ pip install zugferd
12
+ ```
13
+
14
+ ```python
15
+ import zugferd
16
+
17
+ pdf = zugferd.generate(invoice, format="zugferd", language="de") # PDF/A-3 with embedded EN 16931 XML
18
+ open("rechnung.pdf", "wb").write(pdf)
19
+
20
+ result = zugferd.validate("rechnung.pdf")
21
+ for e in result.errors:
22
+ print(e.code, e.message, e.doc_url)
23
+ ```
24
+
25
+ Free tier: 10 documents per month without an API key. See the full documentation in the
26
+ [`xrechnung` README](https://pypi.org/project/xrechnung/). MIT licensed.