spot-sdk-python 2.1.0b1__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.1.0b1 → spot_sdk_python-2.1.0b2}/PKG-INFO +6 -1
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/pyproject.toml +9 -1
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/pyproject.toml.orig +12 -1
- spot_sdk_python-2.1.0b2/spot_sdk/hosting.py +160 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/manifest.py +37 -0
- {spot_sdk_python-2.1.0b1 → 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.1.0b1 → 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.1.0b1 → 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.1.0b1 → spot_sdk_python-2.1.0b2}/README.md +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/__init__.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/_json.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/app.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/artifacts.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/clients.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/config.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/correlation.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/email.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/errors.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/knowledge.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/knowledge_tags.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/llm.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/logging.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/mime.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/model_input.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/orchestrator.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/proxy.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/py.typed +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/retriever.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/settings.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/signals.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/__init__.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/fake_knowledge_client.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/fake_llm.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/settings.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/views.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/urls.py +0 -0
- {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/verdict.py +0 -0
- {spot_sdk_python-2.1.0b1 → 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.1.
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "spot-sdk-python"
|
|
7
|
-
version = "2.1.
|
|
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.1.
|
|
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
|
|
|
@@ -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)
|
|
@@ -11,7 +11,7 @@ shows them in use.
|
|
|
11
11
|
| `assert_contract(app, eml, *, token, timeout=10.0)` | waits until the plugin has started (`/health` is `healthy`), runs the app's `/internal/analyze` on a parsed `.eml` as core does, and checks the token and the `AnalyzerVerdict` |
|
|
12
12
|
| `assert_bounds_agree(manifest, settings_cls)` | checks that the manifest states the bounds (`ge`, `le`, `pattern`...) of the settings model |
|
|
13
13
|
| `assert_provider_contract(app, knowledge, *, token)` | runs two syncs of a context provider as core does, and checks the token, `/internal/sync`, `/internal/state` and the documents written to `knowledge` |
|
|
14
|
-
| `FakeSpot` | `SpotClient` in memory: `ingest`, `raw`, `part`, `result` |
|
|
14
|
+
| `FakeSpot` | `SpotClient` in memory: `ingest`, `raw`, `part`, `result`; `app(token=, knowledge=)` serves it over HTTP |
|
|
15
15
|
| `FakeKnowledgeClient` | `KnowledgeClient` in memory, with naive word matching |
|
|
16
16
|
| `FakeLLM` | `LLMClient` with scripted answers, recording each request |
|
|
17
17
|
|
|
@@ -29,3 +29,23 @@ async def test_the_store_is_read(monkeypatch, documents):
|
|
|
29
29
|
await kb.replace_source(documents)
|
|
30
30
|
monkeypatch.setattr(KnowledgeClient, "for_analysis", kb.for_analysis)
|
|
31
31
|
```
|
|
32
|
+
|
|
33
|
+
## The plugin test engine
|
|
34
|
+
|
|
35
|
+
With the `testing` extra (`pip install "spot-sdk-python[testing]"`), the
|
|
36
|
+
package also runs a plugin's image as SPOT runs it, against
|
|
37
|
+
`tests/emls/{phish,benign}/` and an optional `corpus.yaml`. `spot-cli plugin
|
|
38
|
+
test` uses it, from SPOT 2.1.0-beta.2. Import each module by name: `corpus`,
|
|
39
|
+
`engine` and `container` raise an `ImportError` that names the extra when it
|
|
40
|
+
is missing.
|
|
41
|
+
|
|
42
|
+
| Module | What it has |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `container` | `run_plugin_test(image, corpus, ...)`: runs the image in a network of its own, checks it, removes what it created, returns a `Report` |
|
|
45
|
+
| `corpus` | `load_corpus(directory)`, `CorpusFile`, `Expectation` |
|
|
46
|
+
| `engine` | `check_plugin`: the checks, run over HTTP; `serve`: an ASGI app in a thread |
|
|
47
|
+
| `report` | `Report`, `Check`, `EmailResult`, `PluginTestError`, `junit` |
|
|
48
|
+
| `metrics` | `count`, `wilson`, `Metrics`: those of `spot-cli benchmark report` |
|
|
49
|
+
|
|
50
|
+
The [guide](https://spot-project.codeberg.page/documentation/sdk/DEVELOPMENT-GUIDE/#testing-the-image)
|
|
51
|
+
shows the corpus, the checks, the outputs and the exit codes.
|