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.
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ facturx = facturx_api.cli:main
@@ -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