xmlvalid 1.0.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.
xmlvalid/errors.py ADDED
@@ -0,0 +1,103 @@
1
+ """Every exception the client raises extends :class:`XmlvalidError`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .models import Validation
9
+
10
+ __all__ = [
11
+ "ApiError",
12
+ "AuthenticationError",
13
+ "DocumentTooLargeError",
14
+ "InvalidRequestError",
15
+ "NotFinishedError",
16
+ "NotFoundError",
17
+ "PlanLimitError",
18
+ "QuotaExceededError",
19
+ "RateLimitedError",
20
+ "ServerError",
21
+ "SignatureVerificationError",
22
+ "TransportError",
23
+ "ValidationTimeoutError",
24
+ "XmlvalidError",
25
+ ]
26
+
27
+
28
+ class XmlvalidError(Exception):
29
+ """Anything that went wrong talking to xmlvalid."""
30
+
31
+
32
+ class ApiError(XmlvalidError):
33
+ """The API refused the request."""
34
+
35
+ def __init__(
36
+ self,
37
+ message: str,
38
+ status_code: int,
39
+ error_code: str,
40
+ fields: dict[str, list[str]] | None = None,
41
+ ) -> None:
42
+ super().__init__(message)
43
+ self.status_code = status_code
44
+ """The HTTP status."""
45
+ self.error_code = error_code
46
+ """The API's code: unauthenticated, invalid_request, quota_exceeded, …"""
47
+ self.fields = fields or {}
48
+ """For invalid_request: the problems per field."""
49
+
50
+
51
+ class AuthenticationError(ApiError):
52
+ """401: no key, or a revoked one."""
53
+
54
+
55
+ class PlanLimitError(ApiError):
56
+ """403: not in your plan."""
57
+
58
+
59
+ class NotFoundError(ApiError):
60
+ """404: no such validation, or it expired (24 hours)."""
61
+
62
+
63
+ class NotFinishedError(ApiError):
64
+ """409: the report of a validation still running."""
65
+
66
+
67
+ class DocumentTooLargeError(ApiError):
68
+ """413: over your plan's size limit."""
69
+
70
+
71
+ class InvalidRequestError(ApiError):
72
+ """422: an unknown schema, a bad URL, …; see ``fields``."""
73
+
74
+
75
+ class QuotaExceededError(ApiError):
76
+ """429: this month's validations are used up."""
77
+
78
+
79
+ class RateLimitedError(ApiError):
80
+ """429: too many requests a minute, still so after the retries."""
81
+
82
+ retry_after: int = 0
83
+ """Seconds to wait before the next request."""
84
+
85
+
86
+ class ServerError(ApiError):
87
+ """5xx: something broke on xmlvalid's side."""
88
+
89
+
90
+ class TransportError(XmlvalidError):
91
+ """The request could not be sent, or its answer read."""
92
+
93
+
94
+ class ValidationTimeoutError(XmlvalidError, TimeoutError):
95
+ """The validation was still running when the timeout ran out. It goes on on the server."""
96
+
97
+ def __init__(self, validation: Validation, timeout: float) -> None:
98
+ super().__init__(f"Validation {validation.id} was still {validation.status} after {timeout:g} seconds.")
99
+ self.validation = validation
100
+
101
+
102
+ class SignatureVerificationError(XmlvalidError):
103
+ """A webhook's signature is missing, too old or does not match."""
xmlvalid/models.py ADDED
@@ -0,0 +1,358 @@
1
+ """What the API returns, as typed, read-only objects. Each keeps the JSON it came from in ``raw``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ __all__ = [
10
+ "ArchiveItem",
11
+ "Diagnostic",
12
+ "Event",
13
+ "ReportLink",
14
+ "Result",
15
+ "Schema",
16
+ "Usage",
17
+ "Validation",
18
+ ]
19
+
20
+
21
+ def _object(data: Any, key: str) -> dict[str, Any] | None:
22
+ value = data.get(key) if isinstance(data, dict) else None
23
+ return {str(k): v for k, v in value.items()} if isinstance(value, dict) else None
24
+
25
+
26
+ def _objects(data: dict[str, Any], key: str) -> list[dict[str, Any]]:
27
+ value = data.get(key)
28
+ if not isinstance(value, list):
29
+ return []
30
+ return [{str(k): v for k, v in item.items()} for item in value if isinstance(item, dict)]
31
+
32
+
33
+ def _str(data: dict[str, Any], key: str, default: str = "") -> str:
34
+ value = data.get(key)
35
+ return value if isinstance(value, str) else default
36
+
37
+
38
+ def _opt_str(data: dict[str, Any], key: str) -> str | None:
39
+ value = data.get(key)
40
+ return value if isinstance(value, str) else None
41
+
42
+
43
+ def _int(data: dict[str, Any], key: str) -> int:
44
+ value = data.get(key)
45
+ return int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else 0
46
+
47
+
48
+ def _bool(data: dict[str, Any], key: str) -> bool:
49
+ return data.get(key) is True
50
+
51
+
52
+ def _date(data: dict[str, Any], key: str) -> datetime | None:
53
+ value = data.get(key)
54
+ if not isinstance(value, str):
55
+ return None
56
+ try:
57
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
58
+ except ValueError:
59
+ return None
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Diagnostic:
64
+ """One error or warning, with where it is and, when there is one, the rule that failed."""
65
+
66
+ line: int
67
+ """0 when the message is about the whole document."""
68
+ column: int
69
+ """0 when unknown; business rules point at an element's line only."""
70
+ level: str
71
+ """warning, error or fatal."""
72
+ type: str
73
+ """syntax, xsd, relaxng, dtd, schema, io, schematron (a business rule), archive or pdf."""
74
+ message: str
75
+ rule: str | None = None
76
+ """The business rule that failed (BR-CO-15, PEPPOL-EN16931-R001) or the PDF check (PDF-AF)."""
77
+ message_en: str | None = None
78
+ """The message in English, for a rule its publisher wrote in another language."""
79
+ help_url: str | None = None
80
+ """A page explaining this kind of error and how to fix it."""
81
+ rule_url: str | None = None
82
+ """The rule's page on xmlvalid.com."""
83
+
84
+ @classmethod
85
+ def from_dict(cls, data: dict[str, Any]) -> Diagnostic:
86
+ return cls(
87
+ line=_int(data, "line"),
88
+ column=_int(data, "column"),
89
+ level=_str(data, "level", "error"),
90
+ type=_str(data, "type"),
91
+ message=_str(data, "message"),
92
+ rule=_opt_str(data, "rule"),
93
+ message_en=_opt_str(data, "message_en"),
94
+ help_url=_opt_str(data, "help_url"),
95
+ rule_url=_opt_str(data, "rule_url"),
96
+ )
97
+
98
+ @property
99
+ def is_warning(self) -> bool:
100
+ return self.level == "warning"
101
+
102
+ def __str__(self) -> str:
103
+ """``Line 139: [BR-CO-16] Amount due for payment …``"""
104
+ line = f"Line {self.line}: " if self.line > 0 else ""
105
+ rule = f"[{self.rule}] " if self.rule is not None else ""
106
+ return f"{line}{rule}{self.message_en or self.message}"
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class ArchiveItem:
111
+ """The result for one document of an archive."""
112
+
113
+ name: str
114
+ """Its path in the archive."""
115
+ schema: str | None
116
+ outcome: str
117
+ """valid, warnings, invalid, schema, input, failed or skipped."""
118
+ valid: bool
119
+ errors_total: int
120
+ warnings_total: int
121
+ errors: list[Diagnostic]
122
+ warnings: list[Diagnostic]
123
+ note: str | None = None
124
+ """Why a skipped document was left unchecked."""
125
+
126
+ @classmethod
127
+ def from_dict(cls, data: dict[str, Any]) -> ArchiveItem:
128
+ return cls(
129
+ name=_str(data, "name"),
130
+ schema=_opt_str(data, "schema"),
131
+ outcome=_str(data, "outcome"),
132
+ valid=_bool(data, "valid"),
133
+ errors_total=_int(data, "errors_total"),
134
+ warnings_total=_int(data, "warnings_total"),
135
+ errors=[Diagnostic.from_dict(item) for item in _objects(data, "errors")],
136
+ warnings=[Diagnostic.from_dict(item) for item in _objects(data, "warnings")],
137
+ note=_opt_str(data, "note"),
138
+ )
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class Result:
143
+ """What a finished validation found."""
144
+
145
+ valid: bool
146
+ """True for the outcomes valid and warnings."""
147
+ outcome: str
148
+ """valid, warnings, invalid, schema (the schema can't be used), input (the document can't be read) or failed."""
149
+ errors_total: int
150
+ warnings_total: int
151
+ errors_truncated: bool
152
+ """More errors were found than are listed."""
153
+ errors: list[Diagnostic]
154
+ """The first 500."""
155
+ warnings: list[Diagnostic]
156
+ """The first 500."""
157
+ bytes: int
158
+ duration_ms: int
159
+ pdf: dict[str, Any] | None = None
160
+ """For a PDF: the invoice found in it (attachment, attachments, guideline, profile)."""
161
+ documents: dict[str, Any] | None = None
162
+ """For an archive: total, passed, failed, skipped."""
163
+ items: list[ArchiveItem] = field(default_factory=list)
164
+ """For an archive: one result per document."""
165
+
166
+ @classmethod
167
+ def from_dict(cls, data: dict[str, Any]) -> Result:
168
+ return cls(
169
+ valid=_bool(data, "valid"),
170
+ outcome=_str(data, "outcome"),
171
+ errors_total=_int(data, "errors_total"),
172
+ warnings_total=_int(data, "warnings_total"),
173
+ errors_truncated=_bool(data, "errors_truncated"),
174
+ errors=[Diagnostic.from_dict(item) for item in _objects(data, "errors")],
175
+ warnings=[Diagnostic.from_dict(item) for item in _objects(data, "warnings")],
176
+ bytes=_int(data, "bytes"),
177
+ duration_ms=_int(data, "duration_ms"),
178
+ pdf=_object(data, "pdf"),
179
+ documents=_object(data, "documents"),
180
+ items=[ArchiveItem.from_dict(item) for item in _objects(data, "items")],
181
+ )
182
+
183
+ @property
184
+ def first_error(self) -> Diagnostic | None:
185
+ return self.errors[0] if self.errors else None
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class Validation:
190
+ """A validation, finished or not. Results are kept for 24 hours."""
191
+
192
+ QUEUED = "queued"
193
+ RUNNING = "running"
194
+ DONE = "done"
195
+ FAILED = "failed"
196
+ CANCELLED = "cancelled"
197
+
198
+ id: str
199
+ """val_…"""
200
+ status: str
201
+ """queued, running, done, failed (on xmlvalid's side; see ``error``) or cancelled."""
202
+ progress: int
203
+ created_at: datetime | None
204
+ finished_at: datetime | None
205
+ document: dict[str, Any]
206
+ """source, name, size, url."""
207
+ schema: dict[str, Any] | None
208
+ """kind, catalog, saved, detected, name."""
209
+ result: Result | None
210
+ error: str | None
211
+ """What went wrong on xmlvalid's side when the status is failed."""
212
+ report_url: str | None
213
+ """The result as an HTML page, once finished (needs the API key)."""
214
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
215
+
216
+ @classmethod
217
+ def from_dict(cls, data: dict[str, Any]) -> Validation:
218
+ result = _object(data, "result")
219
+ return cls(
220
+ id=_str(data, "id"),
221
+ status=_str(data, "status"),
222
+ progress=_int(data, "progress"),
223
+ created_at=_date(data, "created_at"),
224
+ finished_at=_date(data, "finished_at"),
225
+ document=_object(data, "document") or {},
226
+ schema=_object(data, "schema"),
227
+ result=Result.from_dict(result) if result is not None else None,
228
+ error=_opt_str(data, "error"),
229
+ report_url=_opt_str(_object(data, "links") or {}, "report"),
230
+ raw=data,
231
+ )
232
+
233
+ @property
234
+ def is_finished(self) -> bool:
235
+ """No longer queued or running."""
236
+ return self.status not in (self.QUEUED, self.RUNNING)
237
+
238
+ @property
239
+ def is_valid(self) -> bool:
240
+ """Finished, and the document passed (valid, or valid with warnings)."""
241
+ return self.result is not None and self.result.valid
242
+
243
+ @property
244
+ def errors(self) -> list[Diagnostic]:
245
+ """The errors found (empty while running)."""
246
+ return self.result.errors if self.result is not None else []
247
+
248
+ @property
249
+ def warnings(self) -> list[Diagnostic]:
250
+ """The warnings found (empty while running)."""
251
+ return self.result.warnings if self.result is not None else []
252
+
253
+
254
+ @dataclass(frozen=True)
255
+ class Usage:
256
+ """This month's validations and the plan's limits."""
257
+
258
+ plan: str
259
+ """free or pro."""
260
+ period_start: datetime | None
261
+ reset_at: datetime | None
262
+ """When the count resets (the 1st of next month, UTC)."""
263
+ used: int
264
+ limit: int
265
+ max_file_bytes: int
266
+ requests_per_minute: int
267
+
268
+ @classmethod
269
+ def from_dict(cls, data: dict[str, Any]) -> Usage:
270
+ period = _object(data, "period") or {}
271
+ validations = _object(data, "validations") or {}
272
+ limits = _object(data, "limits") or {}
273
+ return cls(
274
+ plan=_str(data, "plan"),
275
+ period_start=_date(period, "start"),
276
+ reset_at=_date(period, "reset_at"),
277
+ used=_int(validations, "used"),
278
+ limit=_int(validations, "limit"),
279
+ max_file_bytes=_int(limits, "max_file_bytes"),
280
+ requests_per_minute=_int(limits, "requests_per_minute"),
281
+ )
282
+
283
+ @property
284
+ def remaining(self) -> int:
285
+ return max(0, self.limit - self.used)
286
+
287
+
288
+ @dataclass(frozen=True)
289
+ class Schema:
290
+ """A schema to validate against: one of the account's saved schemas or a catalog entry."""
291
+
292
+ schema: str
293
+ """The value to pass as schema: a catalog slug (peppol-bis-3-invoice) or a saved schema (my-12)."""
294
+ name: str
295
+ kind: str
296
+ """xsd, dtd, rng or rnc."""
297
+ namespace: str | None
298
+ saved: bool
299
+ version: str | None = None
300
+ rules: list[str] = field(default_factory=list)
301
+ """Schematron rule sets run after the schema check, in order."""
302
+
303
+ @classmethod
304
+ def from_dict(cls, data: dict[str, Any], saved: bool) -> Schema:
305
+ rules = data.get("rules")
306
+ return cls(
307
+ schema=_str(data, "schema"),
308
+ name=_str(data, "name"),
309
+ kind=_str(data, "kind"),
310
+ namespace=_opt_str(data, "namespace"),
311
+ saved=saved,
312
+ version=_opt_str(data, "version"),
313
+ rules=[rule for rule in rules if isinstance(rule, str)] if isinstance(rules, list) else [],
314
+ )
315
+
316
+
317
+ @dataclass(frozen=True)
318
+ class ReportLink:
319
+ """A public link to a report, for 30 days."""
320
+
321
+ url: str
322
+ expires_at: datetime | None
323
+
324
+ @classmethod
325
+ def from_dict(cls, data: dict[str, Any]) -> ReportLink:
326
+ return cls(url=_str(data, "url"), expires_at=_date(data, "expires_at"))
327
+
328
+
329
+ @dataclass(frozen=True)
330
+ class Event:
331
+ """A webhook event: validation.completed, monitor.failed, monitor.recovered or ping.
332
+
333
+ A retry carries the same id, so it can be used to skip events already handled.
334
+ """
335
+
336
+ VALIDATION_COMPLETED = "validation.completed"
337
+ MONITOR_FAILED = "monitor.failed"
338
+ MONITOR_RECOVERED = "monitor.recovered"
339
+ PING = "ping"
340
+
341
+ id: str
342
+ type: str
343
+ created_at: datetime | None
344
+ data: dict[str, Any]
345
+
346
+ @classmethod
347
+ def from_dict(cls, payload: dict[str, Any]) -> Event:
348
+ return cls(
349
+ id=_str(payload, "id"),
350
+ type=_str(payload, "type"),
351
+ created_at=_date(payload, "created_at"),
352
+ data=_object(payload, "data") or {},
353
+ )
354
+
355
+ @property
356
+ def validation(self) -> Validation | None:
357
+ """The validation of a validation.completed event; None for other events."""
358
+ return Validation.from_dict(self.data) if self.type == self.VALIDATION_COMPLETED else None
xmlvalid/py.typed ADDED
File without changes
xmlvalid/webhooks.py ADDED
@@ -0,0 +1,56 @@
1
+ """Checks and reads the webhooks xmlvalid sends (Dashboard → Webhooks)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import re
9
+ import time
10
+
11
+ from .errors import SignatureVerificationError
12
+ from .models import Event
13
+
14
+ __all__ = ["verify"]
15
+
16
+
17
+ def verify(
18
+ payload: bytes | str,
19
+ signature: str | None,
20
+ secret: str,
21
+ *,
22
+ tolerance: int = 300,
23
+ now: int | None = None,
24
+ ) -> Event:
25
+ """Checks a webhook's ``Xmlvalid-Signature`` header and returns its event.
26
+
27
+ The header is ``t=<unix time>,v1=<HMAC-SHA256 of "t.body" keyed with the endpoint's secret>``.
28
+ Pass the raw body as received. ``tolerance`` is how many seconds a signature stays valid,
29
+ against replays (0 to skip that check).
30
+
31
+ Raises :class:`~xmlvalid.errors.SignatureVerificationError`.
32
+ """
33
+ body = payload.encode() if isinstance(payload, str) else payload
34
+ timestamp = re.search(r"(?:^|,)\s*t=(\d+)", signature or "")
35
+ hashes = re.findall(r"(?:^|,)\s*v1=([0-9a-f]+)", signature or "")
36
+ if timestamp is None or not hashes:
37
+ raise SignatureVerificationError("The Xmlvalid-Signature header is missing or malformed.")
38
+
39
+ sent = int(timestamp.group(1))
40
+ if tolerance > 0 and abs((now if now is not None else int(time.time())) - sent) > tolerance:
41
+ raise SignatureVerificationError("The webhook signature is too old.")
42
+
43
+ expected = hmac.new(secret.encode(), f"{sent}.".encode() + body, hashlib.sha256).hexdigest()
44
+ if not any(hmac.compare_digest(expected, candidate) for candidate in hashes):
45
+ raise SignatureVerificationError(
46
+ "The webhook signature does not match: check the endpoint's secret and pass the raw body."
47
+ )
48
+
49
+ try:
50
+ data = json.loads(body)
51
+ except ValueError as e:
52
+ raise SignatureVerificationError("The webhook body is not JSON.") from e
53
+ if not isinstance(data, dict):
54
+ raise SignatureVerificationError("The webhook body is not a JSON object.")
55
+
56
+ return Event.from_dict({str(key): value for key, value in data.items()})