spot-sdk-python 2.0.0b13__tar.gz → 2.1.0b2__tar.gz
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.
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/PKG-INFO +7 -2
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/README.md +1 -1
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/pyproject.toml +9 -1
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/pyproject.toml.orig +12 -1
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/email.py +16 -6
- spot_sdk_python-2.1.0b2/spot_sdk/hosting.py +160 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/manifest.py +37 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/mime.py +168 -57
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/README.md +21 -1
- spot_sdk_python-2.1.0b2/spot_sdk/testing/container.py +481 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/contract.py +71 -20
- spot_sdk_python-2.1.0b2/spot_sdk/testing/corpus.py +263 -0
- spot_sdk_python-2.1.0b2/spot_sdk/testing/engine.py +446 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/fake_spot.py +102 -0
- spot_sdk_python-2.1.0b2/spot_sdk/testing/metrics.py +157 -0
- spot_sdk_python-2.1.0b2/spot_sdk/testing/report.py +126 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/__init__.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/_json.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/app.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/artifacts.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/clients.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/config.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/correlation.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/errors.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/knowledge.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/knowledge_tags.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/llm.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/logging.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/model_input.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/orchestrator.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/proxy.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/py.typed +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/retriever.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/settings.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/signals.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/__init__.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/fake_knowledge_client.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/fake_llm.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/settings.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/views.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/urls.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/verdict.py +0 -0
- {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b2}/spot_sdk/workflow.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spot-sdk-python
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0b2
|
|
4
4
|
Summary: SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers
|
|
5
5
|
Author: SPOT Project
|
|
6
6
|
Author-email: SPOT Project <spot@sonn.lu>
|
|
@@ -18,8 +18,13 @@ Requires-Dist: selectolax>=1.0,<1.1
|
|
|
18
18
|
Requires-Dist: idna>=3.11,<4
|
|
19
19
|
Requires-Dist: fastapi>=0.133.0,<1
|
|
20
20
|
Requires-Dist: starlette>=1.7.0,<2
|
|
21
|
+
Requires-Dist: pyyaml>=6.0,<7 ; extra == 'testing'
|
|
22
|
+
Requires-Dist: uvicorn>=0.32,<1 ; extra == 'testing'
|
|
23
|
+
Requires-Dist: docker>=7.1,<8 ; extra == 'testing'
|
|
24
|
+
Requires-Dist: requests>=2.32,<3 ; extra == 'testing'
|
|
21
25
|
Requires-Python: >=3.11, <3.16
|
|
22
26
|
Project-URL: Homepage, https://sonn.lu
|
|
27
|
+
Provides-Extra: testing
|
|
23
28
|
Description-Content-Type: text/markdown
|
|
24
29
|
|
|
25
30
|
# spot-sdk-python
|
|
@@ -50,7 +55,7 @@ plugins share:
|
|
|
50
55
|
## Install
|
|
51
56
|
|
|
52
57
|
```bash
|
|
53
|
-
pip install spot-sdk-python==2.0.
|
|
58
|
+
pip install spot-sdk-python==2.0.0
|
|
54
59
|
```
|
|
55
60
|
|
|
56
61
|
Python 3.11 to 3.15. Version 2 implements plugin contract 2, for
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "spot-sdk-python"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.1.0b2"
|
|
8
8
|
description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -31,6 +31,14 @@ dependencies = [
|
|
|
31
31
|
name = "SPOT Project"
|
|
32
32
|
email = "spot@sonn.lu"
|
|
33
33
|
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
testing = [
|
|
36
|
+
"pyyaml>=6.0,<7",
|
|
37
|
+
"uvicorn>=0.32,<1",
|
|
38
|
+
"docker>=7.1,<8",
|
|
39
|
+
"requests>=2.32,<3",
|
|
40
|
+
]
|
|
41
|
+
|
|
34
42
|
[project.urls]
|
|
35
43
|
Homepage = "https://sonn.lu"
|
|
36
44
|
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "spot-sdk-python"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.1.0b2"
|
|
8
8
|
description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -28,6 +28,17 @@ dependencies = [
|
|
|
28
28
|
"starlette>=1.7.0,<2",
|
|
29
29
|
]
|
|
30
30
|
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
# The plugin test engine: it reads the corpus file (PyYAML), serves the
|
|
33
|
+
# fake SPOT with uvicorn and runs the plugin's image with the Docker SDK,
|
|
34
|
+
# whose connection errors are requests' own.
|
|
35
|
+
testing = [
|
|
36
|
+
"pyyaml>=6.0,<7",
|
|
37
|
+
"uvicorn>=0.32,<1",
|
|
38
|
+
"docker>=7.1,<8",
|
|
39
|
+
"requests>=2.32,<3",
|
|
40
|
+
]
|
|
41
|
+
|
|
31
42
|
[project.urls]
|
|
32
43
|
Homepage = "https://sonn.lu"
|
|
33
44
|
|
|
@@ -26,9 +26,14 @@ class Address(BaseModel):
|
|
|
26
26
|
default="",
|
|
27
27
|
description="As browsers read a host (WHATWG URL Standard): UTS 46-mapped"
|
|
28
28
|
" (lower-cased...), A-labels decoded, no trailing dots; one that can be"
|
|
29
|
-
" no DNS name only lower-cased: over 1 012 characters
|
|
30
|
-
"
|
|
31
|
-
"
|
|
29
|
+
" no DNS name only lower-cased: over 1 012 characters, a label over 252"
|
|
30
|
+
" or over 63 combining marks in a row as UTS 46 maps them, invisible ones"
|
|
31
|
+
" aside (DNS allows"
|
|
32
|
+
" 253 and 63, NFC composes at most 4 code points into one, and an"
|
|
33
|
+
" A-label spells each mark with a character at least); one whose"
|
|
34
|
+
" A-labels not yet decoded do not fit in the 32 768 characters of"
|
|
35
|
+
" distinct A-labels a message decodes keeps all its A-labels encoded, as"
|
|
36
|
+
" UTS 46 maps them (host_limit)",
|
|
32
37
|
)
|
|
33
38
|
|
|
34
39
|
|
|
@@ -83,9 +88,14 @@ class Url(BaseModel):
|
|
|
83
88
|
" reads it: percent-decoded, UTS 46-mapped (lower-cased...), A-labels"
|
|
84
89
|
" decoded when UTS 46 leaves them as they are, no trailing dots; one"
|
|
85
90
|
" browsers refuse is kept as read, one that can be no DNS name only"
|
|
86
|
-
" lower-cased: over 1 012 characters
|
|
87
|
-
"
|
|
88
|
-
|
|
91
|
+
" lower-cased: over 1 012 characters, a label over 252 or over 63"
|
|
92
|
+
" combining marks in a row as UTS 46 maps them, invisible ones aside (DNS"
|
|
93
|
+
" allows 253 and 63,"
|
|
94
|
+
" NFC composes at most 4 code points into one, and an A-label spells"
|
|
95
|
+
" each mark with a character at least); one whose A-labels not yet"
|
|
96
|
+
" decoded do not fit in the 32 768 characters of distinct A-labels a"
|
|
97
|
+
" message decodes keeps all its A-labels encoded, as UTS 46 maps them"
|
|
98
|
+
' (host_limit); "" when none'
|
|
89
99
|
)
|
|
90
100
|
found_in: Literal["text", "html_href", "html_text", "header"] = Field(
|
|
91
101
|
description="html_text: written in the HTML's visible text, not in an attribute"
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""How core hosts a plugin and reads its answers.
|
|
2
|
+
|
|
3
|
+
The plugin-manager creates every plugin container with these options, and
|
|
4
|
+
``spot-cli plugin test`` creates the container under test with them too, so
|
|
5
|
+
that a plugin runs in SPOT as it ran in its test. Core reads a plugin's
|
|
6
|
+
answers with ``read_answer`` and ``parse_verdict``, and so does the test.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import tarfile
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from types import MappingProxyType
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
|
|
19
|
+
from .manifest import Egress, PluginKind, Resources
|
|
20
|
+
from .settings import SECRETS_DIR
|
|
21
|
+
from .verdict import AnalyzerVerdict
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
import httpx2
|
|
25
|
+
|
|
26
|
+
HARDENING: Mapping[str, Any] = MappingProxyType(
|
|
27
|
+
{
|
|
28
|
+
"cap_drop": ["ALL"],
|
|
29
|
+
"security_opt": ["no-new-privileges:true"],
|
|
30
|
+
"pids_limit": 512,
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
"""The options every plugin container is created with, as the Docker SDK names them."""
|
|
34
|
+
|
|
35
|
+
TOKEN_SECRET = "spot_plugin_token"
|
|
36
|
+
"""The file of ``SECRETS_DIR`` that holds the plugin's own token."""
|
|
37
|
+
|
|
38
|
+
MAX_ANSWER_BYTES = 1 << 20
|
|
39
|
+
"""The most of a plugin's answer core reads: a verdict, a sync state or a health is far smaller."""
|
|
40
|
+
|
|
41
|
+
IDENTITY: Mapping[str, str] = MappingProxyType({"Accept-Encoding": "identity"})
|
|
42
|
+
"""Asks for an answer as it is: ``read_answer`` refuses an encoded one, which
|
|
43
|
+
could expand past the bound once decoded."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AnswerRefusedError(Exception):
|
|
47
|
+
"""A plugin's answer that is not read: encoded, or longer than ``MAX_ANSWER_BYTES``."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InvalidResponseError(Exception):
|
|
51
|
+
"""An answer that is not an ``AnalyzerVerdict``; the message names where and why, no content."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def isolated(egress: Egress | None) -> bool:
|
|
55
|
+
"""Whether a plugin of this egress class runs on a network without a route
|
|
56
|
+
out: class none. An unrecorded class (None) counts as content."""
|
|
57
|
+
return egress == Egress.NONE
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def limits(resources: Resources) -> dict[str, Any]:
|
|
61
|
+
"""The options that hold a plugin's container to its memory and CPU."""
|
|
62
|
+
return {
|
|
63
|
+
"mem_limit": f"{resources.memory_mb}m",
|
|
64
|
+
"nano_cpus": int(resources.cpus * 1_000_000_000),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def setting_text(value: Any) -> str:
|
|
69
|
+
"""A setting as text, for an environment variable or a secret file."""
|
|
70
|
+
if isinstance(value, bool):
|
|
71
|
+
return "true" if value else "false"
|
|
72
|
+
return "" if value is None else str(value)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def environment(
|
|
76
|
+
settings: Mapping[str, Any],
|
|
77
|
+
*,
|
|
78
|
+
plugin_id: str,
|
|
79
|
+
kind: PluginKind,
|
|
80
|
+
version: str,
|
|
81
|
+
gateway_url: str,
|
|
82
|
+
knowledge_url: str,
|
|
83
|
+
proxy: Mapping[str, str] = MappingProxyType({}),
|
|
84
|
+
) -> dict[str, str]:
|
|
85
|
+
"""The environment of a plugin's container: its plain settings in upper
|
|
86
|
+
case, then ``proxy``, then SPOT's variables, each winning over the one
|
|
87
|
+
before. No credential is in it: the token and the proxy's user and
|
|
88
|
+
password are secret files."""
|
|
89
|
+
env = {name.upper(): setting_text(value) for name, value in settings.items()}
|
|
90
|
+
env.update(proxy)
|
|
91
|
+
env.update(
|
|
92
|
+
{
|
|
93
|
+
"SPOT_GATEWAY_URL": gateway_url,
|
|
94
|
+
"SPOT_KNOWLEDGE_URL": knowledge_url,
|
|
95
|
+
"SPOT_PLUGIN_ID": plugin_id,
|
|
96
|
+
"SPOT_PLUGIN_KIND": kind.value,
|
|
97
|
+
"SPOT_PLUGIN_VERSION": version,
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
return env
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def secrets_archive(secrets: Mapping[str, str]) -> bytes:
|
|
104
|
+
"""A tar of ``/run/secrets/<name>``, each readable by the plugin's user,
|
|
105
|
+
put in the container before it starts."""
|
|
106
|
+
root = SECRETS_DIR.relative_to("/")
|
|
107
|
+
buffer = io.BytesIO()
|
|
108
|
+
with tarfile.open(fileobj=buffer, mode="w") as tar:
|
|
109
|
+
directory = tarfile.TarInfo(str(root))
|
|
110
|
+
directory.type = tarfile.DIRTYPE
|
|
111
|
+
directory.mode = 0o755
|
|
112
|
+
tar.addfile(directory)
|
|
113
|
+
for name, value in sorted(secrets.items()):
|
|
114
|
+
data = value.encode()
|
|
115
|
+
info = tarfile.TarInfo(f"{root}/{name}")
|
|
116
|
+
info.size = len(data)
|
|
117
|
+
info.mode = 0o444
|
|
118
|
+
tar.addfile(info, io.BytesIO(data))
|
|
119
|
+
return buffer.getvalue()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def read_answer(response: httpx2.Response) -> bytes:
|
|
123
|
+
"""A streamed answer's body, as sent, read up to ``MAX_ANSWER_BYTES``.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
AnswerRefusedError: the answer is encoded, or longer than ``MAX_ANSWER_BYTES``
|
|
127
|
+
"""
|
|
128
|
+
if (
|
|
129
|
+
response.headers.get("content-encoding", "identity").strip().lower()
|
|
130
|
+
!= "identity"
|
|
131
|
+
):
|
|
132
|
+
raise AnswerRefusedError("unsupported content encoding")
|
|
133
|
+
body = bytearray()
|
|
134
|
+
async for chunk in response.aiter_raw():
|
|
135
|
+
body += chunk
|
|
136
|
+
if len(body) > MAX_ANSWER_BYTES:
|
|
137
|
+
raise AnswerRefusedError("response too large")
|
|
138
|
+
return bytes(body)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_verdict(body: bytes) -> AnalyzerVerdict:
|
|
142
|
+
"""The verdict an analyzer answered.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
InvalidResponseError: the body is not an ``AnalyzerVerdict``
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
return AnalyzerVerdict.model_validate_json(body)
|
|
149
|
+
except ValidationError as e:
|
|
150
|
+
cause = validation_cause(e)
|
|
151
|
+
# Raised outside the handler: a validation error quotes its input.
|
|
152
|
+
raise InvalidResponseError(f"invalid response: {cause}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def validation_cause(error: ValidationError) -> str:
|
|
156
|
+
"""Where and why an answer does not validate, as core names it: the first
|
|
157
|
+
error's location and type (``score: less_than_equal``), never the value."""
|
|
158
|
+
first = error.errors()[0]
|
|
159
|
+
where = ".".join(str(part) for part in first["loc"])
|
|
160
|
+
return f"{where}: {first['type']}" if where else first["type"]
|
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
import json
|
|
12
12
|
import math
|
|
13
13
|
import re
|
|
14
|
+
from collections.abc import Mapping
|
|
14
15
|
from enum import StrEnum
|
|
15
16
|
from typing import Any, Self
|
|
16
17
|
|
|
@@ -279,3 +280,39 @@ class Manifest(BaseModel):
|
|
|
279
280
|
if self.resources is not None:
|
|
280
281
|
labels[LABEL_RESOURCES] = self.resources.model_dump_json()
|
|
281
282
|
return labels
|
|
283
|
+
|
|
284
|
+
@classmethod
|
|
285
|
+
def from_labels(cls, labels: Mapping[str, str]) -> Manifest:
|
|
286
|
+
"""The manifest an image's labels declare, as ``labels()`` renders one:
|
|
287
|
+
``Manifest.from_labels(m.labels()) == m``.
|
|
288
|
+
|
|
289
|
+
Labels of other prefixes are ignored. A missing id, version or kind,
|
|
290
|
+
or a label that does not parse, raises ``ValueError``.
|
|
291
|
+
"""
|
|
292
|
+
data: dict[str, Any] = {
|
|
293
|
+
name: labels[label]
|
|
294
|
+
for name, label in (
|
|
295
|
+
("id", LABEL_ID),
|
|
296
|
+
("version", LABEL_VERSION),
|
|
297
|
+
("kind", LABEL_KIND),
|
|
298
|
+
("contract", LABEL_CONTRACT),
|
|
299
|
+
("egress", LABEL_EGRESS),
|
|
300
|
+
("port", LABEL_PORT),
|
|
301
|
+
("healthcheck", LABEL_HEALTHCHECK),
|
|
302
|
+
("compatibility", LABEL_COMPATIBILITY),
|
|
303
|
+
)
|
|
304
|
+
if label in labels
|
|
305
|
+
}
|
|
306
|
+
if LABEL_CAPABILITIES in labels:
|
|
307
|
+
data["capabilities"] = [
|
|
308
|
+
c for c in labels[LABEL_CAPABILITIES].split(",") if c
|
|
309
|
+
]
|
|
310
|
+
if LABEL_SETTINGS in labels:
|
|
311
|
+
data["settings"] = json.loads(labels[LABEL_SETTINGS])
|
|
312
|
+
if LABEL_RESOURCES in labels:
|
|
313
|
+
data["resources"] = json.loads(labels[LABEL_RESOURCES])
|
|
314
|
+
if LABEL_REQUIRES_EXTERNAL_INGRESS in labels:
|
|
315
|
+
data["requires_external_ingress"] = (
|
|
316
|
+
labels[LABEL_REQUIRES_EXTERNAL_INGRESS] == "true"
|
|
317
|
+
)
|
|
318
|
+
return cls.model_validate(data)
|