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 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"