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.
Files changed (43) hide show
  1. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/PKG-INFO +6 -1
  2. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/pyproject.toml +9 -1
  3. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/pyproject.toml.orig +12 -1
  4. spot_sdk_python-2.1.0b2/spot_sdk/hosting.py +160 -0
  5. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/manifest.py +37 -0
  6. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/README.md +21 -1
  7. spot_sdk_python-2.1.0b2/spot_sdk/testing/container.py +481 -0
  8. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/contract.py +71 -20
  9. spot_sdk_python-2.1.0b2/spot_sdk/testing/corpus.py +263 -0
  10. spot_sdk_python-2.1.0b2/spot_sdk/testing/engine.py +446 -0
  11. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/fake_spot.py +102 -0
  12. spot_sdk_python-2.1.0b2/spot_sdk/testing/metrics.py +157 -0
  13. spot_sdk_python-2.1.0b2/spot_sdk/testing/report.py +126 -0
  14. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/README.md +0 -0
  15. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/__init__.py +0 -0
  16. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/_json.py +0 -0
  17. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/app.py +0 -0
  18. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/artifacts.py +0 -0
  19. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/clients.py +0 -0
  20. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/config.py +0 -0
  21. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/correlation.py +0 -0
  22. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/email.py +0 -0
  23. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/errors.py +0 -0
  24. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/knowledge.py +0 -0
  25. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/knowledge_tags.py +0 -0
  26. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/llm.py +0 -0
  27. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/logging.py +0 -0
  28. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/mime.py +0 -0
  29. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/model_input.py +0 -0
  30. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/orchestrator.py +0 -0
  31. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/proxy.py +0 -0
  32. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/py.typed +0 -0
  33. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/retriever.py +0 -0
  34. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/settings.py +0 -0
  35. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/signals.py +0 -0
  36. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/__init__.py +0 -0
  37. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/fake_knowledge_client.py +0 -0
  38. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/fake_llm.py +0 -0
  39. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/settings.py +0 -0
  40. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/testing/views.py +0 -0
  41. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/urls.py +0 -0
  42. {spot_sdk_python-2.1.0b1 → spot_sdk_python-2.1.0b2}/spot_sdk/verdict.py +0 -0
  43. {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.0b1
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.0b1"
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.0b1"
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.