spot-sdk-python 2.0.0b7__tar.gz → 2.0.0b9__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.0b7 → spot_sdk_python-2.0.0b9}/PKG-INFO +6 -5
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/README.md +2 -2
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/pyproject.toml +6 -5
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/__init__.py +1 -1
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/app.py +7 -7
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/clients.py +10 -16
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/knowledge.py +4 -4
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/llm.py +5 -5
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/manifest.py +16 -4
- spot_sdk_python-2.0.0b9/spot_sdk/model_input.py +190 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/retriever.py +13 -4
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/contract.py +3 -3
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/fake_spot.py +0 -2
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/_json.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/artifacts.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/config.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/email.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/errors.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/knowledge_tags.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/logging.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/mime.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/orchestrator.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/py.typed +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/settings.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/signals.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/README.md +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/__init__.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/fake_knowledge_client.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/fake_llm.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/settings.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/views.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/urls.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/verdict.py +0 -0
- {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/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.
|
|
3
|
+
Version: 2.0.0b9
|
|
4
4
|
Summary: SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Author: SPOT Project
|
|
@@ -13,12 +13,13 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.15
|
|
16
|
-
Requires-Dist: fastapi (>=0.
|
|
17
|
-
Requires-Dist:
|
|
16
|
+
Requires-Dist: fastapi (>=0.133.0,<1)
|
|
17
|
+
Requires-Dist: httpx2 (>=2.13.1,<3)
|
|
18
18
|
Requires-Dist: idna (>=3.11,<4)
|
|
19
19
|
Requires-Dist: pydantic (>=2.10,<3.0)
|
|
20
20
|
Requires-Dist: pydantic-settings (>=2.0.0,<3.0.0)
|
|
21
21
|
Requires-Dist: selectolax (>=1.0,<1.1)
|
|
22
|
+
Requires-Dist: starlette (>=1.7.0,<2)
|
|
22
23
|
Project-URL: Homepage, https://sonn.lu
|
|
23
24
|
Description-Content-Type: text/markdown
|
|
24
25
|
|
|
@@ -50,7 +51,7 @@ plugins share:
|
|
|
50
51
|
## Install
|
|
51
52
|
|
|
52
53
|
```bash
|
|
53
|
-
pip install spot-sdk-python==2.0.
|
|
54
|
+
pip install spot-sdk-python==2.0.0b9
|
|
54
55
|
```
|
|
55
56
|
|
|
56
57
|
Python 3.11 to 3.15. Version 2 implements plugin contract 2, for
|
|
@@ -75,7 +76,7 @@ from spot_sdk.signals import AUTH_DMARC_FAIL
|
|
|
75
76
|
MANIFEST = Manifest(
|
|
76
77
|
id="analyzer-dmarc",
|
|
77
78
|
version="1.0.0",
|
|
78
|
-
|
|
79
|
+
kind=PluginKind.ANALYZER,
|
|
79
80
|
egress=Egress.NONE,
|
|
80
81
|
)
|
|
81
82
|
|
|
@@ -26,7 +26,7 @@ plugins share:
|
|
|
26
26
|
## Install
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
pip install spot-sdk-python==2.0.
|
|
29
|
+
pip install spot-sdk-python==2.0.0b9
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
Python 3.11 to 3.15. Version 2 implements plugin contract 2, for
|
|
@@ -51,7 +51,7 @@ from spot_sdk.signals import AUTH_DMARC_FAIL
|
|
|
51
51
|
MANIFEST = Manifest(
|
|
52
52
|
id="analyzer-dmarc",
|
|
53
53
|
version="1.0.0",
|
|
54
|
-
|
|
54
|
+
kind=PluginKind.ANALYZER,
|
|
55
55
|
egress=Egress.NONE,
|
|
56
56
|
)
|
|
57
57
|
|
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "spot-sdk-python"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.0b9"
|
|
8
8
|
description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
|
|
9
9
|
authors = ["SPOT Project <spot@sonn.lu>"]
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -18,14 +18,15 @@ packages = [
|
|
|
18
18
|
python = ">=3.11,<3.16"
|
|
19
19
|
pydantic = "^2.10"
|
|
20
20
|
pydantic-settings = "^2.0.0"
|
|
21
|
-
|
|
21
|
+
httpx2 = ">=2.13.1,<3"
|
|
22
22
|
selectolax = ">=1.0,<1.1"
|
|
23
23
|
idna = ">=3.11,<4"
|
|
24
|
-
fastapi = ">=0.
|
|
24
|
+
fastapi = ">=0.133.0,<1"
|
|
25
|
+
starlette = ">=1.7.0,<2"
|
|
25
26
|
|
|
26
27
|
[tool.poetry.group.dev.dependencies]
|
|
27
|
-
pytest = "^
|
|
28
|
-
pytest-asyncio = "^
|
|
28
|
+
pytest = "^9.0.3"
|
|
29
|
+
pytest-asyncio = "^1.3.0"
|
|
29
30
|
pytest-cov = "^4.1.0"
|
|
30
31
|
mypy = "^1.5.0"
|
|
31
32
|
|
|
@@ -47,7 +47,7 @@ if TYPE_CHECKING:
|
|
|
47
47
|
from .knowledge import KnowledgeClient, KnowledgeDocument, chunk_text, content_hash
|
|
48
48
|
from .llm import LLMClient, LLMOutputError, LLMToolCall, LLMToolCalls, LLMToolRound
|
|
49
49
|
|
|
50
|
-
# The modules that import FastAPI, Starlette or
|
|
50
|
+
# The modules that import FastAPI, Starlette or httpx2 load on first use, so importing
|
|
51
51
|
# `spot_sdk.mime` or the models stays light (PEP 562).
|
|
52
52
|
_LAZY = {
|
|
53
53
|
**dict.fromkeys(("PluginHealth", "SyncState", "create_plugin_app"), ".app"),
|
|
@@ -215,9 +215,9 @@ def create_plugin_app(
|
|
|
215
215
|
) -> FastAPI:
|
|
216
216
|
"""Build the plugin's app; the settings are read once, here.
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
The manifest's kind needs its handler (analyzer: ``analyze``, responder:
|
|
219
|
+
``respond``, context provider: ``sync``; a mail retriever has none), and
|
|
220
|
+
each handler its kind, or ``ValueError`` is raised.
|
|
221
221
|
|
|
222
222
|
``lifespan`` is passed to FastAPI as is: an async context manager run
|
|
223
223
|
around the app's life, to load a model at startup, say.
|
|
@@ -248,7 +248,7 @@ def create_plugin_app(
|
|
|
248
248
|
components of that document alone, so read it from this app: another
|
|
249
249
|
FastAPI app that includes its routes leaves their ``$ref`` dangling.
|
|
250
250
|
"""
|
|
251
|
-
|
|
251
|
+
_check_kind(manifest, {"analyze": analyze, "respond": respond, "sync": sync})
|
|
252
252
|
settings = settings_cls()
|
|
253
253
|
if not settings.spot_plugin_token.get_secret_value():
|
|
254
254
|
logger.warning(
|
|
@@ -398,10 +398,10 @@ _HANDLER_KINDS = {
|
|
|
398
398
|
"""The kind each handler serves; a mail retriever has no handler."""
|
|
399
399
|
|
|
400
400
|
|
|
401
|
-
def
|
|
402
|
-
"""
|
|
401
|
+
def _check_kind(manifest: Manifest, handlers: dict[str, object]) -> None:
|
|
402
|
+
"""The manifest's kind has its handler, and each handler its kind."""
|
|
403
403
|
for name, kind in _HANDLER_KINDS.items():
|
|
404
|
-
declared = kind
|
|
404
|
+
declared = kind is manifest.kind
|
|
405
405
|
given = handlers[name] is not None
|
|
406
406
|
if declared and not given:
|
|
407
407
|
raise ValueError(
|
|
@@ -22,7 +22,7 @@ from typing import Any, TypeVar
|
|
|
22
22
|
from urllib.parse import quote
|
|
23
23
|
from uuid import UUID
|
|
24
24
|
|
|
25
|
-
import
|
|
25
|
+
import httpx2
|
|
26
26
|
from pydantic import BaseModel, SecretStr, ValidationError
|
|
27
27
|
|
|
28
28
|
from .email import Envelope
|
|
@@ -56,7 +56,7 @@ class SpotAPIError(Exception):
|
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
def read_answer(
|
|
59
|
-
model: type[ModelT], response:
|
|
59
|
+
model: type[ModelT], response: httpx2.Response, method: str, path: str
|
|
60
60
|
) -> ModelT:
|
|
61
61
|
"""SPOT's answer as ``model``. One that does not validate raises
|
|
62
62
|
``SpotAPIError`` after the handler, so no validation error, which quotes
|
|
@@ -70,8 +70,8 @@ def read_answer(
|
|
|
70
70
|
|
|
71
71
|
def json_body(value: Any) -> bytes:
|
|
72
72
|
"""``value`` as ASCII JSON, lone surrogates escaped, for a body sent with
|
|
73
|
-
``Content-Type: application/json``:
|
|
74
|
-
|
|
73
|
+
``Content-Type: application/json``: httpx2 encodes a ``json=`` body as UTF-8
|
|
74
|
+
and refuses lone surrogates, so the SDK encodes the body itself."""
|
|
75
75
|
return json.dumps(value).encode()
|
|
76
76
|
|
|
77
77
|
|
|
@@ -84,8 +84,8 @@ async def spot_request(
|
|
|
84
84
|
json: Any = None,
|
|
85
85
|
content: bytes | None = None,
|
|
86
86
|
timeout: float,
|
|
87
|
-
transport:
|
|
88
|
-
) ->
|
|
87
|
+
transport: httpx2.AsyncBaseTransport | None = None,
|
|
88
|
+
) -> httpx2.Response:
|
|
89
89
|
"""Call a SPOT service as a plugin; an error status raises ``SpotAPIError``.
|
|
90
90
|
|
|
91
91
|
The body is ``json`` encoded by :func:`json_body`, or ``content``, a JSON
|
|
@@ -98,7 +98,7 @@ async def spot_request(
|
|
|
98
98
|
content = json_body(json)
|
|
99
99
|
if content is not None:
|
|
100
100
|
headers["Content-Type"] = "application/json"
|
|
101
|
-
async with
|
|
101
|
+
async with httpx2.AsyncClient(timeout=timeout, transport=transport) as client:
|
|
102
102
|
response = await client.request(
|
|
103
103
|
method, base_url + path, content=content, headers=headers
|
|
104
104
|
)
|
|
@@ -111,7 +111,6 @@ def ingestion_request(
|
|
|
111
111
|
raw: bytes,
|
|
112
112
|
*,
|
|
113
113
|
envelope: Envelope | None = None,
|
|
114
|
-
workflow_id: str | None = None,
|
|
115
114
|
priority: IngestionPriority = "normal",
|
|
116
115
|
wait_for_verdict: bool = False,
|
|
117
116
|
timeout_ms: int = 30_000,
|
|
@@ -121,7 +120,6 @@ def ingestion_request(
|
|
|
121
120
|
return IngestionRequest(
|
|
122
121
|
raw=base64.b64encode(raw).decode("ascii"),
|
|
123
122
|
envelope=envelope,
|
|
124
|
-
workflow_id=workflow_id,
|
|
125
123
|
priority=priority,
|
|
126
124
|
wait_for_verdict=wait_for_verdict,
|
|
127
125
|
timeout_ms=timeout_ms,
|
|
@@ -132,7 +130,6 @@ def ingestion_body(
|
|
|
132
130
|
raw: bytes,
|
|
133
131
|
*,
|
|
134
132
|
envelope: Envelope | None = None,
|
|
135
|
-
workflow_id: str | None = None,
|
|
136
133
|
priority: IngestionPriority = "normal",
|
|
137
134
|
wait_for_verdict: bool = False,
|
|
138
135
|
timeout_ms: int = 30_000,
|
|
@@ -149,7 +146,6 @@ def ingestion_body(
|
|
|
149
146
|
request = IngestionRequest(
|
|
150
147
|
raw=(pieces[0] if pieces else b"").decode("ascii"),
|
|
151
148
|
envelope=envelope,
|
|
152
|
-
workflow_id=workflow_id,
|
|
153
149
|
priority=priority,
|
|
154
150
|
wait_for_verdict=wait_for_verdict,
|
|
155
151
|
timeout_ms=timeout_ms,
|
|
@@ -169,12 +165,12 @@ class SpotClient:
|
|
|
169
165
|
token: SecretStr,
|
|
170
166
|
*,
|
|
171
167
|
timeout: float = DEFAULT_TIMEOUT,
|
|
172
|
-
transport:
|
|
168
|
+
transport: httpx2.AsyncBaseTransport | None = None,
|
|
173
169
|
) -> None:
|
|
174
170
|
"""Build a client of the gateway at ``base_url``.
|
|
175
171
|
|
|
176
172
|
``timeout`` bounds each call, in seconds; ``transport`` replaces the
|
|
177
|
-
network, for tests (``
|
|
173
|
+
network, for tests (``httpx2.MockTransport``).
|
|
178
174
|
"""
|
|
179
175
|
self.base_url = base_url.rstrip("/")
|
|
180
176
|
self.token = token
|
|
@@ -191,7 +187,6 @@ class SpotClient:
|
|
|
191
187
|
raw: bytes,
|
|
192
188
|
*,
|
|
193
189
|
envelope: Envelope | None = None,
|
|
194
|
-
workflow_id: str | None = None,
|
|
195
190
|
priority: IngestionPriority = "normal",
|
|
196
191
|
wait_for_verdict: bool = False,
|
|
197
192
|
timeout_ms: int = 30_000,
|
|
@@ -211,7 +206,6 @@ class SpotClient:
|
|
|
211
206
|
ingestion_body,
|
|
212
207
|
raw,
|
|
213
208
|
envelope=envelope,
|
|
214
|
-
workflow_id=workflow_id,
|
|
215
209
|
priority=priority,
|
|
216
210
|
wait_for_verdict=wait_for_verdict,
|
|
217
211
|
timeout_ms=timeout_ms,
|
|
@@ -250,7 +244,7 @@ class SpotClient:
|
|
|
250
244
|
*,
|
|
251
245
|
content: bytes | None = None,
|
|
252
246
|
timeout: float | None = None,
|
|
253
|
-
) ->
|
|
247
|
+
) -> httpx2.Response:
|
|
254
248
|
return await spot_request(
|
|
255
249
|
self.base_url,
|
|
256
250
|
self.token,
|
|
@@ -36,7 +36,7 @@ from datetime import datetime
|
|
|
36
36
|
from typing import TYPE_CHECKING, Any
|
|
37
37
|
from urllib.parse import quote
|
|
38
38
|
|
|
39
|
-
import
|
|
39
|
+
import httpx2
|
|
40
40
|
from pydantic import BaseModel, Field, SecretStr
|
|
41
41
|
|
|
42
42
|
from .clients import read_answer, spot_request
|
|
@@ -187,7 +187,7 @@ class KnowledgeClient:
|
|
|
187
187
|
source: str = "",
|
|
188
188
|
timeout: float = DEFAULT_TIMEOUT,
|
|
189
189
|
retrieval_limits: RetrievalLimits | None = None,
|
|
190
|
-
transport:
|
|
190
|
+
transport: httpx2.AsyncBaseTransport | None = None,
|
|
191
191
|
) -> None:
|
|
192
192
|
"""Build a client of the store at ``url``.
|
|
193
193
|
|
|
@@ -201,7 +201,7 @@ class KnowledgeClient:
|
|
|
201
201
|
retrieval_limits: Caps applied to ``fetch()``; they only shrink
|
|
202
202
|
what the caller asks for.
|
|
203
203
|
transport: Replaces the network, for tests
|
|
204
|
-
(``
|
|
204
|
+
(``httpx2.MockTransport``).
|
|
205
205
|
"""
|
|
206
206
|
self.url = url.rstrip("/")
|
|
207
207
|
self.token = token
|
|
@@ -318,7 +318,7 @@ class KnowledgeClient:
|
|
|
318
318
|
|
|
319
319
|
async def _call(
|
|
320
320
|
self, method: str, path: str, *, json: Any = None
|
|
321
|
-
) ->
|
|
321
|
+
) -> httpx2.Response:
|
|
322
322
|
return await spot_request(
|
|
323
323
|
self.url,
|
|
324
324
|
self.token,
|
|
@@ -40,7 +40,7 @@ from datetime import UTC, datetime
|
|
|
40
40
|
from typing import Any, Self, TypeVar, overload
|
|
41
41
|
from uuid import UUID
|
|
42
42
|
|
|
43
|
-
import
|
|
43
|
+
import httpx2
|
|
44
44
|
from pydantic import (
|
|
45
45
|
BaseModel,
|
|
46
46
|
Field,
|
|
@@ -154,7 +154,7 @@ class LLMClient:
|
|
|
154
154
|
options: Mapping[str, Any] | None = None,
|
|
155
155
|
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
156
156
|
timeout: float = DEFAULT_TIMEOUT,
|
|
157
|
-
transport:
|
|
157
|
+
transport: httpx2.AsyncBaseTransport | None = None,
|
|
158
158
|
) -> None:
|
|
159
159
|
"""Build a client of the server at ``base_url``.
|
|
160
160
|
|
|
@@ -178,7 +178,7 @@ class LLMClient:
|
|
|
178
178
|
lowered value is used.
|
|
179
179
|
timeout: Request timeout in seconds.
|
|
180
180
|
transport: Replaces the network, for tests
|
|
181
|
-
(``
|
|
181
|
+
(``httpx2.MockTransport``).
|
|
182
182
|
"""
|
|
183
183
|
_check_budget(max_bytes)
|
|
184
184
|
self.base_url = base_url.rstrip("/")
|
|
@@ -249,7 +249,7 @@ class LLMClient:
|
|
|
249
249
|
results of earlier calls go back in ``tool_rounds``, oldest first,
|
|
250
250
|
with the same ``tools``. Raises ``LLMOutputError`` when the answer
|
|
251
251
|
is empty, does not parse, or calls tools that were not given, and
|
|
252
|
-
``
|
|
252
|
+
``httpx2.HTTPStatusError`` on an error status.
|
|
253
253
|
"""
|
|
254
254
|
body: dict[str, Any] = {
|
|
255
255
|
**self.options,
|
|
@@ -291,7 +291,7 @@ class LLMClient:
|
|
|
291
291
|
headers = {"Content-Type": "application/json"}
|
|
292
292
|
if self.api_key is not None:
|
|
293
293
|
headers["Authorization"] = f"Bearer {self.api_key.get_secret_value()}"
|
|
294
|
-
async with
|
|
294
|
+
async with httpx2.AsyncClient(
|
|
295
295
|
timeout=self.timeout, transport=self._transport
|
|
296
296
|
) as client:
|
|
297
297
|
response = await client.post(
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
``Manifest.labels()`` renders the labels, so a plugin's Dockerfile labels
|
|
4
4
|
come from the same object that its ``GET /manifest`` returns. The catalog
|
|
5
5
|
refuses an image with an unknown kind or a contract other than
|
|
6
|
-
``CONTRACT_VERSION``.
|
|
6
|
+
``CONTRACT_VERSION``. A plugin has one kind.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
@@ -33,7 +33,7 @@ LABEL_RESOURCES = "spot.plugin.resources"
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
class PluginKind(StrEnum):
|
|
36
|
-
"""What a plugin does;
|
|
36
|
+
"""What a plugin does; a plugin has exactly one kind."""
|
|
37
37
|
|
|
38
38
|
ANALYZER = "analyzer" # answers POST /internal/analyze with a verdict
|
|
39
39
|
CONTEXT_PROVIDER = "context_provider" # fills the Knowledge Store
|
|
@@ -230,7 +230,7 @@ class Manifest(BaseModel):
|
|
|
230
230
|
|
|
231
231
|
id: str
|
|
232
232
|
version: str
|
|
233
|
-
|
|
233
|
+
kind: PluginKind
|
|
234
234
|
contract: int = CONTRACT_VERSION
|
|
235
235
|
egress: Egress = Egress.CONTENT
|
|
236
236
|
capabilities: list[str] = Field(default_factory=list)
|
|
@@ -243,6 +243,18 @@ class Manifest(BaseModel):
|
|
|
243
243
|
requires_external_ingress: bool = False
|
|
244
244
|
resources: Resources | None = None
|
|
245
245
|
|
|
246
|
+
@model_validator(mode="before")
|
|
247
|
+
@classmethod
|
|
248
|
+
def _refuse_kinds(cls, data: Any) -> Any:
|
|
249
|
+
"""Refuse ``kinds`` rather than drop it as other unknown fields are: a
|
|
250
|
+
plugin built for several kinds must be split, not silently cut to one."""
|
|
251
|
+
if isinstance(data, dict) and "kinds" in data:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
"kinds is no longer taken: a plugin has one kind (kind); split a "
|
|
254
|
+
"plugin of several kinds into one plugin per kind"
|
|
255
|
+
)
|
|
256
|
+
return data
|
|
257
|
+
|
|
246
258
|
def labels(self) -> dict[str, str]:
|
|
247
259
|
"""The OCI labels that declare this manifest; unset optional labels are left out."""
|
|
248
260
|
settings = {
|
|
@@ -253,7 +265,7 @@ class Manifest(BaseModel):
|
|
|
253
265
|
labels = {
|
|
254
266
|
LABEL_ID: self.id,
|
|
255
267
|
LABEL_VERSION: self.version,
|
|
256
|
-
LABEL_KIND:
|
|
268
|
+
LABEL_KIND: self.kind.value,
|
|
257
269
|
LABEL_CONTRACT: str(self.contract),
|
|
258
270
|
LABEL_EGRESS: self.egress.value,
|
|
259
271
|
LABEL_CAPABILITIES: ",".join(self.capabilities),
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""The text a phishing model reads, and the manifest its files carry.
|
|
2
|
+
|
|
3
|
+
Training (spot-models) and inference (analyzer-nlp, analyzer-ml) call the same
|
|
4
|
+
``model_text``, so a model reads at inference the text it was trained on. A
|
|
5
|
+
released normalisation never changes: another one gets a new name, and a
|
|
6
|
+
model's ``spot-model.json`` names the one it was trained with.
|
|
7
|
+
|
|
8
|
+
``tests/data/model_text_golden.json`` freezes ``spot-text-1``: a change to any
|
|
9
|
+
of its outputs is a new normalisation name, never an edit of ``spot-text-1``.
|
|
10
|
+
That includes what ``spot_sdk.mime.visible`` and the interpreter's HTML
|
|
11
|
+
entities and Unicode tables give it.
|
|
12
|
+
|
|
13
|
+
Every pattern below runs in linear time: a pattern that can rescan a long run
|
|
14
|
+
of text from each of its starts is a denial of service on a 1 MiB body.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import html
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Final
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
|
23
|
+
|
|
24
|
+
from .mime import visible
|
|
25
|
+
|
|
26
|
+
TEXT_V1: Final = "spot-text-1"
|
|
27
|
+
TEXT_VERSIONS: Final = frozenset({TEXT_V1})
|
|
28
|
+
MANIFEST_FILE: Final = "spot-model.json"
|
|
29
|
+
|
|
30
|
+
# spot-text-1's models are trained on MeAJOR v2's ham, which its authors
|
|
31
|
+
# anonymised with markers. Its URL and address markers read as real URLs and
|
|
32
|
+
# addresses do; its other markers, and only these, are dropped. Bracketed text
|
|
33
|
+
# that is not one of them ([EXTERNAL], [PATCH]) stays, in training and in mail.
|
|
34
|
+
_WORDS: Final = {"URL": " URL ", "EMAIL": " EMAIL ", "EMAIL_ADDRESS": " EMAIL "}
|
|
35
|
+
_ANGLE_DROPPED: Final = (
|
|
36
|
+
"SYMBOL",
|
|
37
|
+
"SIMBOL",
|
|
38
|
+
"EMOJI",
|
|
39
|
+
"EMAIL_SEPARATOR",
|
|
40
|
+
"FILE",
|
|
41
|
+
"PHONE",
|
|
42
|
+
"PGP",
|
|
43
|
+
)
|
|
44
|
+
# The bracket markers MeAJOR v2 uses at least 100 times that name an entity type
|
|
45
|
+
_BRACKET_DROPPED: Final = (
|
|
46
|
+
"ORGANIZATION",
|
|
47
|
+
"PRODUCT",
|
|
48
|
+
"NAME",
|
|
49
|
+
"FINANCIAL_INFO",
|
|
50
|
+
"REFERENCE_NUMBER",
|
|
51
|
+
"DATE",
|
|
52
|
+
"ADDRESS",
|
|
53
|
+
"TIME",
|
|
54
|
+
"PHONE_NUMBER",
|
|
55
|
+
"FILE_NAME",
|
|
56
|
+
"USERNAME",
|
|
57
|
+
"INITIALS",
|
|
58
|
+
"FILE_PATH",
|
|
59
|
+
"IP_ADDRESS",
|
|
60
|
+
"NUMBER",
|
|
61
|
+
"VERSION",
|
|
62
|
+
"LOCATION",
|
|
63
|
+
"SYMBOL",
|
|
64
|
+
"VARIABLE",
|
|
65
|
+
"PASSWORD",
|
|
66
|
+
"FAX_NUMBER",
|
|
67
|
+
"IMAGE",
|
|
68
|
+
)
|
|
69
|
+
_ANGLE = re.compile(r"<\|(URL|EMAIL|" + "|".join(_ANGLE_DROPPED) + r")\|>")
|
|
70
|
+
_BRACKET = re.compile(r"\[(URL|EMAIL_ADDRESS|" + "|".join(_BRACKET_DROPPED) + r")\]")
|
|
71
|
+
# A tag is "<", an optional "/", a name (letters, digits, "_" and "-", with an
|
|
72
|
+
# optional "prefix:" as in Outlook's <o:p>) followed by a space, "/" or ">", then
|
|
73
|
+
# attributes without "<" or ">"; or "<!" or "<?" up to ">" (a comment, a doctype,
|
|
74
|
+
# an XML declaration). A ">" inside a quoted attribute or a comment ends the tag.
|
|
75
|
+
# So "1 < 2", "Bob <bob@corp.example>" and
|
|
76
|
+
# "<https://evil.example/x>" hold no tag. The possessive quantifiers keep each
|
|
77
|
+
# "<" from rescanning.
|
|
78
|
+
_TAG = re.compile(
|
|
79
|
+
r"</?[A-Za-z][A-Za-z0-9_-]*+(?::[A-Za-z][A-Za-z0-9_-]*+)?+(?=[\s/>])[^<>]*+>"
|
|
80
|
+
r"|<[!?][^<>]*+>"
|
|
81
|
+
)
|
|
82
|
+
# A script or a style: the view's visible_text leaves their contents out, so
|
|
83
|
+
# the text of a dataset does too. An unclosed one runs to the end.
|
|
84
|
+
_RAW_OPEN = re.compile(r"<(script|style)(?=[\s/>])", re.IGNORECASE)
|
|
85
|
+
_RAW_CLOSE = {
|
|
86
|
+
name: re.compile(rf"</{name}(?=[\s/>])[^<>]*+>?", re.IGNORECASE)
|
|
87
|
+
for name in ("script", "style")
|
|
88
|
+
}
|
|
89
|
+
_URL = re.compile(r"(?:https?://|www\.)[^\s<>]+", re.IGNORECASE)
|
|
90
|
+
# The lookbehind starts a match only at the start of a run of address
|
|
91
|
+
# characters, and the parts are bounded (RFC 5321: 64 for the local part, 63
|
|
92
|
+
# for a label), so each character is scanned a bounded number of times.
|
|
93
|
+
_EMAIL = re.compile(r"(?<![\w.+-])[\w.+-]{1,64}@[\w-]{1,63}(?:\.[\w-]{1,63}){1,8}")
|
|
94
|
+
_SPACE = re.compile(r"\s+")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _marker(match: re.Match[str]) -> str:
|
|
98
|
+
return _WORDS.get(match.group(1), " ")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _without_raw_text(text: str) -> str:
|
|
102
|
+
"""``text`` without its ``<script>`` and ``<style>`` elements, in one pass."""
|
|
103
|
+
parts: list[str] = []
|
|
104
|
+
position = 0
|
|
105
|
+
while opening := _RAW_OPEN.search(text, position):
|
|
106
|
+
parts.append(text[position : opening.start()])
|
|
107
|
+
closing = _RAW_CLOSE[opening.group(1).lower()].search(text, opening.end())
|
|
108
|
+
if closing is None:
|
|
109
|
+
return " ".join(parts)
|
|
110
|
+
position = closing.end()
|
|
111
|
+
parts.append(" ")
|
|
112
|
+
parts.append(text[position:])
|
|
113
|
+
return "".join(parts)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def model_text(subject: str, body: str, version: str = TEXT_V1) -> str:
|
|
117
|
+
"""The text a model of normalisation ``version`` reads from a subject and
|
|
118
|
+
a body: the email's visible text at inference, a dataset's text in training.
|
|
119
|
+
|
|
120
|
+
``spot-text-1``: HTML entities decoded; characters that render as nothing
|
|
121
|
+
removed; ``<script>`` and ``<style>`` elements dropped, as the view's
|
|
122
|
+
``visible_text`` leaves them out; MeAJOR's markers read as above (before
|
|
123
|
+
the tags, as <|URL|> looks like one); tags dropped (text that is not a
|
|
124
|
+
tag, such as ``1 < 2``, stays, and so do the angle brackets around an
|
|
125
|
+
address or a URL); each URL (up to the next space or angle bracket, so
|
|
126
|
+
trailing punctuation goes with it) and address replaced by the word URL or
|
|
127
|
+
EMAIL; whitespace collapsed. Case is kept: an uncased tokenizer lowers it.
|
|
128
|
+
Escaped markup (``<b>``) is decoded first, so it is dropped as a
|
|
129
|
+
tag.
|
|
130
|
+
"""
|
|
131
|
+
if version not in TEXT_VERSIONS:
|
|
132
|
+
raise ValueError(f"unknown model text normalisation: {version}")
|
|
133
|
+
text = _without_raw_text(visible(html.unescape(f"{subject} {body}")))
|
|
134
|
+
text = _ANGLE.sub(_marker, text)
|
|
135
|
+
text = _TAG.sub(" ", text)
|
|
136
|
+
text = _URL.sub(" URL ", text)
|
|
137
|
+
text = _EMAIL.sub(" EMAIL ", text)
|
|
138
|
+
text = _BRACKET.sub(_marker, text)
|
|
139
|
+
return _SPACE.sub(" ", text).strip()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ModelManifest(BaseModel):
|
|
143
|
+
"""``spot-model.json``: which model a directory holds, and the input it reads."""
|
|
144
|
+
|
|
145
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
146
|
+
|
|
147
|
+
name: str = Field(pattern=r"^[a-z0-9][a-z0-9-]*$")
|
|
148
|
+
version: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._+-]*$", max_length=64)
|
|
149
|
+
text: str = TEXT_V1
|
|
150
|
+
max_tokens: int | None = Field(default=None, ge=1, le=512)
|
|
151
|
+
|
|
152
|
+
@field_validator("text")
|
|
153
|
+
@classmethod
|
|
154
|
+
def _known_text(cls, text: str) -> str:
|
|
155
|
+
if text not in TEXT_VERSIONS:
|
|
156
|
+
raise ValueError(f"unknown model text normalisation: {text}")
|
|
157
|
+
return text
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def label(self) -> str:
|
|
161
|
+
"""The model's name and version, as a verdict's ``model_version`` gives them."""
|
|
162
|
+
return f"{self.name} {self.version}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def read_manifest(directory: Path) -> ModelManifest:
|
|
166
|
+
"""The manifest of a model directory; ``ValueError`` names the file and says
|
|
167
|
+
``missing``, ``unreadable`` or ``invalid`` with the failing fields."""
|
|
168
|
+
path = directory / MANIFEST_FILE
|
|
169
|
+
try:
|
|
170
|
+
raw = path.read_bytes()
|
|
171
|
+
except FileNotFoundError:
|
|
172
|
+
raise ValueError(f"{MANIFEST_FILE}: missing") from None
|
|
173
|
+
except OSError:
|
|
174
|
+
raise ValueError(f"{MANIFEST_FILE}: unreadable") from None
|
|
175
|
+
try:
|
|
176
|
+
return ModelManifest.model_validate_json(raw)
|
|
177
|
+
except ValidationError as error:
|
|
178
|
+
# The names of the failing fields, never their values
|
|
179
|
+
names = sorted(
|
|
180
|
+
{
|
|
181
|
+
repr(".".join(map(str, e["loc"]))) if e["loc"] else "the document"
|
|
182
|
+
for e in error.errors()
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
raise ValueError(f"{MANIFEST_FILE}: invalid ({', '.join(names)})") from None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def write_manifest(directory: Path, manifest: ModelManifest) -> None:
|
|
189
|
+
"""Write ``manifest`` as the directory's ``spot-model.json``."""
|
|
190
|
+
(directory / MANIFEST_FILE).write_text(manifest.model_dump_json(indent=2) + "\n")
|
|
@@ -21,7 +21,7 @@ from __future__ import annotations
|
|
|
21
21
|
import base64
|
|
22
22
|
import binascii
|
|
23
23
|
from enum import StrEnum
|
|
24
|
-
from typing import Annotated, Literal
|
|
24
|
+
from typing import Annotated, Any, Literal
|
|
25
25
|
from uuid import UUID
|
|
26
26
|
|
|
27
27
|
from pydantic import (
|
|
@@ -30,6 +30,7 @@ from pydantic import (
|
|
|
30
30
|
Field,
|
|
31
31
|
PlainSerializer,
|
|
32
32
|
WithJsonSchema,
|
|
33
|
+
model_validator,
|
|
33
34
|
)
|
|
34
35
|
|
|
35
36
|
from .email import Envelope
|
|
@@ -78,9 +79,6 @@ class IngestionRequest(BaseModel):
|
|
|
78
79
|
|
|
79
80
|
raw: Base64Message = Field(description="The RFC 5322 message")
|
|
80
81
|
envelope: Envelope | None = None
|
|
81
|
-
workflow_id: str | None = Field(
|
|
82
|
-
default=None, description="None: the platform's default workflow"
|
|
83
|
-
)
|
|
84
82
|
priority: IngestionPriority = "normal"
|
|
85
83
|
wait_for_verdict: bool = False
|
|
86
84
|
timeout_ms: int = Field(
|
|
@@ -90,6 +88,17 @@ class IngestionRequest(BaseModel):
|
|
|
90
88
|
description="The caller's budget when wait_for_verdict is set",
|
|
91
89
|
)
|
|
92
90
|
|
|
91
|
+
@model_validator(mode="before")
|
|
92
|
+
@classmethod
|
|
93
|
+
def _refuse_workflow_id(cls, data: Any) -> Any:
|
|
94
|
+
"""Refuse ``workflow_id`` rather than drop it as other unknown fields are:
|
|
95
|
+
a retriever that still names a workflow would otherwise be silently routed."""
|
|
96
|
+
if isinstance(data, dict) and "workflow_id" in data:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"workflow_id is no longer taken: SPOT's routes choose the workflow"
|
|
99
|
+
)
|
|
100
|
+
return data
|
|
101
|
+
|
|
93
102
|
|
|
94
103
|
class IngestionResult(BaseModel):
|
|
95
104
|
"""The response of ``POST /internal/ingest`` for a stored message."""
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
calls as the contract says.
|
|
3
3
|
|
|
4
4
|
FastAPI's ``TestClient`` is imported when a check runs, not with
|
|
5
|
-
``spot_sdk.testing
|
|
5
|
+
``spot_sdk.testing``, which stays free of the test client.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -22,7 +22,7 @@ from .fake_knowledge_client import FakeKnowledgeClient
|
|
|
22
22
|
from .views import view_from_eml
|
|
23
23
|
|
|
24
24
|
if TYPE_CHECKING:
|
|
25
|
-
import
|
|
25
|
+
import httpx2
|
|
26
26
|
from fastapi.testclient import TestClient
|
|
27
27
|
|
|
28
28
|
# The document the second sync of a provider must remove: a record gone from
|
|
@@ -184,7 +184,7 @@ def _sync(client: TestClient, token: str, timeout: float) -> SyncState:
|
|
|
184
184
|
return state
|
|
185
185
|
|
|
186
186
|
|
|
187
|
-
def _state(answer:
|
|
187
|
+
def _state(answer: httpx2.Response, status: int) -> SyncState:
|
|
188
188
|
"""The ``SyncState`` a route answers with ``status``."""
|
|
189
189
|
route = answer.request.url.path
|
|
190
190
|
if answer.status_code != status:
|
|
@@ -57,7 +57,6 @@ class FakeSpot:
|
|
|
57
57
|
raw: bytes,
|
|
58
58
|
*,
|
|
59
59
|
envelope: Envelope | None = None,
|
|
60
|
-
workflow_id: str | None = None,
|
|
61
60
|
priority: IngestionPriority = "normal",
|
|
62
61
|
wait_for_verdict: bool = False,
|
|
63
62
|
timeout_ms: int = 30_000,
|
|
@@ -65,7 +64,6 @@ class FakeSpot:
|
|
|
65
64
|
request = ingestion_request(
|
|
66
65
|
raw,
|
|
67
66
|
envelope=envelope,
|
|
68
|
-
workflow_id=workflow_id,
|
|
69
67
|
priority=priority,
|
|
70
68
|
wait_for_verdict=wait_for_verdict,
|
|
71
69
|
timeout_ms=timeout_ms,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/fake_knowledge_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|