facturx-api 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.
- facturx_api/__init__.py +39 -0
- facturx_api/_client.py +354 -0
- facturx_api/_errors.py +155 -0
- facturx_api/_results.py +81 -0
- facturx_api/cli.py +129 -0
- facturx_api/py.typed +0 -0
- facturx_api-0.1.0.dist-info/METADATA +148 -0
- facturx_api-0.1.0.dist-info/RECORD +12 -0
- facturx_api-0.1.0.dist-info/WHEEL +5 -0
- facturx_api-0.1.0.dist-info/entry_points.txt +2 -0
- facturx_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- facturx_api-0.1.0.dist-info/top_level.txt +1 -0
facturx_api/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""FacturX API Python SDK (standard library only)."""
|
|
2
|
+
|
|
3
|
+
from ._client import FacturX, Transport, __version__, urllib_transport
|
|
4
|
+
from ._errors import (
|
|
5
|
+
ApiError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
ConflictError,
|
|
8
|
+
ConversionPendingError,
|
|
9
|
+
FacturXError,
|
|
10
|
+
InvalidRequestError,
|
|
11
|
+
NotDeliveredError,
|
|
12
|
+
QuotaExceededError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ServerError,
|
|
15
|
+
ServiceUnavailableError,
|
|
16
|
+
TransportError,
|
|
17
|
+
)
|
|
18
|
+
from ._results import ConvertResult, DryRunResult
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ApiError",
|
|
22
|
+
"AuthenticationError",
|
|
23
|
+
"ConflictError",
|
|
24
|
+
"ConversionPendingError",
|
|
25
|
+
"ConvertResult",
|
|
26
|
+
"DryRunResult",
|
|
27
|
+
"FacturX",
|
|
28
|
+
"FacturXError",
|
|
29
|
+
"InvalidRequestError",
|
|
30
|
+
"NotDeliveredError",
|
|
31
|
+
"QuotaExceededError",
|
|
32
|
+
"RateLimitError",
|
|
33
|
+
"ServerError",
|
|
34
|
+
"ServiceUnavailableError",
|
|
35
|
+
"Transport",
|
|
36
|
+
"TransportError",
|
|
37
|
+
"__version__",
|
|
38
|
+
"urllib_transport",
|
|
39
|
+
]
|
facturx_api/_client.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""FacturX API client: Python standard library only, no automatic retry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ._errors import (
|
|
18
|
+
ConversionPendingError,
|
|
19
|
+
FacturXError,
|
|
20
|
+
NotDeliveredError,
|
|
21
|
+
TransportError,
|
|
22
|
+
api_error,
|
|
23
|
+
)
|
|
24
|
+
from ._results import ConvertResult, DryRunResult
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
Response = tuple[int, dict[str, str], bytes]
|
|
29
|
+
Transport = Callable[[str, str, Mapping[str, str], bytes | None], Response]
|
|
30
|
+
FileInput = bytes | str | Path
|
|
31
|
+
|
|
32
|
+
OUTPUTS = ("facturx_pdfa3", "xml_cii")
|
|
33
|
+
TARGETS = ("en16931", "france_2026")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
37
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
38
|
+
return None # A redirect must never carry the API key elsewhere.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def urllib_transport(timeout: float = 120.0) -> Transport:
|
|
42
|
+
"""Default transport: one HTTP request, no redirect, no retry."""
|
|
43
|
+
opener = urllib.request.build_opener(_NoRedirect)
|
|
44
|
+
|
|
45
|
+
def send(method: str, url: str, headers: Mapping[str, str], body: bytes | None) -> Response:
|
|
46
|
+
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
|
|
47
|
+
try:
|
|
48
|
+
response = opener.open(request, timeout=timeout)
|
|
49
|
+
except urllib.error.HTTPError as exc:
|
|
50
|
+
response = exc
|
|
51
|
+
with response:
|
|
52
|
+
return (
|
|
53
|
+
response.status,
|
|
54
|
+
{k.lower(): v for k, v in response.headers.items()},
|
|
55
|
+
response.read(),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return send
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class FacturX:
|
|
62
|
+
"""Client for the FacturX API.
|
|
63
|
+
|
|
64
|
+
There is deliberately no default host: pass ``base_url`` (for example
|
|
65
|
+
``https://api.facturxapi.com``) or set ``FACTURX_BASE_URL``.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
api_key: str | None = None,
|
|
71
|
+
base_url: str | None = None,
|
|
72
|
+
*,
|
|
73
|
+
timeout: float = 120.0,
|
|
74
|
+
transport: Transport | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.api_key = api_key if api_key is not None else os.environ.get("FACTURX_API_KEY", "")
|
|
77
|
+
base = base_url if base_url is not None else os.environ.get("FACTURX_BASE_URL", "")
|
|
78
|
+
if not base:
|
|
79
|
+
raise ValueError("base_url is required (or set FACTURX_BASE_URL).")
|
|
80
|
+
if urllib.parse.urlparse(base).scheme != "https":
|
|
81
|
+
raise ValueError("base_url must use https.")
|
|
82
|
+
self.base_url = base.rstrip("/")
|
|
83
|
+
self._send = transport or urllib_transport(timeout)
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------ public
|
|
86
|
+
|
|
87
|
+
def dry_run(
|
|
88
|
+
self,
|
|
89
|
+
invoice_data: Mapping[str, Any] | str | None = None,
|
|
90
|
+
*,
|
|
91
|
+
file: FileInput | None = None,
|
|
92
|
+
output: str = "facturx_pdfa3",
|
|
93
|
+
validation_target: str = "en16931",
|
|
94
|
+
options: Mapping[str, Any] | None = None,
|
|
95
|
+
lang: str | None = None,
|
|
96
|
+
idempotency_key: str | None = None,
|
|
97
|
+
) -> DryRunResult:
|
|
98
|
+
"""Check an invoice without producing a deliverable (does not consume quota)."""
|
|
99
|
+
key = idempotency_key or str(uuid.uuid4())
|
|
100
|
+
data = self._convert_call(
|
|
101
|
+
invoice_data, file, output, validation_target, options, lang, key, dry_run=True
|
|
102
|
+
)[0]
|
|
103
|
+
return DryRunResult(raw=data, idempotency_key=key)
|
|
104
|
+
|
|
105
|
+
def convert(
|
|
106
|
+
self,
|
|
107
|
+
invoice_data: Mapping[str, Any] | str | None = None,
|
|
108
|
+
*,
|
|
109
|
+
file: FileInput | None = None,
|
|
110
|
+
output: str = "facturx_pdfa3",
|
|
111
|
+
validation_target: str = "en16931",
|
|
112
|
+
options: Mapping[str, Any] | None = None,
|
|
113
|
+
lang: str | None = None,
|
|
114
|
+
idempotency_key: str | None = None,
|
|
115
|
+
) -> ConvertResult:
|
|
116
|
+
"""Final conversion (1 processed invoice on the quota, refunded without deliverable).
|
|
117
|
+
|
|
118
|
+
Keep ``idempotency_key`` (on the result or on the raised error) to resend
|
|
119
|
+
the identical request safely; reusing it with a different payload is a
|
|
120
|
+
``409 idempotency_conflict``.
|
|
121
|
+
"""
|
|
122
|
+
key = idempotency_key or str(uuid.uuid4())
|
|
123
|
+
data, headers = self._convert_call(
|
|
124
|
+
invoice_data, file, output, validation_target, options, lang, key, dry_run=False
|
|
125
|
+
)
|
|
126
|
+
failed = _final_checks(data, output)
|
|
127
|
+
if failed:
|
|
128
|
+
raise NotDeliveredError(data, failed, idempotency_key=key)
|
|
129
|
+
result = data["result"]
|
|
130
|
+
try:
|
|
131
|
+
xml = base64.b64decode(result["xml"], validate=True)
|
|
132
|
+
pdf = self._pdf(result) if output == "facturx_pdfa3" else None
|
|
133
|
+
except (ValueError, KeyError) as exc:
|
|
134
|
+
raise NotDeliveredError(
|
|
135
|
+
data, ["result.xml/pdf decodable"], idempotency_key=key
|
|
136
|
+
) from exc
|
|
137
|
+
return ConvertResult(
|
|
138
|
+
raw=data, idempotency_key=key, xml=xml, pdf=pdf, output=output, headers=headers
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def validate(
|
|
142
|
+
self,
|
|
143
|
+
file: FileInput,
|
|
144
|
+
*,
|
|
145
|
+
validation_target: str | None = None,
|
|
146
|
+
validation_profile: str | None = None,
|
|
147
|
+
lang: str | None = None,
|
|
148
|
+
idempotency_key: str | None = None,
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""Validate an existing Factur-X PDF or XML. Returns the API report as is."""
|
|
151
|
+
query = _query(
|
|
152
|
+
validation_target=validation_target, validation_profile=validation_profile, lang=lang
|
|
153
|
+
)
|
|
154
|
+
filename, content = _read_file(file)
|
|
155
|
+
body, content_type = _multipart({}, ("file", filename, content), seed=idempotency_key)
|
|
156
|
+
headers = {"Content-Type": content_type}
|
|
157
|
+
if idempotency_key:
|
|
158
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
159
|
+
return self._request("POST", "/api/v1/validate" + query, body, headers, idempotency_key)[0]
|
|
160
|
+
|
|
161
|
+
def extract(
|
|
162
|
+
self,
|
|
163
|
+
file: FileInput,
|
|
164
|
+
*,
|
|
165
|
+
validate: bool = False,
|
|
166
|
+
lang: str | None = None,
|
|
167
|
+
idempotency_key: str | None = None,
|
|
168
|
+
) -> dict[str, Any]:
|
|
169
|
+
"""Extract the XML embedded in a Factur-X PDF (``result`` as returned by the API).
|
|
170
|
+
|
|
171
|
+
The response carries ``xml`` (base64), ``xmlSize`` and the detected ``profile``;
|
|
172
|
+
``validate=True`` also runs validation in the same processed-invoice call.
|
|
173
|
+
"""
|
|
174
|
+
query = _query(validate="true" if validate else None, lang=lang)
|
|
175
|
+
filename, content = _read_file(file)
|
|
176
|
+
body, content_type = _multipart({}, ("file", filename, content), seed=idempotency_key)
|
|
177
|
+
headers = {"Content-Type": content_type}
|
|
178
|
+
if idempotency_key:
|
|
179
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
180
|
+
return self._request("POST", "/api/v1/extract" + query, body, headers, idempotency_key)[0]
|
|
181
|
+
|
|
182
|
+
def meta(self) -> dict[str, Any]:
|
|
183
|
+
"""``GET /meta``: public versions, limits and capabilities."""
|
|
184
|
+
return self._request("GET", "/meta", None, {}, None, auth=False)[0]
|
|
185
|
+
|
|
186
|
+
def integration_contract(self) -> dict[str, Any]:
|
|
187
|
+
"""``GET /api/v1/integration-contract``: public ERP integration contract."""
|
|
188
|
+
return self._request("GET", "/api/v1/integration-contract", None, {}, None, auth=False)[0]
|
|
189
|
+
|
|
190
|
+
# ----------------------------------------------------------------- private
|
|
191
|
+
|
|
192
|
+
def _convert_call(
|
|
193
|
+
self,
|
|
194
|
+
invoice_data: Mapping[str, Any] | str | None,
|
|
195
|
+
file: FileInput | None,
|
|
196
|
+
output: str,
|
|
197
|
+
validation_target: str,
|
|
198
|
+
options: Mapping[str, Any] | None,
|
|
199
|
+
lang: str | None,
|
|
200
|
+
key: str,
|
|
201
|
+
*,
|
|
202
|
+
dry_run: bool,
|
|
203
|
+
) -> tuple[dict[str, Any], dict[str, str]]:
|
|
204
|
+
if output not in OUTPUTS:
|
|
205
|
+
raise ValueError(f"output must be one of {OUTPUTS}")
|
|
206
|
+
if validation_target not in TARGETS:
|
|
207
|
+
raise ValueError(f"validation_target must be one of {TARGETS}")
|
|
208
|
+
if invoice_data is None and file is None:
|
|
209
|
+
raise ValueError("Provide invoice_data, file, or both.")
|
|
210
|
+
fields: dict[str, str] = {"output": output, "validation_target": validation_target}
|
|
211
|
+
if invoice_data is not None:
|
|
212
|
+
fields["invoice_data"] = _json(invoice_data)
|
|
213
|
+
if options is not None:
|
|
214
|
+
fields["options"] = _json(options)
|
|
215
|
+
upload = ("file", *_read_file(file)) if file is not None else None
|
|
216
|
+
body, content_type = _multipart(fields, upload, seed=key)
|
|
217
|
+
query = _query(dry_run="true" if dry_run else None, lang=lang)
|
|
218
|
+
headers = {"Content-Type": content_type, "Idempotency-Key": key}
|
|
219
|
+
data, response_headers, status = self._request(
|
|
220
|
+
"POST", "/api/v1/convert" + query, body, headers, key
|
|
221
|
+
)
|
|
222
|
+
if status == 202:
|
|
223
|
+
raise ConversionPendingError(data, idempotency_key=key)
|
|
224
|
+
return data, response_headers
|
|
225
|
+
|
|
226
|
+
def _request(
|
|
227
|
+
self,
|
|
228
|
+
method: str,
|
|
229
|
+
path: str,
|
|
230
|
+
body: bytes | None,
|
|
231
|
+
headers: dict[str, str],
|
|
232
|
+
idempotency_key: str | None,
|
|
233
|
+
*,
|
|
234
|
+
auth: bool = True,
|
|
235
|
+
) -> tuple[dict[str, Any], dict[str, str], int]:
|
|
236
|
+
headers = {
|
|
237
|
+
"Accept": "application/json",
|
|
238
|
+
"User-Agent": f"facturx-api-python/{__version__}",
|
|
239
|
+
**headers,
|
|
240
|
+
}
|
|
241
|
+
if auth:
|
|
242
|
+
if not self.api_key:
|
|
243
|
+
raise ValueError("api_key is required (or set FACTURX_API_KEY).")
|
|
244
|
+
headers["X-API-Key"] = self.api_key
|
|
245
|
+
try:
|
|
246
|
+
status, response_headers, raw = self._send(method, self.base_url + path, headers, body)
|
|
247
|
+
except (OSError, TimeoutError) as exc:
|
|
248
|
+
raise TransportError(
|
|
249
|
+
f"{method} {path}: transport failed, outcome unknown ({exc})",
|
|
250
|
+
idempotency_key=idempotency_key,
|
|
251
|
+
) from exc
|
|
252
|
+
try:
|
|
253
|
+
data = json.loads(raw) if raw else {}
|
|
254
|
+
except ValueError:
|
|
255
|
+
data = {"detail": {"message": raw[:500].decode("utf-8", "replace")}}
|
|
256
|
+
if 200 <= status < 300:
|
|
257
|
+
raise FacturXError(f"HTTP {status}: non-JSON response") from None
|
|
258
|
+
if not 200 <= status < 300:
|
|
259
|
+
raise api_error(status, data, response_headers, idempotency_key=idempotency_key)
|
|
260
|
+
return data, response_headers, status
|
|
261
|
+
|
|
262
|
+
def _pdf(self, result: dict[str, Any]) -> bytes:
|
|
263
|
+
inline, url = result.get("pdf"), result.get("pdfUrl")
|
|
264
|
+
if bool(inline) == bool(url):
|
|
265
|
+
raise KeyError("expected exactly one of result.pdf / result.pdfUrl")
|
|
266
|
+
if inline:
|
|
267
|
+
pdf = base64.b64decode(inline, validate=True)
|
|
268
|
+
else:
|
|
269
|
+
if urllib.parse.urlparse(url).scheme != "https":
|
|
270
|
+
raise ValueError("pdfUrl is not https")
|
|
271
|
+
# Presigned URL: the API key is NEVER sent to it.
|
|
272
|
+
status, _, pdf = self._send("GET", url, {}, None)
|
|
273
|
+
if status != 200:
|
|
274
|
+
raise ValueError(f"pdfUrl download HTTP {status}")
|
|
275
|
+
if not pdf.startswith(b"%PDF-"):
|
|
276
|
+
raise ValueError("deliverable is not a PDF")
|
|
277
|
+
return pdf
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _final_checks(data: dict[str, Any], output: str) -> list[str]:
|
|
281
|
+
"""Documented success fields; HTTP 200 alone does not prove a conversion."""
|
|
282
|
+
result = data.get("result") or {}
|
|
283
|
+
checks = {
|
|
284
|
+
"dry_run is not true": data.get("dry_run") is not True,
|
|
285
|
+
"success == true": data.get("success") is True,
|
|
286
|
+
"result.conversionSuccessful == true": result.get("conversionSuccessful") is True,
|
|
287
|
+
"target.status == verified": (data.get("target") or {}).get("status") == "verified",
|
|
288
|
+
"result.xml present": bool(result.get("xml")),
|
|
289
|
+
}
|
|
290
|
+
if output == "facturx_pdfa3":
|
|
291
|
+
generated = data.get("finalGeneratedValidation") or {}
|
|
292
|
+
packaging = (generated.get("validationReadiness") or {}).get("packaging") or {}
|
|
293
|
+
checks.update(
|
|
294
|
+
{
|
|
295
|
+
"result.packagingPerformed == true": result.get("packagingPerformed") is True,
|
|
296
|
+
"finalGeneratedValidation.status == passed": generated.get("status") == "passed",
|
|
297
|
+
"finalGeneratedValidation.artifactAvailable == true": generated.get(
|
|
298
|
+
"artifactAvailable"
|
|
299
|
+
)
|
|
300
|
+
is True,
|
|
301
|
+
"packaging.status == passed": packaging.get("status") == "passed",
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
return [name for name, ok in checks.items() if not ok]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _json(value: Mapping[str, Any] | str) -> str:
|
|
308
|
+
if isinstance(value, str):
|
|
309
|
+
return value
|
|
310
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _query(**params: str | None) -> str:
|
|
314
|
+
kept = {k: v for k, v in params.items() if v is not None}
|
|
315
|
+
return "?" + urllib.parse.urlencode(kept) if kept else ""
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _read_file(file: FileInput) -> tuple[str, bytes]:
|
|
319
|
+
if isinstance(file, bytes):
|
|
320
|
+
return ("invoice.xml" if file.lstrip().startswith(b"<") else "invoice.pdf"), file
|
|
321
|
+
path = Path(file)
|
|
322
|
+
return path.name, path.read_bytes()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _multipart(
|
|
326
|
+
fields: Mapping[str, str],
|
|
327
|
+
upload: tuple[str, str, bytes] | None = None,
|
|
328
|
+
seed: str | None = None,
|
|
329
|
+
) -> tuple[bytes, str]:
|
|
330
|
+
# Same idempotency key + same fields => byte-identical body on a resend.
|
|
331
|
+
token = hashlib.sha256((seed or uuid.uuid4().hex).encode()).hexdigest()[:32]
|
|
332
|
+
boundary = "facturx-sdk-" + token
|
|
333
|
+
parts: list[bytes] = []
|
|
334
|
+
for name, value in fields.items():
|
|
335
|
+
parts.append(
|
|
336
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
|
|
337
|
+
+ value.encode()
|
|
338
|
+
+ b"\r\n"
|
|
339
|
+
)
|
|
340
|
+
if upload is not None:
|
|
341
|
+
name, filename, content = upload
|
|
342
|
+
mime = "application/pdf" if content.startswith(b"%PDF-") else "application/xml"
|
|
343
|
+
safe = filename.replace('"', "_").replace("\r", "_").replace("\n", "_")
|
|
344
|
+
parts.append(
|
|
345
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"; '
|
|
346
|
+
f'filename="{safe}"\r\nContent-Type: {mime}\r\n\r\n'.encode()
|
|
347
|
+
+ content
|
|
348
|
+
+ b"\r\n"
|
|
349
|
+
)
|
|
350
|
+
parts.append(f"--{boundary}--\r\n".encode())
|
|
351
|
+
body = b"".join(parts)
|
|
352
|
+
if body.count(boundary.encode()) != len(parts):
|
|
353
|
+
raise ValueError("multipart boundary collides with the payload")
|
|
354
|
+
return body, "multipart/form-data; boundary=" + boundary
|
facturx_api/_errors.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Typed errors. Code and message are the API's own values, never rewritten."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FacturXError(Exception):
|
|
9
|
+
"""Base class of every error raised by this SDK."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TransportError(FacturXError):
|
|
13
|
+
"""The request may or may not have reached the API: its outcome is unknown.
|
|
14
|
+
|
|
15
|
+
Send the same request again with the same ``idempotency_key`` to learn the
|
|
16
|
+
outcome without being charged twice. The SDK never does it for you.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, *, idempotency_key: str | None = None) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.idempotency_key = idempotency_key
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApiError(FacturXError):
|
|
25
|
+
"""The API answered with a non-success HTTP status.
|
|
26
|
+
|
|
27
|
+
Attributes mirror the response body: ``code`` is ``detail.error`` (or
|
|
28
|
+
``detail.code``), ``message`` is ``detail.message``. ``retryable`` and
|
|
29
|
+
``retry_after`` are only what the server said; ``None`` means it said nothing.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
status: int,
|
|
35
|
+
body: Any,
|
|
36
|
+
headers: dict[str, str],
|
|
37
|
+
*,
|
|
38
|
+
idempotency_key: str | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.status = status
|
|
41
|
+
self.body = body
|
|
42
|
+
self.headers = headers
|
|
43
|
+
self.idempotency_key = idempotency_key
|
|
44
|
+
data = body if isinstance(body, dict) else {}
|
|
45
|
+
detail = data.get("detail", data)
|
|
46
|
+
if not isinstance(detail, dict):
|
|
47
|
+
detail = {"message": detail} if isinstance(detail, str) else {}
|
|
48
|
+
self.detail: dict[str, Any] = detail
|
|
49
|
+
self.code: str | None = detail.get("error") or detail.get("code") or data.get("error")
|
|
50
|
+
self.message: str | None = detail.get("message")
|
|
51
|
+
self.issues: list[Any] = list(detail.get("issues") or [])
|
|
52
|
+
self.retryable: bool | None = detail.get("retryable")
|
|
53
|
+
self.request_id: str | None = data.get("request_id") or headers.get("x-request-id")
|
|
54
|
+
self.retry_after: float | None = _retry_after(headers, detail)
|
|
55
|
+
super().__init__(
|
|
56
|
+
f"HTTP {status} {self.code or 'unknown_error'}: {self.message or ''}".strip()
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AuthenticationError(ApiError):
|
|
61
|
+
"""401 / 403: missing, invalid or unauthorised API key."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class QuotaExceededError(ApiError):
|
|
65
|
+
"""402: monthly quota of processed invoices reached."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class RateLimitError(ApiError):
|
|
69
|
+
"""429: too many requests."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class InvalidRequestError(ApiError):
|
|
73
|
+
"""400 / 413 / 415 / 422: the request must be fixed; ``issues`` names the fields."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ConflictError(ApiError):
|
|
77
|
+
"""409: idempotency or context conflict. Read ``code`` before doing anything."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ServerError(ApiError):
|
|
81
|
+
"""5xx returned by the API."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ServiceUnavailableError(ServerError):
|
|
85
|
+
"""503: a dependency of the API is unavailable."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ConversionPendingError(FacturXError):
|
|
89
|
+
"""202: the API accepted the conversion for asynchronous processing.
|
|
90
|
+
|
|
91
|
+
No final file is available yet. ``job_id`` and ``idempotency_key`` are kept
|
|
92
|
+
so the caller decides what to do; the SDK does not poll.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, response: dict[str, Any], *, idempotency_key: str | None) -> None:
|
|
96
|
+
result = response.get("result") or {}
|
|
97
|
+
self.response = response
|
|
98
|
+
self.job_id: str | None = result.get("jobId")
|
|
99
|
+
self.request_id: str | None = response.get("request_id")
|
|
100
|
+
self.idempotency_key = idempotency_key
|
|
101
|
+
super().__init__(f"Conversion accepted for async processing (jobId={self.job_id})")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class NotDeliveredError(FacturXError):
|
|
105
|
+
"""HTTP 200 without a verified final deliverable.
|
|
106
|
+
|
|
107
|
+
HTTP 200 alone does not prove a successful conversion: ``failed_checks``
|
|
108
|
+
lists which documented fields were not in the required state, ``response``
|
|
109
|
+
holds the full body.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
response: dict[str, Any],
|
|
115
|
+
failed_checks: list[str],
|
|
116
|
+
*,
|
|
117
|
+
idempotency_key: str | None,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.response = response
|
|
120
|
+
self.failed_checks = failed_checks
|
|
121
|
+
self.request_id: str | None = response.get("request_id")
|
|
122
|
+
self.idempotency_key = idempotency_key
|
|
123
|
+
super().__init__("No verified final file: " + ", ".join(failed_checks))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
_BY_STATUS: dict[int, type[ApiError]] = {
|
|
127
|
+
400: InvalidRequestError,
|
|
128
|
+
401: AuthenticationError,
|
|
129
|
+
402: QuotaExceededError,
|
|
130
|
+
403: AuthenticationError,
|
|
131
|
+
409: ConflictError,
|
|
132
|
+
413: InvalidRequestError,
|
|
133
|
+
415: InvalidRequestError,
|
|
134
|
+
422: InvalidRequestError,
|
|
135
|
+
429: RateLimitError,
|
|
136
|
+
503: ServiceUnavailableError,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def api_error(
|
|
141
|
+
status: int, body: Any, headers: dict[str, str], *, idempotency_key: str | None
|
|
142
|
+
) -> ApiError:
|
|
143
|
+
cls = _BY_STATUS.get(status) or (ServerError if status >= 500 else ApiError)
|
|
144
|
+
return cls(status, body, headers, idempotency_key=idempotency_key)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _retry_after(headers: dict[str, str], detail: dict[str, Any]) -> float | None:
|
|
148
|
+
for raw in (headers.get("retry-after"), detail.get("retry_after_seconds")):
|
|
149
|
+
if raw is None:
|
|
150
|
+
continue
|
|
151
|
+
try:
|
|
152
|
+
return float(raw)
|
|
153
|
+
except (TypeError, ValueError):
|
|
154
|
+
continue # An HTTP-date stays readable in ``headers``; not converted here.
|
|
155
|
+
return None
|
facturx_api/_results.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Result objects. ``raw`` always holds the full API response, unmodified."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class DryRunResult:
|
|
13
|
+
"""``POST /api/v1/convert?dry_run=true``: free check, no deliverable.
|
|
14
|
+
|
|
15
|
+
``ready`` reads ``generationReadiness.status == "ready"``. It is NOT
|
|
16
|
+
``success``: a dry run can report ``success=false`` and still allow the
|
|
17
|
+
final generation (observed on the staging contract, 05/11/2026).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
raw: dict[str, Any]
|
|
21
|
+
idempotency_key: str
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def ready(self) -> bool:
|
|
25
|
+
readiness = self.raw.get("generationReadiness") or {}
|
|
26
|
+
return self.raw.get("dry_run") is True and readiness.get("status") == "ready"
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def generation_readiness(self) -> dict[str, Any]:
|
|
30
|
+
return dict(self.raw.get("generationReadiness") or {})
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def request_id(self) -> str | None:
|
|
34
|
+
return self.raw.get("request_id")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ConvertResult:
|
|
39
|
+
"""Verified final conversion (1 processed invoice on the quota).
|
|
40
|
+
|
|
41
|
+
Only built when every documented success field is in its required state;
|
|
42
|
+
otherwise :class:`~facturx_api.NotDeliveredError` is raised instead.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
raw: dict[str, Any]
|
|
46
|
+
idempotency_key: str
|
|
47
|
+
xml: bytes
|
|
48
|
+
pdf: bytes | None
|
|
49
|
+
output: str
|
|
50
|
+
headers: dict[str, str] = field(default_factory=dict, repr=False)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def request_id(self) -> str | None:
|
|
54
|
+
return self.raw.get("request_id") or self.headers.get("x-request-id")
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def target(self) -> dict[str, Any]:
|
|
58
|
+
return dict(self.raw.get("target") or {})
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def target_profile(self) -> str | None:
|
|
62
|
+
return (self.raw.get("result") or {}).get("targetProfile")
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def xml_sha256(self) -> str:
|
|
66
|
+
return hashlib.sha256(self.xml).hexdigest()
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def pdf_sha256(self) -> str | None:
|
|
70
|
+
return hashlib.sha256(self.pdf).hexdigest() if self.pdf is not None else None
|
|
71
|
+
|
|
72
|
+
def save(self, directory: str | Path, stem: str = "factur-x") -> dict[str, Path]:
|
|
73
|
+
"""Write the deliverable(s) to ``directory`` and return their paths."""
|
|
74
|
+
out = Path(directory)
|
|
75
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
paths = {"xml": out / f"{stem}.xml"}
|
|
77
|
+
paths["xml"].write_bytes(self.xml)
|
|
78
|
+
if self.pdf is not None:
|
|
79
|
+
paths["pdf"] = out / f"{stem}.pdf"
|
|
80
|
+
paths["pdf"].write_bytes(self.pdf)
|
|
81
|
+
return paths
|
facturx_api/cli.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""``facturx`` command line, built on the SDK. JSON on stdout, one line per run.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 success; 1 dry run not ready / no verified file; 2 usage error;
|
|
4
|
+
3 API error; 4 transport failure (outcome unknown: resend with the same key).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ._client import OUTPUTS, TARGETS, FacturX, __version__
|
|
16
|
+
from ._errors import (
|
|
17
|
+
ApiError,
|
|
18
|
+
ConversionPendingError,
|
|
19
|
+
NotDeliveredError,
|
|
20
|
+
TransportError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
EXIT_OK, EXIT_NOT_READY, EXIT_USAGE, EXIT_API, EXIT_TRANSPORT = 0, 1, 2, 3, 4
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parser() -> argparse.ArgumentParser:
|
|
27
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
28
|
+
common.add_argument("--base-url", help="API host (default: $FACTURX_BASE_URL)")
|
|
29
|
+
common.add_argument("--api-key", help="API key (default: $FACTURX_API_KEY)")
|
|
30
|
+
common.add_argument("--timeout", type=float, default=120.0, help="seconds (default 120)")
|
|
31
|
+
|
|
32
|
+
conv = argparse.ArgumentParser(add_help=False)
|
|
33
|
+
conv.add_argument("invoice", type=Path, help="complete ERP invoice JSON file")
|
|
34
|
+
conv.add_argument("--target", choices=TARGETS, default="en16931")
|
|
35
|
+
conv.add_argument("--output", choices=OUTPUTS, default="facturx_pdfa3")
|
|
36
|
+
conv.add_argument("--idempotency-key", help="reuse it to resend the same operation")
|
|
37
|
+
|
|
38
|
+
parser = argparse.ArgumentParser(prog="facturx", description="FacturX API command line")
|
|
39
|
+
parser.add_argument("--version", action="version", version=f"facturx {__version__}")
|
|
40
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
41
|
+
sub.add_parser("dry-run", parents=[common, conv], help="free check, no deliverable (no quota)")
|
|
42
|
+
convert = sub.add_parser(
|
|
43
|
+
"convert", parents=[common, conv], help="final conversion (1 invoice on the quota)"
|
|
44
|
+
)
|
|
45
|
+
convert.add_argument("--out", type=Path, required=True, help="directory for the files")
|
|
46
|
+
validate = sub.add_parser("validate", parents=[common], help="validate a PDF or XML file")
|
|
47
|
+
validate.add_argument("file", type=Path)
|
|
48
|
+
validate.add_argument("--target", choices=TARGETS)
|
|
49
|
+
return parser
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _emit(payload: dict[str, Any]) -> None:
|
|
53
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _error(exc: Exception) -> dict[str, Any]:
|
|
57
|
+
out: dict[str, Any] = {"type": type(exc).__name__, "message": str(exc)}
|
|
58
|
+
for name in ("status", "code", "request_id", "retryable", "retry_after", "issues"):
|
|
59
|
+
if getattr(exc, name, None) not in (None, []):
|
|
60
|
+
out[name] = getattr(exc, name)
|
|
61
|
+
if isinstance(exc, ApiError) and exc.message is not None:
|
|
62
|
+
out["api_message"] = exc.message
|
|
63
|
+
for name in ("idempotency_key", "job_id", "failed_checks"):
|
|
64
|
+
if getattr(exc, name, None):
|
|
65
|
+
out[name] = getattr(exc, name)
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: list[str] | None = None, client: FacturX | None = None) -> int:
|
|
70
|
+
args = _parser().parse_args(argv)
|
|
71
|
+
try:
|
|
72
|
+
fx = client or FacturX(args.api_key, args.base_url, timeout=args.timeout)
|
|
73
|
+
if args.command == "validate":
|
|
74
|
+
report = fx.validate(args.file, validation_target=args.target)
|
|
75
|
+
_emit({"ok": True, "command": "validate", "report": report})
|
|
76
|
+
return EXIT_OK
|
|
77
|
+
invoice = json.loads(args.invoice.read_text(encoding="utf-8"))
|
|
78
|
+
kwargs = {
|
|
79
|
+
"output": args.output,
|
|
80
|
+
"validation_target": args.target,
|
|
81
|
+
"idempotency_key": args.idempotency_key,
|
|
82
|
+
}
|
|
83
|
+
if args.command == "dry-run":
|
|
84
|
+
check = fx.dry_run(invoice, **kwargs)
|
|
85
|
+
_emit(
|
|
86
|
+
{
|
|
87
|
+
"ok": check.ready,
|
|
88
|
+
"command": "dry-run",
|
|
89
|
+
"ready": check.ready,
|
|
90
|
+
"generation_readiness": check.generation_readiness,
|
|
91
|
+
"request_id": check.request_id,
|
|
92
|
+
"idempotency_key": check.idempotency_key,
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
return EXIT_OK if check.ready else EXIT_NOT_READY
|
|
96
|
+
doc = fx.convert(invoice, **kwargs)
|
|
97
|
+
paths = doc.save(args.out)
|
|
98
|
+
files = {kind: {"path": str(path)} for kind, path in paths.items()}
|
|
99
|
+
files["xml"].update(bytes=len(doc.xml), sha256=doc.xml_sha256)
|
|
100
|
+
if doc.pdf is not None:
|
|
101
|
+
files["pdf"].update(bytes=len(doc.pdf), sha256=doc.pdf_sha256)
|
|
102
|
+
_emit(
|
|
103
|
+
{
|
|
104
|
+
"ok": True,
|
|
105
|
+
"command": "convert",
|
|
106
|
+
"target": doc.target,
|
|
107
|
+
"target_profile": doc.target_profile,
|
|
108
|
+
"files": files,
|
|
109
|
+
"request_id": doc.request_id,
|
|
110
|
+
"idempotency_key": doc.idempotency_key,
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
return EXIT_OK
|
|
114
|
+
except (ValueError, OSError) as exc: # bad arguments, unreadable input, invalid JSON
|
|
115
|
+
_emit({"ok": False, "command": args.command, "error": _error(exc)})
|
|
116
|
+
return EXIT_USAGE
|
|
117
|
+
except (NotDeliveredError, ConversionPendingError) as exc:
|
|
118
|
+
_emit({"ok": False, "command": args.command, "error": _error(exc)})
|
|
119
|
+
return EXIT_NOT_READY
|
|
120
|
+
except ApiError as exc:
|
|
121
|
+
_emit({"ok": False, "command": args.command, "error": _error(exc)})
|
|
122
|
+
return EXIT_API
|
|
123
|
+
except TransportError as exc:
|
|
124
|
+
_emit({"ok": False, "command": args.command, "error": _error(exc)})
|
|
125
|
+
return EXIT_TRANSPORT
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__": # pragma: no cover
|
|
129
|
+
sys.exit(main())
|
facturx_api/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: facturx-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the FacturX API: dry run, Factur-X PDF/A-3 conversion, validation.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/facturxapi/sdk
|
|
7
|
+
Project-URL: Source, https://github.com/facturxapi/sdk/tree/main/python
|
|
8
|
+
Project-URL: Issues, https://github.com/facturxapi/sdk/issues
|
|
9
|
+
Keywords: factur-x,facturx,en16931,e-invoicing,facture-electronique
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# facturx-api (Python)
|
|
21
|
+
|
|
22
|
+
Client Python de FacturX API : simulation gratuite (`dry_run`), conversion finale en
|
|
23
|
+
Factur-X PDF/A-3 (ou XML CII), validation d'un fichier existant. Bibliothèque standard
|
|
24
|
+
uniquement, Python 3.10+.
|
|
25
|
+
|
|
26
|
+
> Statut 0.1.0 : **non publié sur PyPI.** Installation depuis la wheel construite localement.
|
|
27
|
+
> Parcours facture → fichiers exécuté contre staging le 06/11/2026 (voir « Ce qui est prouvé »).
|
|
28
|
+
|
|
29
|
+
## Installation (locale)
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
cd sdk/python && python -m build # produit dist/facturx_api-0.1.0-py3-none-any.whl
|
|
33
|
+
pip install dist/facturx_api-0.1.0-py3-none-any.whl
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Utilisation
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import json
|
|
40
|
+
from facturx_api import FacturX
|
|
41
|
+
|
|
42
|
+
fx = FacturX(api_key="…", base_url="https://api.facturxapi.com")
|
|
43
|
+
invoice = json.load(open("invoice.json")) # JSON ERP complet
|
|
44
|
+
|
|
45
|
+
check = fx.dry_run(invoice) # gratuit, aucun livrable
|
|
46
|
+
if check.ready: # generationReadiness.status == "ready"
|
|
47
|
+
doc = fx.convert(invoice) # 1 facture traitée sur le quota
|
|
48
|
+
print(doc.save("out")) # out/factur-x.pdf et out/factur-x.xml
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Il n'y a **pas d'hôte par défaut** : `base_url` (ou `FACTURX_BASE_URL`) est obligatoire.
|
|
52
|
+
`api_key` peut venir de `FACTURX_API_KEY`.
|
|
53
|
+
|
|
54
|
+
- `ready` lit `generationReadiness.status`, pas `success` : une simulation peut répondre
|
|
55
|
+
`success=false` et autoriser quand même la génération.
|
|
56
|
+
- `convert` ne renvoie un résultat que si tous les champs de succès documentés sont au bon
|
|
57
|
+
état (`success`, `result.conversionSuccessful`, `target.status == verified`,
|
|
58
|
+
`finalGeneratedValidation`, packaging PDF/A-3). Sinon `NotDeliveredError.failed_checks`
|
|
59
|
+
dit lesquels : un HTTP 200 seul ne prouve rien.
|
|
60
|
+
- Si le PDF arrive par URL signée (`result.pdfUrl`), il est téléchargé **sans** la clé API.
|
|
61
|
+
|
|
62
|
+
Autres appels : `fx.validate(path_or_bytes, validation_target="en16931")`,
|
|
63
|
+
`fx.extract(pdf)` (XML embarqué d'un PDF Factur-X), `fx.meta()`, `fx.integration_contract()`
|
|
64
|
+
(ces deux derniers sans clé). Exemples exécutés : `sdk/examples/`.
|
|
65
|
+
|
|
66
|
+
## Ligne de commande `facturx`
|
|
67
|
+
|
|
68
|
+
Installée avec le paquet. Sortie JSON sur une ligne ; `FACTURX_API_KEY` et `FACTURX_BASE_URL`
|
|
69
|
+
(ou `--api-key`, `--base-url`).
|
|
70
|
+
|
|
71
|
+
| Commande | Effet | Code de sortie |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `facturx dry-run invoice.json` | simulation gratuite | 0 prêt, 1 non prêt |
|
|
74
|
+
| `facturx convert invoice.json --out DIR` | conversion finale, écrit `DIR/factur-x.pdf` et `.xml` | 0, ou 1 sans fichier vérifié |
|
|
75
|
+
| `facturx validate FICHIER [--target en16931]` | rapport de validation de l'API | 0 |
|
|
76
|
+
|
|
77
|
+
Codes communs : 2 argument ou fichier invalide, 3 erreur API (`code`, `api_message`,
|
|
78
|
+
`request_id` tels que l'API les envoie), 4 transport coupé (résultat inconnu : relancer avec
|
|
79
|
+
`--idempotency-key` égal à la clé affichée). Options `--target`, `--output`, `--idempotency-key`.
|
|
80
|
+
|
|
81
|
+
Exécution réelle contre staging (release `900685311ea7`, 06/11/2026 15:56Z, clé sandbox de test) :
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
$ facturx dry-run invoice.json
|
|
85
|
+
{"ok": true, "command": "dry-run", "ready": true, "generation_readiness": {"status": "ready", "blockingInputCount": 0, "reason": "no_generation_blocking_inputs"}, "request_id": "f0bb01da-564c-49ec-b4f0-2371f578773e", "idempotency_key": "b4aa1cc0-e675-4ffa-8ae6-644b111754f5"}
|
|
86
|
+
$ facturx convert invoice.json --out out-cli
|
|
87
|
+
{"ok": true, "command": "convert", "target": {"requested": "en16931", "executed": "en16931", "status": "verified", "missing_inputs": [], "not_run_reason": null}, "target_profile": "EN16931", "files": {"xml": {"path": "out-cli/factur-x.xml", "bytes": 4584, "sha256": "f332975e12f8dac94c926d8832c6434daf3ae718e3b10fb8acb9758bc940e03c"}, "pdf": {"path": "out-cli/factur-x.pdf", "bytes": 20624, "sha256": "75fd1d5e30ecf539a54ffc639134a17105b3b97e04941079c530cf2bb87ecd80"}}, "request_id": "66739ba5-e3c8-457b-9343-a90a9e836900", "idempotency_key": "df90c5ce-6b58-420d-a46b-4f72224b19e2"}
|
|
88
|
+
$ facturx validate out-cli/factur-x.pdf --target en16931 # extrait du rapport
|
|
89
|
+
ok= True | valid= True | validationStatus= valid | profile= EN16931 | target.status= verified | errors= 0
|
|
90
|
+
$ FACTURX_API_KEY=invalid facturx dry-run invoice.json # code de sortie 3
|
|
91
|
+
{"ok": false, "command": "dry-run", "error": {"type": "AuthenticationError", "message": "HTTP 401 invalid_api_key: The API key provided is invalid or revoked.", "status": 401, "code": "invalid_api_key", "request_id": "1c393ffb-981e-4bca-b148-57b8170a8240", "api_message": "The API key provided is invalid or revoked.", "idempotency_key": "42b47d9a-8ef4-4b22-90dd-17405957ffa2"}}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Erreurs
|
|
95
|
+
|
|
96
|
+
| Exception | Quand |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `AuthenticationError` | 401 / 403 |
|
|
99
|
+
| `QuotaExceededError` | 402 |
|
|
100
|
+
| `RateLimitError` | 429 |
|
|
101
|
+
| `InvalidRequestError` | 400 / 413 / 415 / 422 ; `issues` nomme les champs |
|
|
102
|
+
| `ConflictError` | 409 (idempotence, contexte) |
|
|
103
|
+
| `ServiceUnavailableError` / `ServerError` | 503 / autres 5xx |
|
|
104
|
+
| `ConversionPendingError` | 202 : traitement asynchrone, `job_id` |
|
|
105
|
+
| `NotDeliveredError` | 200 sans fichier final vérifié |
|
|
106
|
+
| `TransportError` | réseau coupé : **résultat inconnu** |
|
|
107
|
+
|
|
108
|
+
Toute `ApiError` porte `status`, `code` et `message` **tels que l'API les a envoyés**, plus
|
|
109
|
+
`request_id`, `issues`, `detail`, et `retryable` / `retry_after` seulement si le serveur les
|
|
110
|
+
a fournis (`None` sinon).
|
|
111
|
+
|
|
112
|
+
## Idempotence et reprise
|
|
113
|
+
|
|
114
|
+
Chaque appel `convert` / `dry_run` envoie un `Idempotency-Key` (UUID généré, ou le vôtre
|
|
115
|
+
via `idempotency_key=`). La clé est rendue sur le résultat **et** sur toute erreur. Pour
|
|
116
|
+
connaître l'issue d'une opération interrompue sans payer deux fois, renvoyez la même
|
|
117
|
+
requête avec la même clé : le corps envoyé est identique à l'octet près. Réutiliser une clé
|
|
118
|
+
avec une autre facture provoque `409 idempotency_conflict`.
|
|
119
|
+
|
|
120
|
+
**Le SDK ne relance jamais rien automatiquement.** La règle « un 5xx peut-il être renvoyé
|
|
121
|
+
tel quel » est en cours de fixation côté API (api#2111) ; tant qu'elle n'est pas publiée,
|
|
122
|
+
la décision reste à l'appelant.
|
|
123
|
+
|
|
124
|
+
## Ce qui est prouvé (06/11/2026)
|
|
125
|
+
|
|
126
|
+
- 24 tests hors réseau (`PYTHONPATH=src python -m unittest discover -s tests`), sockets interdits,
|
|
127
|
+
dont 8 pour la CLI et le rejeu de la conversion staging réelle (`tests/cassettes/` : réponses réelles réduites aux champs lus, PDF omis, XML réel).
|
|
128
|
+
- Parcours réel contre staging (release `900685311ea7`), clé sandbox de test, facture
|
|
129
|
+
`tests/fixtures/invoice.json`, même enchaînement que l’exemple « Utilisation », 06/11/2026 15:48Z :
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
dry_run.ready = True | generationReadiness.status = ready | success = False | request_id = ea443cc8-30dd-4c17-b991-697d52f97646
|
|
133
|
+
convert: target = {"requested": "en16931", "executed": "en16931", "status": "verified", "missing_inputs": [], "not_run_reason": null} | profile = EN16931 | request_id = 2db8ddcb-8541-48e1-ae99-a1416221f6b3
|
|
134
|
+
pdf bytes = 20624 sha256 = dd9abd2cbab9679abdaf6e397fd0805032413a406bd757de23d009b4108f41f7 | starts b'%PDF-1.7'
|
|
135
|
+
xml bytes = 4584 sha256 = f332975e12f8dac94c926d8832c6434daf3ae718e3b10fb8acb9758bc940e03c
|
|
136
|
+
seconds command -> files = 21.426
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Le XML est identique, octet pour octet, à celui obtenu le 05/11 par `examples/first-file`
|
|
140
|
+
pour la même facture. PDF/A-3 et EN16931 sont attestés par l'API, pas revérifiés localement.
|
|
141
|
+
- Sans clé : `meta()`, `integration_contract()` et une clé invalide ⇒ `AuthenticationError`
|
|
142
|
+
401 `invalid_api_key` avec le message de l'API intact.
|
|
143
|
+
- `validate` : exécuté réellement via la CLI sur le PDF produit (voir « Ligne de commande »).
|
|
144
|
+
- `extract` : exécuté réellement, XML extrait identique au XML produit (`sdk/examples/extract-xml/run.txt`).
|
|
145
|
+
- `output="xml_cii"` : exécuté réellement pour EN16931 et EXTENDED (`sdk/examples/urn-par-profil/run.txt`).
|
|
146
|
+
|
|
147
|
+
**Pas encore prouvé avec ce SDK** : téléchargement par URL signée (`pdfUrl`), réponses 409/503
|
|
148
|
+
réelles (exercées hors ligne uniquement).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
facturx_api/__init__.py,sha256=cGkm_9oJhKDRoX65Jlv1BstD40H-hzmEeEQJ1GvkJKY,880
|
|
2
|
+
facturx_api/_client.py,sha256=ngPpItao498ZJFNQl5KA43P4oRbsgHkRo_Poh3flPvs,14147
|
|
3
|
+
facturx_api/_errors.py,sha256=52n8hpnilulJJHOE_XCtRUXmUXFQwIokczxI_wHnjSw,5236
|
|
4
|
+
facturx_api/_results.py,sha256=rQ-qKeUEWUr8w1EmCo_B0YlOD5QMPlYD_BDa613tT_o,2570
|
|
5
|
+
facturx_api/cli.py,sha256=qTG-tFNMXpZ-LbY0axck42ePZf73UplEh90LGnykhRA,5394
|
|
6
|
+
facturx_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
facturx_api-0.1.0.dist-info/licenses/LICENSE,sha256=Nr37-wz5XGkBZfLdQhO2uol5FzQ4EL2WreYs7AcFb00,1064
|
|
8
|
+
facturx_api-0.1.0.dist-info/METADATA,sha256=jE5PiFI_F9Wi0bNDih8WDwCTKiSxqJbKWvA1UxZiIIQ,8558
|
|
9
|
+
facturx_api-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
facturx_api-0.1.0.dist-info/entry_points.txt,sha256=BEzu6AcvDzMCEx7p5gQeMOzd2Q-zGX9rpje4KV7OX8M,49
|
|
11
|
+
facturx_api-0.1.0.dist-info/top_level.txt,sha256=jbHu_aMN1FAwK3fTTGyKuhtVPGrtdg0bIefO7GXL-Gg,12
|
|
12
|
+
facturx_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FacturX
|
|
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 @@
|
|
|
1
|
+
facturx_api
|