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.
Files changed (34) hide show
  1. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/PKG-INFO +6 -5
  2. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/README.md +2 -2
  3. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/pyproject.toml +6 -5
  4. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/__init__.py +1 -1
  5. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/app.py +7 -7
  6. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/clients.py +10 -16
  7. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/knowledge.py +4 -4
  8. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/llm.py +5 -5
  9. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/manifest.py +16 -4
  10. spot_sdk_python-2.0.0b9/spot_sdk/model_input.py +190 -0
  11. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/retriever.py +13 -4
  12. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/contract.py +3 -3
  13. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/fake_spot.py +0 -2
  14. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/_json.py +0 -0
  15. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/artifacts.py +0 -0
  16. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/config.py +0 -0
  17. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/email.py +0 -0
  18. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/errors.py +0 -0
  19. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/knowledge_tags.py +0 -0
  20. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/logging.py +0 -0
  21. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/mime.py +0 -0
  22. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/orchestrator.py +0 -0
  23. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/py.typed +0 -0
  24. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/settings.py +0 -0
  25. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/signals.py +0 -0
  26. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/README.md +0 -0
  27. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/__init__.py +0 -0
  28. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/fake_knowledge_client.py +0 -0
  29. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/fake_llm.py +0 -0
  30. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/settings.py +0 -0
  31. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/testing/views.py +0 -0
  32. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/urls.py +0 -0
  33. {spot_sdk_python-2.0.0b7 → spot_sdk_python-2.0.0b9}/spot_sdk/verdict.py +0 -0
  34. {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.0b7
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.115.3,<1)
17
- Requires-Dist: httpx (>=0.25,<1)
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.0b7
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
- kinds=[PluginKind.ANALYZER],
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.0b7
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
- kinds=[PluginKind.ANALYZER],
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.0b7"
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
- httpx = ">=0.25,<1"
21
+ httpx2 = ">=2.13.1,<3"
22
22
  selectolax = ">=1.0,<1.1"
23
23
  idna = ">=3.11,<4"
24
- fastapi = ">=0.115.3,<1"
24
+ fastapi = ">=0.133.0,<1"
25
+ starlette = ">=1.7.0,<2"
25
26
 
26
27
  [tool.poetry.group.dev.dependencies]
27
- pytest = "^7.4.0"
28
- pytest-asyncio = "^0.21.0"
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 httpx load on first use, so importing
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
- Each kind the manifest declares needs its handler (analyzer: ``analyze``,
219
- responder: ``respond``, context provider: ``sync``; a mail retriever has
220
- none), and each handler its kind, or ``ValueError`` is raised.
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
- _check_kinds(manifest, {"analyze": analyze, "respond": respond, "sync": sync})
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 _check_kinds(manifest: Manifest, handlers: dict[str, object]) -> None:
402
- """Each kind the manifest declares has its handler, and each handler its kind."""
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 in manifest.kinds
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 httpx
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: httpx.Response, method: str, path: str
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``: the bytes do not depend on the httpx
74
- version (0.28 encodes a ``json=`` body as UTF-8 and refuses them)."""
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: httpx.AsyncBaseTransport | None = None,
88
- ) -> httpx.Response:
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 httpx.AsyncClient(timeout=timeout, transport=transport) as client:
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: httpx.AsyncBaseTransport | None = None,
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 (``httpx.MockTransport``).
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
- ) -> httpx.Response:
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 httpx
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: httpx.AsyncBaseTransport | None = None,
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
- (``httpx.MockTransport``).
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
- ) -> httpx.Response:
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 httpx
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: httpx.AsyncBaseTransport | None = None,
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
- (``httpx.MockTransport``).
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
- ``httpx.HTTPStatusError`` on an error status.
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 httpx.AsyncClient(
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; one plugin may be of several kinds."""
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
- kinds: list[PluginKind] = Field(min_length=1)
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: ",".join(self.kinds),
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 (``&lt;b&gt;``) 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``: Starlette 1.x warns when it is imported with httpx.
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 httpx
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: httpx.Response, status: int) -> SyncState:
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,