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/__init__.py +54 -0
- xmlvalid/_client.py +638 -0
- xmlvalid/_version.py +1 -0
- xmlvalid/errors.py +103 -0
- xmlvalid/models.py +358 -0
- xmlvalid/py.typed +0 -0
- xmlvalid/webhooks.py +56 -0
- xmlvalid-1.0.0.dist-info/METADATA +199 -0
- xmlvalid-1.0.0.dist-info/RECORD +11 -0
- xmlvalid-1.0.0.dist-info/WHEEL +4 -0
- xmlvalid-1.0.0.dist-info/licenses/LICENSE +21 -0
xmlvalid/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Python client for the xmlvalid API: https://xmlvalid.com/docs/api"""
|
|
2
|
+
|
|
3
|
+
from . import webhooks
|
|
4
|
+
from ._client import DEFAULT_BASE_URL, AsyncClient, Client, DocumentInput, Rules
|
|
5
|
+
from ._version import __version__
|
|
6
|
+
from .errors import (
|
|
7
|
+
ApiError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
DocumentTooLargeError,
|
|
10
|
+
InvalidRequestError,
|
|
11
|
+
NotFinishedError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
PlanLimitError,
|
|
14
|
+
QuotaExceededError,
|
|
15
|
+
RateLimitedError,
|
|
16
|
+
ServerError,
|
|
17
|
+
SignatureVerificationError,
|
|
18
|
+
TransportError,
|
|
19
|
+
ValidationTimeoutError,
|
|
20
|
+
XmlvalidError,
|
|
21
|
+
)
|
|
22
|
+
from .models import ArchiveItem, Diagnostic, Event, ReportLink, Result, Schema, Usage, Validation
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"DEFAULT_BASE_URL",
|
|
26
|
+
"ApiError",
|
|
27
|
+
"ArchiveItem",
|
|
28
|
+
"AsyncClient",
|
|
29
|
+
"AuthenticationError",
|
|
30
|
+
"Client",
|
|
31
|
+
"Diagnostic",
|
|
32
|
+
"DocumentInput",
|
|
33
|
+
"DocumentTooLargeError",
|
|
34
|
+
"Event",
|
|
35
|
+
"InvalidRequestError",
|
|
36
|
+
"NotFinishedError",
|
|
37
|
+
"NotFoundError",
|
|
38
|
+
"PlanLimitError",
|
|
39
|
+
"QuotaExceededError",
|
|
40
|
+
"RateLimitedError",
|
|
41
|
+
"ReportLink",
|
|
42
|
+
"Result",
|
|
43
|
+
"Rules",
|
|
44
|
+
"Schema",
|
|
45
|
+
"ServerError",
|
|
46
|
+
"SignatureVerificationError",
|
|
47
|
+
"TransportError",
|
|
48
|
+
"Usage",
|
|
49
|
+
"Validation",
|
|
50
|
+
"ValidationTimeoutError",
|
|
51
|
+
"XmlvalidError",
|
|
52
|
+
"__version__",
|
|
53
|
+
"webhooks",
|
|
54
|
+
]
|
xmlvalid/_client.py
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import IO, Any
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
import anyio
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from .errors import (
|
|
17
|
+
ApiError,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
DocumentTooLargeError,
|
|
20
|
+
InvalidRequestError,
|
|
21
|
+
NotFinishedError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
PlanLimitError,
|
|
24
|
+
QuotaExceededError,
|
|
25
|
+
RateLimitedError,
|
|
26
|
+
ServerError,
|
|
27
|
+
TransportError,
|
|
28
|
+
ValidationTimeoutError,
|
|
29
|
+
)
|
|
30
|
+
from .models import ReportLink, Schema, Usage, Validation
|
|
31
|
+
|
|
32
|
+
DEFAULT_BASE_URL = "https://xmlvalid.com/api/v1"
|
|
33
|
+
|
|
34
|
+
DocumentInput = str | os.PathLike[str] | bytes | IO[bytes]
|
|
35
|
+
"""A file path, the document's bytes, or a binary file object."""
|
|
36
|
+
|
|
37
|
+
Rules = str | os.PathLike[str] | Mapping[str, str | os.PathLike[str]]
|
|
38
|
+
"""Your own Schematron: a .sch file, or {relative path: file} for one with includes."""
|
|
39
|
+
|
|
40
|
+
_XML_EXTENSIONS = (".xml", ".xsd", ".sch", ".rng", ".xsl", ".xslt", ".svg", ".xhtml", ".gpx", ".kml")
|
|
41
|
+
_CHUNK = 1 << 16
|
|
42
|
+
_BUFFER = 1 << 21
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _Document:
|
|
46
|
+
"""The document to validate, readable again for a retry when it can be."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, document: DocumentInput | None, text: str | None, url: str | None, name: str | None) -> None:
|
|
49
|
+
if sum(value is not None for value in (document, text, url)) != 1:
|
|
50
|
+
raise ValueError("Pass one of document, text= or url=.")
|
|
51
|
+
|
|
52
|
+
self.url = url
|
|
53
|
+
self.path: str | None = None
|
|
54
|
+
self.data: bytes | None = None
|
|
55
|
+
self.stream: IO[bytes] | None = None
|
|
56
|
+
self.start = 0
|
|
57
|
+
|
|
58
|
+
if text is not None:
|
|
59
|
+
self.data = text.encode()
|
|
60
|
+
elif isinstance(document, bytes):
|
|
61
|
+
self.data = document
|
|
62
|
+
elif isinstance(document, (str, os.PathLike)):
|
|
63
|
+
self.path = os.fspath(document)
|
|
64
|
+
if not os.path.isfile(self.path):
|
|
65
|
+
raise ValueError(f"Cannot read the file {self.path}.")
|
|
66
|
+
elif document is not None:
|
|
67
|
+
self.stream = document
|
|
68
|
+
self.start = document.tell() if document.seekable() else 0
|
|
69
|
+
|
|
70
|
+
default = os.path.basename(self.path or str(getattr(self.stream, "name", "")) or "") or "document.xml"
|
|
71
|
+
self.name = name or (os.path.basename(url.split("?")[0]) if url else default)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def size(self) -> int | None:
|
|
75
|
+
if self.data is not None:
|
|
76
|
+
return len(self.data)
|
|
77
|
+
if self.path is not None:
|
|
78
|
+
return os.path.getsize(self.path)
|
|
79
|
+
if self.stream is not None and self.stream.seekable():
|
|
80
|
+
end = self.stream.seek(0, io.SEEK_END)
|
|
81
|
+
self.stream.seek(self.start)
|
|
82
|
+
return end - self.start
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def rereadable(self) -> bool:
|
|
87
|
+
return self.stream is None or self.stream.seekable()
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def content_type(self) -> str:
|
|
91
|
+
return "application/xml" if self.name.lower().endswith(_XML_EXTENSIONS) else "application/octet-stream"
|
|
92
|
+
|
|
93
|
+
def open(self) -> tuple[IO[bytes], bool]:
|
|
94
|
+
"""A file object at the document's start, and whether the caller closes it."""
|
|
95
|
+
if self.data is not None:
|
|
96
|
+
return io.BytesIO(self.data), True
|
|
97
|
+
if self.path is not None:
|
|
98
|
+
return open(self.path, "rb"), True
|
|
99
|
+
assert self.stream is not None
|
|
100
|
+
if self.stream.seekable():
|
|
101
|
+
self.stream.seek(self.start)
|
|
102
|
+
return self.stream, False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class _Call:
|
|
107
|
+
"""A request, built afresh for each attempt."""
|
|
108
|
+
|
|
109
|
+
method: str
|
|
110
|
+
path: str
|
|
111
|
+
params: dict[str, str] = field(default_factory=dict)
|
|
112
|
+
json: Any = None
|
|
113
|
+
accept: str = "application/json"
|
|
114
|
+
document: _Document | None = None
|
|
115
|
+
"""Sent as the raw body."""
|
|
116
|
+
form: dict[str, str | list[str]] | None = None
|
|
117
|
+
files: list[tuple[str, tuple[str, str | _Document, str]]] | None = None
|
|
118
|
+
"""Multipart parts: (field, (filename, path or document, content type))."""
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def retryable(self) -> bool:
|
|
122
|
+
documents = [self.document] if self.document is not None else []
|
|
123
|
+
documents += [source for _, (_, source, _) in self.files or [] if isinstance(source, _Document)]
|
|
124
|
+
return all(document.rereadable for document in documents)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _BaseClient:
|
|
128
|
+
def __init__(self, api_key: str, base_url: str, timeout: float, wait: int, max_retries: int) -> None:
|
|
129
|
+
if not api_key:
|
|
130
|
+
raise ValueError("An API key (xv_…) from https://xmlvalid.com/dashboard/api is needed.")
|
|
131
|
+
self._api_key = api_key
|
|
132
|
+
self._base_url = base_url.rstrip("/")
|
|
133
|
+
self._timeout = timeout
|
|
134
|
+
self._wait = max(0, min(60, wait))
|
|
135
|
+
self._max_retries = max_retries
|
|
136
|
+
|
|
137
|
+
def _headers(self, accept: str) -> dict[str, str]:
|
|
138
|
+
return {
|
|
139
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
140
|
+
"Accept": accept,
|
|
141
|
+
"User-Agent": f"xmlvalid-python/{__version__} Python/{platform.python_version()}",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def _submit_call(
|
|
145
|
+
self,
|
|
146
|
+
document: _Document,
|
|
147
|
+
schema: str | None,
|
|
148
|
+
schema_file: str | os.PathLike[str] | None,
|
|
149
|
+
doctype: bool,
|
|
150
|
+
rules: Rules | None,
|
|
151
|
+
wait: int,
|
|
152
|
+
) -> _Call:
|
|
153
|
+
wait = max(0, min(60, wait))
|
|
154
|
+
if schema_file is not None or rules is not None:
|
|
155
|
+
return self._multipart_call(document, schema, schema_file, doctype, rules, wait)
|
|
156
|
+
if document.url is not None:
|
|
157
|
+
body: dict[str, Any] = {"url": document.url, "wait": wait}
|
|
158
|
+
if schema is not None:
|
|
159
|
+
body["schema"] = schema
|
|
160
|
+
if doctype:
|
|
161
|
+
body["doctype"] = True
|
|
162
|
+
return _Call("POST", "/validations", json=body)
|
|
163
|
+
|
|
164
|
+
params = {"wait": str(wait), "name": document.name}
|
|
165
|
+
if schema is not None:
|
|
166
|
+
params["schema"] = schema
|
|
167
|
+
if doctype:
|
|
168
|
+
params["doctype"] = "1"
|
|
169
|
+
return _Call("POST", "/validations", params=params, document=document)
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _multipart_call(
|
|
173
|
+
document: _Document,
|
|
174
|
+
schema: str | None,
|
|
175
|
+
schema_file: str | os.PathLike[str] | None,
|
|
176
|
+
doctype: bool,
|
|
177
|
+
rules: Rules | None,
|
|
178
|
+
wait: int,
|
|
179
|
+
) -> _Call:
|
|
180
|
+
form: dict[str, str | list[str]] = {"wait": str(wait)}
|
|
181
|
+
files: list[tuple[str, tuple[str, str | _Document, str]]] = []
|
|
182
|
+
if document.url is not None:
|
|
183
|
+
form["url"] = document.url
|
|
184
|
+
else:
|
|
185
|
+
files.append(("document", (document.name, document, document.content_type)))
|
|
186
|
+
if schema is not None:
|
|
187
|
+
form["schema"] = schema
|
|
188
|
+
if doctype:
|
|
189
|
+
form["doctype"] = "1"
|
|
190
|
+
|
|
191
|
+
def part(path: str | os.PathLike[str], filename: str | None = None) -> tuple[str, str | _Document, str]:
|
|
192
|
+
path = os.fspath(path)
|
|
193
|
+
if not os.path.isfile(path):
|
|
194
|
+
raise ValueError(f"Cannot read the file {path}.")
|
|
195
|
+
return filename or os.path.basename(path), path, "application/octet-stream"
|
|
196
|
+
|
|
197
|
+
if schema_file is not None:
|
|
198
|
+
files.append(("schema_file", part(schema_file)))
|
|
199
|
+
if isinstance(rules, Mapping):
|
|
200
|
+
paths = list(rules)
|
|
201
|
+
files += [("rules_file[]", part(rules[relative], os.path.basename(relative))) for relative in paths]
|
|
202
|
+
# httpx sends a list as repeated fields; rules_path[] pairs with rules_file[] by position.
|
|
203
|
+
form["rules_path[]"] = paths
|
|
204
|
+
return _Call("POST", "/validations", form=form, files=files)
|
|
205
|
+
if rules is not None:
|
|
206
|
+
files.append(("rules_file", part(rules)))
|
|
207
|
+
return _Call("POST", "/validations", form=form, files=files)
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def _id(validation: Validation | str) -> str:
|
|
211
|
+
return validation if isinstance(validation, str) else validation.id
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _path(id: str, suffix: str = "") -> str:
|
|
215
|
+
return f"/validations/{quote(id, safe='')}{suffix}"
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _error(response: httpx.Response) -> ApiError:
|
|
219
|
+
status = response.status_code
|
|
220
|
+
try:
|
|
221
|
+
data = response.json()
|
|
222
|
+
except ValueError:
|
|
223
|
+
data = None
|
|
224
|
+
error = data.get("error") if isinstance(data, dict) else None
|
|
225
|
+
error = error if isinstance(error, dict) else {}
|
|
226
|
+
code = error.get("code")
|
|
227
|
+
code = code if isinstance(code, str) else ("server_error" if status >= 500 else "http_error")
|
|
228
|
+
message = error.get("message")
|
|
229
|
+
fields = error.get("fields")
|
|
230
|
+
problems = {
|
|
231
|
+
str(name): [str(item) for item in value] if isinstance(value, list) else []
|
|
232
|
+
for name, value in (fields.items() if isinstance(fields, dict) else [])
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
kind: type[ApiError] = ApiError
|
|
236
|
+
if status == 401:
|
|
237
|
+
kind = AuthenticationError
|
|
238
|
+
elif status == 403:
|
|
239
|
+
kind = PlanLimitError
|
|
240
|
+
elif status == 404:
|
|
241
|
+
kind = NotFoundError
|
|
242
|
+
elif status == 409:
|
|
243
|
+
kind = NotFinishedError
|
|
244
|
+
elif status == 413:
|
|
245
|
+
kind = DocumentTooLargeError
|
|
246
|
+
elif status == 422:
|
|
247
|
+
kind = InvalidRequestError
|
|
248
|
+
elif status == 429:
|
|
249
|
+
kind = QuotaExceededError if code == "quota_exceeded" else RateLimitedError
|
|
250
|
+
elif status >= 500:
|
|
251
|
+
kind = ServerError
|
|
252
|
+
|
|
253
|
+
text = message if isinstance(message, str) and message else f"xmlvalid answered HTTP {status}."
|
|
254
|
+
exception = kind(text, status, code, problems)
|
|
255
|
+
if isinstance(exception, RateLimitedError):
|
|
256
|
+
retry_after = response.headers.get("Retry-After", "")
|
|
257
|
+
exception.retry_after = int(retry_after) if retry_after.isdigit() else 0
|
|
258
|
+
return exception
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _decode(response: httpx.Response) -> dict[str, Any]:
|
|
262
|
+
try:
|
|
263
|
+
data = response.json()
|
|
264
|
+
except ValueError as e:
|
|
265
|
+
raise TransportError("xmlvalid answered with something other than JSON.") from e
|
|
266
|
+
if not isinstance(data, dict):
|
|
267
|
+
raise TransportError("xmlvalid answered with something other than a JSON object.")
|
|
268
|
+
return {str(key): value for key, value in data.items()}
|
|
269
|
+
|
|
270
|
+
@staticmethod
|
|
271
|
+
def _schemas(data: dict[str, Any]) -> list[Schema]:
|
|
272
|
+
def entries(key: str) -> list[dict[str, Any]]:
|
|
273
|
+
value = data.get(key)
|
|
274
|
+
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
275
|
+
|
|
276
|
+
return [Schema.from_dict(item, True) for item in entries("saved")] + [
|
|
277
|
+
Schema.from_dict(item, False) for item in entries("catalog")
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
def _retry_delay(self, error: ApiError, attempt: int, call: _Call) -> float | None:
|
|
281
|
+
"""Seconds to wait before retrying, or None to raise the error."""
|
|
282
|
+
if isinstance(error, RateLimitedError) and attempt < self._max_retries and call.retryable:
|
|
283
|
+
return float(max(1, min(60, error.retry_after)))
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class Client(_BaseClient):
|
|
288
|
+
"""Client for the xmlvalid API (https://xmlvalid.com/docs/api).
|
|
289
|
+
|
|
290
|
+
::
|
|
291
|
+
|
|
292
|
+
from xmlvalid import Client
|
|
293
|
+
|
|
294
|
+
xmlvalid = Client("xv_…")
|
|
295
|
+
validation = xmlvalid.validate("invoice.xml", schema="peppol-bis-3-invoice")
|
|
296
|
+
for error in validation.errors:
|
|
297
|
+
print(error) # Line 139: [BR-CO-16] Amount due for payment …
|
|
298
|
+
|
|
299
|
+
:param api_key: An API key (xv_…) from https://xmlvalid.com/dashboard/api
|
|
300
|
+
:param timeout: Seconds :meth:`validate` waits for a result in all
|
|
301
|
+
:param wait: Seconds the API holds a new validation's request before answering with its status (0-60)
|
|
302
|
+
:param max_retries: Times a request refused with 429 rate_limited is retried, after Retry-After
|
|
303
|
+
:param http_client: An ``httpx.Client`` of your own (proxies, certificates, transports)
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
def __init__(
|
|
307
|
+
self,
|
|
308
|
+
api_key: str | None = None,
|
|
309
|
+
*,
|
|
310
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
311
|
+
timeout: float = 900,
|
|
312
|
+
wait: int = 10,
|
|
313
|
+
max_retries: int = 3,
|
|
314
|
+
http_client: httpx.Client | None = None,
|
|
315
|
+
) -> None:
|
|
316
|
+
super().__init__(api_key or os.environ.get("XMLVALID_API_KEY", ""), base_url, timeout, wait, max_retries)
|
|
317
|
+
self._http = http_client or httpx.Client(timeout=httpx.Timeout(120, connect=10))
|
|
318
|
+
self._owns_http = http_client is None
|
|
319
|
+
|
|
320
|
+
def __enter__(self) -> Client:
|
|
321
|
+
return self
|
|
322
|
+
|
|
323
|
+
def __exit__(self, *_: object) -> None:
|
|
324
|
+
self.close()
|
|
325
|
+
|
|
326
|
+
def close(self) -> None:
|
|
327
|
+
if self._owns_http:
|
|
328
|
+
self._http.close()
|
|
329
|
+
|
|
330
|
+
def validate(
|
|
331
|
+
self,
|
|
332
|
+
document: DocumentInput | None = None,
|
|
333
|
+
*,
|
|
334
|
+
text: str | None = None,
|
|
335
|
+
url: str | None = None,
|
|
336
|
+
name: str | None = None,
|
|
337
|
+
schema: str | None = None,
|
|
338
|
+
schema_file: str | os.PathLike[str] | None = None,
|
|
339
|
+
rules: Rules | None = None,
|
|
340
|
+
doctype: bool = False,
|
|
341
|
+
timeout: float | None = None,
|
|
342
|
+
) -> Validation:
|
|
343
|
+
"""Validates a document and waits for the result.
|
|
344
|
+
|
|
345
|
+
:param document: A file path (XML, .gz, .zip, EPUB, Factur-X PDF), the document's bytes, or a binary file object
|
|
346
|
+
:param text: The document as a string, instead
|
|
347
|
+
:param url: A public http(s) address xmlvalid downloads itself, instead
|
|
348
|
+
:param name: The document's name in results and reports (default: the file's)
|
|
349
|
+
:param schema: A catalog slug (peppol-bis-3-invoice), a saved schema (my-12), "auto" for the profile
|
|
350
|
+
an e-invoice names, or None to check well-formedness only
|
|
351
|
+
:param schema_file: An XSD, DTD or RelaxNG file to validate against instead
|
|
352
|
+
:param rules: Your own Schematron, run after the schema check
|
|
353
|
+
:param doctype: Validate against the DTD the DOCTYPE names
|
|
354
|
+
:param timeout: Seconds to wait in all (default: the client's)
|
|
355
|
+
:raises ValidationTimeoutError: when it is still running after ``timeout``
|
|
356
|
+
"""
|
|
357
|
+
timeout = self._timeout if timeout is None else timeout
|
|
358
|
+
deadline = time.monotonic() + timeout
|
|
359
|
+
validation = self.submit(
|
|
360
|
+
document, text=text, url=url, name=name, schema=schema, schema_file=schema_file,
|
|
361
|
+
rules=rules, doctype=doctype, wait=int(min(self._wait, max(0, timeout))),
|
|
362
|
+
) # fmt: skip
|
|
363
|
+
return self.wait_for(validation, timeout=max(0.0, deadline - time.monotonic()))
|
|
364
|
+
|
|
365
|
+
def submit(
|
|
366
|
+
self,
|
|
367
|
+
document: DocumentInput | None = None,
|
|
368
|
+
*,
|
|
369
|
+
text: str | None = None,
|
|
370
|
+
url: str | None = None,
|
|
371
|
+
name: str | None = None,
|
|
372
|
+
schema: str | None = None,
|
|
373
|
+
schema_file: str | os.PathLike[str] | None = None,
|
|
374
|
+
rules: Rules | None = None,
|
|
375
|
+
doctype: bool = False,
|
|
376
|
+
wait: int = 0,
|
|
377
|
+
) -> Validation:
|
|
378
|
+
"""Queues a validation and returns it at once (or after ``wait`` seconds, if it is done by then)."""
|
|
379
|
+
call = self._submit_call(_Document(document, text, url, name), schema, schema_file, doctype, rules, wait)
|
|
380
|
+
return Validation.from_dict(self._decode(self._send(call)))
|
|
381
|
+
|
|
382
|
+
def wait_for(self, validation: Validation | str, *, timeout: float | None = None) -> Validation:
|
|
383
|
+
"""Waits until a validation has finished, polling its status.
|
|
384
|
+
|
|
385
|
+
:raises ValidationTimeoutError: when it is still running after ``timeout``
|
|
386
|
+
"""
|
|
387
|
+
timeout = self._timeout if timeout is None else timeout
|
|
388
|
+
deadline = time.monotonic() + timeout
|
|
389
|
+
current = self.validation(validation) if isinstance(validation, str) else validation
|
|
390
|
+
interval = 1.0
|
|
391
|
+
while not current.is_finished:
|
|
392
|
+
left = deadline - time.monotonic()
|
|
393
|
+
if left <= 0:
|
|
394
|
+
raise ValidationTimeoutError(current, timeout)
|
|
395
|
+
time.sleep(min(interval, left))
|
|
396
|
+
interval = min(5.0, interval * 1.5)
|
|
397
|
+
current = self.validation(current.id)
|
|
398
|
+
return current
|
|
399
|
+
|
|
400
|
+
def validation(self, id: str) -> Validation:
|
|
401
|
+
"""A validation by its id (val_…)."""
|
|
402
|
+
return Validation.from_dict(self._decode(self._send(_Call("GET", self._path(id)))))
|
|
403
|
+
|
|
404
|
+
def cancel(self, validation: Validation | str) -> Validation:
|
|
405
|
+
"""Cancels a queued or running validation."""
|
|
406
|
+
return Validation.from_dict(self._decode(self._send(_Call("DELETE", self._path(self._id(validation))))))
|
|
407
|
+
|
|
408
|
+
def report(self, validation: Validation | str) -> str:
|
|
409
|
+
"""The result as an HTML page."""
|
|
410
|
+
return self._send(_Call("GET", self._path(self._id(validation), "/report"), accept="text/html")).text
|
|
411
|
+
|
|
412
|
+
def share(self, validations: Sequence[Validation | str]) -> ReportLink:
|
|
413
|
+
"""A public link to the report of one or more validations, for 30 days (Pro)."""
|
|
414
|
+
body = {"validations": [self._id(validation) for validation in validations]}
|
|
415
|
+
return ReportLink.from_dict(self._decode(self._send(_Call("POST", "/reports", json=body))))
|
|
416
|
+
|
|
417
|
+
def usage(self) -> Usage:
|
|
418
|
+
"""This month's validations and the plan's limits."""
|
|
419
|
+
return Usage.from_dict(self._decode(self._send(_Call("GET", "/usage"))))
|
|
420
|
+
|
|
421
|
+
def schemas(self) -> list[Schema]:
|
|
422
|
+
"""The account's saved schemas, then the public catalog."""
|
|
423
|
+
return self._schemas(self._decode(self._send(_Call("GET", "/schemas"))))
|
|
424
|
+
|
|
425
|
+
def _send(self, call: _Call) -> httpx.Response:
|
|
426
|
+
attempt = 0
|
|
427
|
+
while True:
|
|
428
|
+
opened: list[IO[bytes]] = []
|
|
429
|
+
try:
|
|
430
|
+
response = self._http.request(call.method, self._base_url + call.path, **self._options(call, opened))
|
|
431
|
+
except httpx.TransportError as e:
|
|
432
|
+
raise TransportError(f"Request to xmlvalid failed: {e}") from e
|
|
433
|
+
finally:
|
|
434
|
+
for handle in opened:
|
|
435
|
+
handle.close()
|
|
436
|
+
|
|
437
|
+
if response.status_code < 400:
|
|
438
|
+
return response
|
|
439
|
+
error = self._error(response)
|
|
440
|
+
delay = self._retry_delay(error, attempt, call)
|
|
441
|
+
if delay is None:
|
|
442
|
+
raise error
|
|
443
|
+
time.sleep(delay)
|
|
444
|
+
attempt += 1
|
|
445
|
+
|
|
446
|
+
def _options(self, call: _Call, opened: list[IO[bytes]]) -> dict[str, Any]:
|
|
447
|
+
options: dict[str, Any] = {"headers": self._headers(call.accept), "params": call.params}
|
|
448
|
+
if call.json is not None:
|
|
449
|
+
options["json"] = call.json
|
|
450
|
+
if call.document is not None:
|
|
451
|
+
handle, owned = call.document.open()
|
|
452
|
+
if owned:
|
|
453
|
+
opened.append(handle)
|
|
454
|
+
size = call.document.size
|
|
455
|
+
options["headers"]["Content-Type"] = call.document.content_type
|
|
456
|
+
if size is not None:
|
|
457
|
+
options["headers"]["Content-Length"] = str(size)
|
|
458
|
+
options["content"] = handle.read() if size is not None and size <= _BUFFER else _chunks(handle)
|
|
459
|
+
if call.files is not None:
|
|
460
|
+
options["data"] = call.form
|
|
461
|
+
options["files"] = [
|
|
462
|
+
(name, (filename, _part(source, opened), kind)) for name, (filename, source, kind) in call.files
|
|
463
|
+
]
|
|
464
|
+
return options
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class AsyncClient(_BaseClient):
|
|
468
|
+
"""The same as :class:`Client`, for asyncio and trio.
|
|
469
|
+
|
|
470
|
+
::
|
|
471
|
+
|
|
472
|
+
async with AsyncClient("xv_…") as xmlvalid:
|
|
473
|
+
validation = await xmlvalid.validate("invoice.xml", schema="peppol-bis-3-invoice")
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
def __init__(
|
|
477
|
+
self,
|
|
478
|
+
api_key: str | None = None,
|
|
479
|
+
*,
|
|
480
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
481
|
+
timeout: float = 900,
|
|
482
|
+
wait: int = 10,
|
|
483
|
+
max_retries: int = 3,
|
|
484
|
+
http_client: httpx.AsyncClient | None = None,
|
|
485
|
+
) -> None:
|
|
486
|
+
super().__init__(api_key or os.environ.get("XMLVALID_API_KEY", ""), base_url, timeout, wait, max_retries)
|
|
487
|
+
self._http = http_client or httpx.AsyncClient(timeout=httpx.Timeout(120, connect=10))
|
|
488
|
+
self._owns_http = http_client is None
|
|
489
|
+
|
|
490
|
+
async def __aenter__(self) -> AsyncClient:
|
|
491
|
+
return self
|
|
492
|
+
|
|
493
|
+
async def __aexit__(self, *_: object) -> None:
|
|
494
|
+
await self.aclose()
|
|
495
|
+
|
|
496
|
+
async def aclose(self) -> None:
|
|
497
|
+
if self._owns_http:
|
|
498
|
+
await self._http.aclose()
|
|
499
|
+
|
|
500
|
+
async def validate(
|
|
501
|
+
self,
|
|
502
|
+
document: DocumentInput | None = None,
|
|
503
|
+
*,
|
|
504
|
+
text: str | None = None,
|
|
505
|
+
url: str | None = None,
|
|
506
|
+
name: str | None = None,
|
|
507
|
+
schema: str | None = None,
|
|
508
|
+
schema_file: str | os.PathLike[str] | None = None,
|
|
509
|
+
rules: Rules | None = None,
|
|
510
|
+
doctype: bool = False,
|
|
511
|
+
timeout: float | None = None,
|
|
512
|
+
) -> Validation:
|
|
513
|
+
"""Validates a document and waits for the result; see :meth:`Client.validate`."""
|
|
514
|
+
timeout = self._timeout if timeout is None else timeout
|
|
515
|
+
deadline = time.monotonic() + timeout
|
|
516
|
+
validation = await self.submit(
|
|
517
|
+
document, text=text, url=url, name=name, schema=schema, schema_file=schema_file,
|
|
518
|
+
rules=rules, doctype=doctype, wait=int(min(self._wait, max(0, timeout))),
|
|
519
|
+
) # fmt: skip
|
|
520
|
+
return await self.wait_for(validation, timeout=max(0.0, deadline - time.monotonic()))
|
|
521
|
+
|
|
522
|
+
async def submit(
|
|
523
|
+
self,
|
|
524
|
+
document: DocumentInput | None = None,
|
|
525
|
+
*,
|
|
526
|
+
text: str | None = None,
|
|
527
|
+
url: str | None = None,
|
|
528
|
+
name: str | None = None,
|
|
529
|
+
schema: str | None = None,
|
|
530
|
+
schema_file: str | os.PathLike[str] | None = None,
|
|
531
|
+
rules: Rules | None = None,
|
|
532
|
+
doctype: bool = False,
|
|
533
|
+
wait: int = 0,
|
|
534
|
+
) -> Validation:
|
|
535
|
+
"""Queues a validation and returns it at once; see :meth:`Client.submit`."""
|
|
536
|
+
call = self._submit_call(_Document(document, text, url, name), schema, schema_file, doctype, rules, wait)
|
|
537
|
+
return Validation.from_dict(self._decode(await self._send(call)))
|
|
538
|
+
|
|
539
|
+
async def wait_for(self, validation: Validation | str, *, timeout: float | None = None) -> Validation:
|
|
540
|
+
"""Waits until a validation has finished, polling its status."""
|
|
541
|
+
timeout = self._timeout if timeout is None else timeout
|
|
542
|
+
deadline = time.monotonic() + timeout
|
|
543
|
+
current = await self.validation(validation) if isinstance(validation, str) else validation
|
|
544
|
+
interval = 1.0
|
|
545
|
+
while not current.is_finished:
|
|
546
|
+
left = deadline - time.monotonic()
|
|
547
|
+
if left <= 0:
|
|
548
|
+
raise ValidationTimeoutError(current, timeout)
|
|
549
|
+
await anyio.sleep(min(interval, left))
|
|
550
|
+
interval = min(5.0, interval * 1.5)
|
|
551
|
+
current = await self.validation(current.id)
|
|
552
|
+
return current
|
|
553
|
+
|
|
554
|
+
async def validation(self, id: str) -> Validation:
|
|
555
|
+
return Validation.from_dict(self._decode(await self._send(_Call("GET", self._path(id)))))
|
|
556
|
+
|
|
557
|
+
async def cancel(self, validation: Validation | str) -> Validation:
|
|
558
|
+
return Validation.from_dict(self._decode(await self._send(_Call("DELETE", self._path(self._id(validation))))))
|
|
559
|
+
|
|
560
|
+
async def report(self, validation: Validation | str) -> str:
|
|
561
|
+
return (await self._send(_Call("GET", self._path(self._id(validation), "/report"), accept="text/html"))).text
|
|
562
|
+
|
|
563
|
+
async def share(self, validations: Sequence[Validation | str]) -> ReportLink:
|
|
564
|
+
body = {"validations": [self._id(validation) for validation in validations]}
|
|
565
|
+
return ReportLink.from_dict(self._decode(await self._send(_Call("POST", "/reports", json=body))))
|
|
566
|
+
|
|
567
|
+
async def usage(self) -> Usage:
|
|
568
|
+
return Usage.from_dict(self._decode(await self._send(_Call("GET", "/usage"))))
|
|
569
|
+
|
|
570
|
+
async def schemas(self) -> list[Schema]:
|
|
571
|
+
return self._schemas(self._decode(await self._send(_Call("GET", "/schemas"))))
|
|
572
|
+
|
|
573
|
+
async def _send(self, call: _Call) -> httpx.Response:
|
|
574
|
+
attempt = 0
|
|
575
|
+
while True:
|
|
576
|
+
opened: list[IO[bytes]] = []
|
|
577
|
+
try:
|
|
578
|
+
response = await self._http.request(
|
|
579
|
+
call.method, self._base_url + call.path, **self._options(call, opened)
|
|
580
|
+
)
|
|
581
|
+
except httpx.TransportError as e:
|
|
582
|
+
raise TransportError(f"Request to xmlvalid failed: {e}") from e
|
|
583
|
+
finally:
|
|
584
|
+
for handle in opened:
|
|
585
|
+
handle.close()
|
|
586
|
+
|
|
587
|
+
if response.status_code < 400:
|
|
588
|
+
return response
|
|
589
|
+
error = self._error(response)
|
|
590
|
+
delay = self._retry_delay(error, attempt, call)
|
|
591
|
+
if delay is None:
|
|
592
|
+
raise error
|
|
593
|
+
await anyio.sleep(delay)
|
|
594
|
+
attempt += 1
|
|
595
|
+
|
|
596
|
+
def _options(self, call: _Call, opened: list[IO[bytes]]) -> dict[str, Any]:
|
|
597
|
+
options: dict[str, Any] = {"headers": self._headers(call.accept), "params": call.params}
|
|
598
|
+
if call.json is not None:
|
|
599
|
+
options["json"] = call.json
|
|
600
|
+
if call.document is not None:
|
|
601
|
+
handle, owned = call.document.open()
|
|
602
|
+
if owned:
|
|
603
|
+
opened.append(handle)
|
|
604
|
+
size = call.document.size
|
|
605
|
+
options["headers"]["Content-Type"] = call.document.content_type
|
|
606
|
+
if size is not None:
|
|
607
|
+
options["headers"]["Content-Length"] = str(size)
|
|
608
|
+
options["content"] = handle.read() if size is not None and size <= _BUFFER else _async_chunks(handle)
|
|
609
|
+
if call.files is not None:
|
|
610
|
+
options["data"] = call.form
|
|
611
|
+
options["files"] = [
|
|
612
|
+
(name, (filename, _part(source, opened), kind)) for name, (filename, source, kind) in call.files
|
|
613
|
+
]
|
|
614
|
+
return options
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _part(source: str | _Document, opened: list[IO[bytes]]) -> IO[bytes]:
|
|
618
|
+
if isinstance(source, str):
|
|
619
|
+
handle: IO[bytes] = open(source, "rb") # noqa: SIM115 (closed once the request is sent)
|
|
620
|
+
opened.append(handle)
|
|
621
|
+
return handle
|
|
622
|
+
handle, owned = source.open()
|
|
623
|
+
if owned:
|
|
624
|
+
opened.append(handle)
|
|
625
|
+
return handle
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _chunks(handle: IO[bytes]) -> Iterator[bytes]:
|
|
629
|
+
while chunk := handle.read(_CHUNK):
|
|
630
|
+
yield chunk
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
async def _async_chunks(handle: IO[bytes]) -> AsyncIterator[bytes]:
|
|
634
|
+
while chunk := handle.read(_CHUNK):
|
|
635
|
+
yield chunk
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
__all__ = ["DEFAULT_BASE_URL", "AsyncClient", "Client", "DocumentInput", "Rules"]
|
xmlvalid/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|