spot-sdk-python 2.0.0b13__tar.gz → 2.1.0__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.0.0b13 → spot_sdk_python-2.1.0}/PKG-INFO +7 -2
  2. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/README.md +1 -1
  3. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/pyproject.toml +9 -1
  4. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/pyproject.toml.orig +12 -1
  5. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/email.py +16 -6
  6. spot_sdk_python-2.1.0/spot_sdk/hosting.py +164 -0
  7. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/manifest.py +37 -0
  8. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/mime.py +168 -57
  9. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/testing/README.md +21 -1
  10. spot_sdk_python-2.1.0/spot_sdk/testing/container.py +481 -0
  11. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/testing/contract.py +71 -20
  12. spot_sdk_python-2.1.0/spot_sdk/testing/corpus.py +263 -0
  13. spot_sdk_python-2.1.0/spot_sdk/testing/engine.py +446 -0
  14. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/testing/fake_spot.py +102 -0
  15. spot_sdk_python-2.1.0/spot_sdk/testing/metrics.py +157 -0
  16. spot_sdk_python-2.1.0/spot_sdk/testing/report.py +126 -0
  17. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/__init__.py +0 -0
  18. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/_json.py +0 -0
  19. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/app.py +0 -0
  20. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/artifacts.py +0 -0
  21. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/clients.py +0 -0
  22. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/config.py +0 -0
  23. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/correlation.py +0 -0
  24. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/errors.py +0 -0
  25. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/knowledge.py +0 -0
  26. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/knowledge_tags.py +0 -0
  27. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/llm.py +0 -0
  28. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/logging.py +0 -0
  29. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/model_input.py +0 -0
  30. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/orchestrator.py +0 -0
  31. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/proxy.py +0 -0
  32. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/py.typed +0 -0
  33. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/retriever.py +0 -0
  34. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/settings.py +0 -0
  35. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/signals.py +0 -0
  36. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/testing/__init__.py +0 -0
  37. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/testing/fake_knowledge_client.py +0 -0
  38. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/testing/fake_llm.py +0 -0
  39. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/testing/settings.py +0 -0
  40. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/testing/views.py +0 -0
  41. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/urls.py +0 -0
  42. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/spot_sdk/verdict.py +0 -0
  43. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0}/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.0.0b13
3
+ Version: 2.1.0
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.0b13
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
@@ -26,7 +26,7 @@ plugins share:
26
26
  ## Install
27
27
 
28
28
  ```bash
29
- pip install spot-sdk-python==2.0.0b13
29
+ pip install spot-sdk-python==2.0.0
30
30
  ```
31
31
 
32
32
  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.0.0b13"
7
+ version = "2.1.0"
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.0.0b13"
7
+ version = "2.1.0"
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 or a label over 252,"
30
- " invisible ones aside (DNS allows 253 and 63, and NFC composes at most"
31
- " 4 code points into one)",
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 or a label over 252, invisible ones"
87
- " aside (DNS allows 253 and 63, and NFC composes at most 4 code points"
88
- ' into one); "" when none'
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,164 @@
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 its bound."""
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: every class, from SPOT 2.1 (spot/spot#148). A plugin reaches the
57
+ hosts its settings of type url name, through SPOT's egress gateway, and
58
+ nothing else: its class says what it sends out, not where it runs."""
59
+ return True
60
+
61
+
62
+ def limits(resources: Resources) -> dict[str, Any]:
63
+ """The options that hold a plugin's container to its memory and CPU."""
64
+ return {
65
+ "mem_limit": f"{resources.memory_mb}m",
66
+ "nano_cpus": int(resources.cpus * 1_000_000_000),
67
+ }
68
+
69
+
70
+ def setting_text(value: Any) -> str:
71
+ """A setting as text, for an environment variable or a secret file."""
72
+ if isinstance(value, bool):
73
+ return "true" if value else "false"
74
+ return "" if value is None else str(value)
75
+
76
+
77
+ def environment(
78
+ settings: Mapping[str, Any],
79
+ *,
80
+ plugin_id: str,
81
+ kind: PluginKind,
82
+ version: str,
83
+ gateway_url: str,
84
+ knowledge_url: str,
85
+ proxy: Mapping[str, str] = MappingProxyType({}),
86
+ ) -> dict[str, str]:
87
+ """The environment of a plugin's container: its plain settings in upper
88
+ case, then ``proxy``, then SPOT's variables, each winning over the one
89
+ before. No credential is in it: the token and the proxy's user and
90
+ password are secret files."""
91
+ env = {name.upper(): setting_text(value) for name, value in settings.items()}
92
+ env.update(proxy)
93
+ env.update(
94
+ {
95
+ "SPOT_GATEWAY_URL": gateway_url,
96
+ "SPOT_KNOWLEDGE_URL": knowledge_url,
97
+ "SPOT_PLUGIN_ID": plugin_id,
98
+ "SPOT_PLUGIN_KIND": kind.value,
99
+ "SPOT_PLUGIN_VERSION": version,
100
+ }
101
+ )
102
+ return env
103
+
104
+
105
+ def secrets_archive(secrets: Mapping[str, str]) -> bytes:
106
+ """A tar of ``/run/secrets/<name>``, each readable by the plugin's user,
107
+ put in the container before it starts."""
108
+ root = SECRETS_DIR.relative_to("/")
109
+ buffer = io.BytesIO()
110
+ with tarfile.open(fileobj=buffer, mode="w") as tar:
111
+ directory = tarfile.TarInfo(str(root))
112
+ directory.type = tarfile.DIRTYPE
113
+ directory.mode = 0o755
114
+ tar.addfile(directory)
115
+ for name, value in sorted(secrets.items()):
116
+ data = value.encode()
117
+ info = tarfile.TarInfo(f"{root}/{name}")
118
+ info.size = len(data)
119
+ info.mode = 0o444
120
+ tar.addfile(info, io.BytesIO(data))
121
+ return buffer.getvalue()
122
+
123
+
124
+ async def read_answer(
125
+ response: httpx2.Response, max_bytes: int = MAX_ANSWER_BYTES
126
+ ) -> bytes:
127
+ """A streamed answer's body, as sent, read up to ``max_bytes``.
128
+
129
+ Raises:
130
+ AnswerRefusedError: the answer is encoded, or longer than ``max_bytes``
131
+ """
132
+ if (
133
+ response.headers.get("content-encoding", "identity").strip().lower()
134
+ != "identity"
135
+ ):
136
+ raise AnswerRefusedError("unsupported content encoding")
137
+ body = bytearray()
138
+ async for chunk in response.aiter_raw():
139
+ body += chunk
140
+ if len(body) > max_bytes:
141
+ raise AnswerRefusedError(f"response too large: over {max_bytes} bytes")
142
+ return bytes(body)
143
+
144
+
145
+ def parse_verdict(body: bytes) -> AnalyzerVerdict:
146
+ """The verdict an analyzer answered.
147
+
148
+ Raises:
149
+ InvalidResponseError: the body is not an ``AnalyzerVerdict``
150
+ """
151
+ try:
152
+ return AnalyzerVerdict.model_validate_json(body)
153
+ except ValidationError as e:
154
+ cause = validation_cause(e)
155
+ # Raised outside the handler: a validation error quotes its input.
156
+ raise InvalidResponseError(f"invalid response: {cause}")
157
+
158
+
159
+ def validation_cause(error: ValidationError) -> str:
160
+ """Where and why an answer does not validate, as core names it: the first
161
+ error's location and type (``score: less_than_equal``), never the value."""
162
+ first = error.errors()[0]
163
+ where = ".".join(str(part) for part in first["loc"])
164
+ 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)