spot-sdk-python 2.0.0b3__tar.gz → 2.0.0b4__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 (30) hide show
  1. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/PKG-INFO +3 -2
  2. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/README.md +1 -1
  3. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/pyproject.toml +2 -1
  4. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/__init__.py +3 -2
  5. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/app.py +183 -17
  6. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/email.py +20 -3
  7. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/manifest.py +41 -4
  8. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/mime.py +198 -14
  9. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/views.py +3 -3
  10. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/_json.py +0 -0
  11. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/clients.py +0 -0
  12. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/config.py +0 -0
  13. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/errors.py +0 -0
  14. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/knowledge.py +0 -0
  15. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/knowledge_tags.py +0 -0
  16. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/llm.py +0 -0
  17. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/logging.py +0 -0
  18. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/orchestrator.py +0 -0
  19. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/py.typed +0 -0
  20. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/retriever.py +0 -0
  21. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/settings.py +0 -0
  22. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/signals.py +0 -0
  23. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/README.md +0 -0
  24. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/__init__.py +0 -0
  25. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/contract.py +0 -0
  26. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/fake_knowledge_client.py +0 -0
  27. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/fake_llm.py +0 -0
  28. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/fake_spot.py +0 -0
  29. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/verdict.py +0 -0
  30. {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/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.0b3
3
+ Version: 2.0.0b4
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
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.14
15
15
  Classifier: Programming Language :: Python :: 3.15
16
16
  Requires-Dist: fastapi (>=0.115.3,<1)
17
17
  Requires-Dist: httpx (>=0.25,<1)
18
+ Requires-Dist: idna (>=3.11,<4)
18
19
  Requires-Dist: pydantic (>=2.10,<3.0)
19
20
  Requires-Dist: pydantic-settings (>=2.0.0,<3.0.0)
20
21
  Requires-Dist: selectolax (>=1.0,<1.1)
@@ -49,7 +50,7 @@ plugins share:
49
50
  ## Install
50
51
 
51
52
  ```bash
52
- pip install spot-sdk-python==2.0.0b3
53
+ pip install spot-sdk-python==2.0.0b4
53
54
  ```
54
55
 
55
56
  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.0b3
29
+ pip install spot-sdk-python==2.0.0b4
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 = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "spot-sdk-python"
7
- version = "2.0.0b3"
7
+ version = "2.0.0b4"
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"
@@ -20,6 +20,7 @@ pydantic = "^2.10"
20
20
  pydantic-settings = "^2.0.0"
21
21
  httpx = ">=0.25,<1"
22
22
  selectolax = ">=1.0,<1.1"
23
+ idna = ">=3.11,<4"
23
24
  fastapi = ">=0.115.3,<1"
24
25
 
25
26
  [tool.poetry.group.dev.dependencies]
@@ -42,7 +42,7 @@ from .workflow import (
42
42
  )
43
43
 
44
44
  if TYPE_CHECKING:
45
- from .app import SyncState, create_plugin_app
45
+ from .app import PluginHealth, SyncState, create_plugin_app
46
46
  from .clients import SpotAPIError, SpotClient
47
47
  from .knowledge import KnowledgeClient, KnowledgeDocument, chunk_text, content_hash
48
48
  from .llm import LLMClient, LLMOutputError, LLMToolCall, LLMToolCalls, LLMToolRound
@@ -50,7 +50,7 @@ if TYPE_CHECKING:
50
50
  # The modules that import FastAPI, Starlette or httpx load on first use, so importing
51
51
  # `spot_sdk.mime` or the models stays light (PEP 562).
52
52
  _LAZY = {
53
- **dict.fromkeys(("SyncState", "create_plugin_app"), ".app"),
53
+ **dict.fromkeys(("PluginHealth", "SyncState", "create_plugin_app"), ".app"),
54
54
  **dict.fromkeys(("SpotAPIError", "SpotClient"), ".clients"),
55
55
  **dict.fromkeys(
56
56
  ("KnowledgeClient", "KnowledgeDocument", "chunk_text", "content_hash"),
@@ -122,6 +122,7 @@ __all__ = [
122
122
  "SettingField",
123
123
  "SettingType",
124
124
  # Plugin runtime
125
+ "PluginHealth",
125
126
  "PluginSettings",
126
127
  "SyncState",
127
128
  "create_plugin_app",
@@ -3,7 +3,8 @@
3
3
  ``create_plugin_app`` builds it from the plugin's manifest, its settings
4
4
  class and its handlers:
5
5
 
6
- - ``GET /health`` and ``GET /manifest`` are open;
6
+ - ``GET /health`` and ``GET /manifest`` are open. ``/health`` answers 200
7
+ with the plugin's state, from its ``health`` check when it has one;
7
8
  - ``POST /internal/analyze``, ``POST /internal/respond``, and
8
9
  ``POST /internal/sync`` with ``GET /internal/state``, exist for the
9
10
  handlers given. Each checks ``Authorization: Bearer <the plugin's own
@@ -17,6 +18,10 @@ block. Either way the clients send the call's ``X-Correlation-ID`` on to SPOT.
17
18
  Neither the logs nor the error answers hold the request's content: a
18
19
  handler's exception is logged by its type and place only, and an invalid
19
20
  request is answered with the fields refused, never the values sent.
21
+
22
+ The app's OpenAPI document (``app.openapi()``; the app serves none) shows the
23
+ contract as it runs: each route's request body, its status codes, the error
24
+ answers the app gives itself, and the Bearer token on ``/internal/*``.
20
25
  """
21
26
 
22
27
  from __future__ import annotations
@@ -28,11 +33,13 @@ import traceback
28
33
  from collections.abc import Awaitable, Callable, Iterator
29
34
  from contextlib import contextmanager
30
35
  from datetime import UTC, datetime
31
- from typing import Any, TypeAlias, TypeVar
36
+ from typing import Any, Literal, TypeAlias, TypeVar
32
37
 
33
38
  from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response
34
39
  from fastapi.responses import JSONResponse
35
- from pydantic import BaseModel, Field, SecretStr, ValidationError
40
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
41
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
42
+ from pydantic.json_schema import models_json_schema
36
43
  from pydantic_core import PydanticSerializationError
37
44
  from starlette.concurrency import run_in_threadpool
38
45
  from starlette.types import Lifespan
@@ -56,7 +63,15 @@ AnalyzeHandler: TypeAlias = Callable[
56
63
  RespondHandler: TypeAlias = Callable[
57
64
  [RespondRequest, SettingsT], None | Awaitable[None]
58
65
  ]
59
- """``respond(request, settings) -> None``; it acts only when ``request.mode`` is enforce."""
66
+ """``respond(request, settings) -> None``; it acts only when ``request.mode`` is enforce.
67
+
68
+ Core calls it at least once per job, not exactly once: it retries a call that
69
+ timed out, which may still complete, since the handler runs inside the
70
+ request; and when it restarts between its calls and its acknowledgement of the
71
+ job's result, it calls every responder of the job again. So a responder acts
72
+ once per ``request.job_id``: it keeps the job ids it has acted on, or makes its
73
+ action idempotent (a ticket keyed by the job id).
74
+ """
60
75
  SyncHandler: TypeAlias = Callable[[SettingsT], int | Awaitable[int]]
61
76
  """``sync(settings) -> int``: the number of documents written."""
62
77
 
@@ -80,6 +95,65 @@ class SyncState(BaseModel):
80
95
  )
81
96
 
82
97
 
98
+ class PluginHealth(BaseModel):
99
+ """The plugin's state, as its ``health`` check returns it and ``GET /health``
100
+ reports it.
101
+
102
+ ``/health`` is open to anyone who reaches the plugin: the reason says why
103
+ in a few words of the plugin's own ("loading the model"), and never holds
104
+ an email's content, a setting or an exception's message. Core's logs and
105
+ the dashboard show it, so it holds no control character. An instance is
106
+ validated again wherever it is read, even one built without validation.
107
+ """
108
+
109
+ model_config = ConfigDict(frozen=True, revalidate_instances="always")
110
+
111
+ status: Literal["healthy", "starting", "unhealthy"]
112
+ reason: str | None = Field(
113
+ default=None,
114
+ max_length=200,
115
+ pattern=r"^[^\x00-\x1f\x7f]*$",
116
+ description="Why, in a few words; no content, no control character",
117
+ )
118
+
119
+
120
+ class ErrorAnswer(BaseModel):
121
+ """An error the app answers itself: 401 without the plugin's own token, 500
122
+ when the handler fails. It never holds the request's content."""
123
+
124
+ detail: str = Field(
125
+ description='"unauthorized", "analysis failed", "response failed"'
126
+ )
127
+
128
+
129
+ class RefusedField(BaseModel):
130
+ """A field of a request body that does not validate: never the value sent."""
131
+
132
+ type: str
133
+ loc: list[str | int]
134
+ msg: str
135
+
136
+
137
+ class InvalidRequestAnswer(BaseModel):
138
+ """The 422 for a request body that does not validate: the fields refused."""
139
+
140
+ detail: list[RefusedField]
141
+
142
+
143
+ HealthCheck: TypeAlias = Callable[[], PluginHealth | Awaitable[PluginHealth]]
144
+ """``health() -> PluginHealth``, called on each ``GET /health``."""
145
+ _HEALTHY = PluginHealth(status="healthy")
146
+ # The errors a route that reads its body answers itself, by status.
147
+ _BODY_ERRORS: dict[int | str, dict[str, Any]] = {
148
+ 422: {"model": InvalidRequestAnswer, "description": "The body does not validate"},
149
+ 500: {"model": ErrorAnswer, "description": "The handler failed"},
150
+ }
151
+ _SCHEMAS = "#/components/schemas/{model}"
152
+ _HEALTH_CHECK_FAILED = PluginHealth(
153
+ status="unhealthy", reason="the health check failed"
154
+ )
155
+
156
+
83
157
  def create_plugin_app(
84
158
  manifest: Manifest,
85
159
  settings_cls: type[SettingsT],
@@ -88,6 +162,7 @@ def create_plugin_app(
88
162
  respond: RespondHandler[SettingsT] | None = None,
89
163
  sync: SyncHandler[SettingsT] | None = None,
90
164
  lifespan: Lifespan[FastAPI] | None = None,
165
+ health: HealthCheck | None = None,
91
166
  ) -> FastAPI:
92
167
  """Build the plugin's app; the settings are read once, here.
93
168
 
@@ -98,9 +173,21 @@ def create_plugin_app(
98
173
  ``lifespan`` is passed to FastAPI as is: an async context manager run
99
174
  around the app's life, to load a model at startup, say.
100
175
 
176
+ ``health``, called on each ``GET /health``, returns the plugin's state:
177
+ ``starting`` while the model loads, say, or ``unhealthy`` when it did not.
178
+ Without it, the plugin is ``healthy``. ``/health`` always answers 200, so
179
+ that liveness checks pass, with ``{"status", "version"}`` and the
180
+ ``reason`` when there is one. A check that raises, or returns no valid
181
+ ``PluginHealth``, makes the plugin ``unhealthy`` for a fixed reason, and
182
+ is logged as the handlers are: never with the exception's message.
183
+
101
184
  ``POST /internal/sync`` answers 202 at once and runs ``sync`` in the
102
185
  background. While a sync runs, another call starts nothing and answers
103
186
  202 with the running sync's state.
187
+
188
+ The app's ``openapi()`` describes the contract. The request bodies are
189
+ components of that document alone, so read it from this app: another
190
+ FastAPI app that includes its routes leaves their ``$ref`` dangling.
104
191
  """
105
192
  _check_kinds(manifest, {"analyze": analyze, "respond": respond, "sync": sync})
106
193
  settings = settings_cls()
@@ -110,7 +197,7 @@ def create_plugin_app(
110
197
  "mounts it as /run/secrets/spot_plugin_token; outside a container, set "
111
198
  "SPOT_PLUGIN_TOKEN."
112
199
  )
113
- app = FastAPI(
200
+ app = _PluginApp(
114
201
  title=manifest.id,
115
202
  version=manifest.version,
116
203
  openapi_url=None,
@@ -118,8 +205,12 @@ def create_plugin_app(
118
205
  )
119
206
 
120
207
  @app.get("/health")
121
- async def health() -> dict[str, str]:
122
- return {"status": "healthy", "version": manifest.version}
208
+ async def health_route() -> dict[str, str]:
209
+ state = await _health(health)
210
+ answer = {"status": state.status, "version": manifest.version}
211
+ if state.reason is not None:
212
+ answer["reason"] = state.reason
213
+ return answer
123
214
 
124
215
  @app.get("/manifest")
125
216
  async def get_manifest() -> Manifest:
@@ -128,11 +219,21 @@ def create_plugin_app(
128
219
  internal = APIRouter(
129
220
  prefix="/internal",
130
221
  dependencies=[Depends(_token_check(settings.spot_plugin_token))],
222
+ responses={
223
+ 401: {"model": ErrorAnswer, "description": "Without the plugin's own token"}
224
+ },
131
225
  )
132
226
 
133
227
  if analyze is not None:
134
228
 
135
- @internal.post("/analyze")
229
+ @internal.post(
230
+ "/analyze",
231
+ openapi_extra=app.reads(AnalyzeRequest),
232
+ responses={
233
+ 200: {"model": AnalyzerVerdict, "description": "The verdict"},
234
+ **_BODY_ERRORS,
235
+ },
236
+ )
136
237
  async def analyze_route(request: Request) -> Response:
137
238
  call = await _parse(request, AnalyzeRequest)
138
239
  with _correlated(request):
@@ -155,7 +256,13 @@ def create_plugin_app(
155
256
 
156
257
  if respond is not None:
157
258
 
158
- @internal.post("/respond")
259
+ @internal.post(
260
+ "/respond",
261
+ status_code=204,
262
+ response_class=Response,
263
+ openapi_extra=app.reads(RespondRequest),
264
+ responses=_BODY_ERRORS,
265
+ )
159
266
  async def respond_route(request: Request) -> Response:
160
267
  call = await _parse(request, RespondRequest)
161
268
  with _correlated(request):
@@ -182,6 +289,40 @@ def create_plugin_app(
182
289
  return app
183
290
 
184
291
 
292
+ class _PluginApp(FastAPI):
293
+ """The plugin's app. Its OpenAPI document also holds the schemas of the
294
+ request bodies its routes read themselves, which FastAPI leaves out."""
295
+
296
+ def __init__(self, **options: Any) -> None:
297
+ super().__init__(**options)
298
+ self._read_bodies: list[type[BaseModel]] = []
299
+
300
+ def reads(self, model: type[BaseModel]) -> dict[str, Any]:
301
+ """The ``openapi_extra`` of a route that reads ``model`` from its body
302
+ itself, validated (``_parse``)."""
303
+ self._read_bodies.append(model)
304
+ schema = {"$ref": _SCHEMAS.format(model=model.__name__)}
305
+ return {
306
+ "requestBody": {
307
+ "required": True,
308
+ "content": {"application/json": {"schema": schema}},
309
+ }
310
+ }
311
+
312
+ def openapi(self) -> dict[str, Any]:
313
+ if self.openapi_schema is not None:
314
+ return self.openapi_schema
315
+ document = super().openapi() # kept as self.openapi_schema
316
+ _, definitions = models_json_schema(
317
+ [(model, "validation") for model in self._read_bodies],
318
+ ref_template=_SCHEMAS,
319
+ )
320
+ schemas = document.setdefault("components", {}).setdefault("schemas", {})
321
+ for name, schema in definitions.get("$defs", {}).items():
322
+ schemas.setdefault(name, schema)
323
+ return document
324
+
325
+
185
326
  _HANDLER_KINDS = {
186
327
  "analyze": PluginKind.ANALYZER,
187
328
  "respond": PluginKind.RESPONDER,
@@ -239,11 +380,37 @@ class _SyncRunner:
239
380
  self.state.in_progress = False
240
381
 
241
382
 
242
- def _token_check(token: SecretStr) -> Callable[[Request], Awaitable[None]]:
383
+ async def _health(check: HealthCheck | None) -> PluginHealth:
384
+ """The plugin's state: its check's answer, healthy without one, unhealthy
385
+ when the check fails."""
386
+ if check is None:
387
+ return _HEALTHY
388
+ try:
389
+ answer = await _run(check)
390
+ except Exception as error:
391
+ _log_failure("health check", error)
392
+ return _HEALTH_CHECK_FAILED
393
+ try:
394
+ return PluginHealth.model_validate(answer)
395
+ except ValidationError as error:
396
+ _log_invalid_answer("health check", error)
397
+ return _HEALTH_CHECK_FAILED
398
+
399
+
400
+ # The plugin's own token, as Authorization: Bearer <token> carries it; the
401
+ # check below answers 401 itself.
402
+ _BEARER = HTTPBearer(auto_error=False, description="The plugin's own token")
403
+
404
+
405
+ def _token_check(
406
+ token: SecretStr,
407
+ ) -> Callable[[HTTPAuthorizationCredentials | None], Awaitable[None]]:
243
408
  expected = token.get_secret_value().encode()
244
409
 
245
- async def check_token(request: Request) -> None:
246
- if not _is_bearer(request.headers.get("authorization"), expected):
410
+ async def check_token(
411
+ credentials: HTTPAuthorizationCredentials | None = Depends(_BEARER),
412
+ ) -> None:
413
+ if not _is_token(credentials, expected):
247
414
  raise HTTPException(
248
415
  status_code=401,
249
416
  detail="unauthorized",
@@ -253,12 +420,11 @@ def _token_check(token: SecretStr) -> Callable[[Request], Awaitable[None]]:
253
420
  return check_token
254
421
 
255
422
 
256
- def _is_bearer(header: str | None, token: bytes) -> bool:
257
- """Whether ``header`` is ``Bearer <token>``; an empty token matches nothing."""
258
- if not token or header is None:
423
+ def _is_token(credentials: HTTPAuthorizationCredentials | None, token: bytes) -> bool:
424
+ """Whether the Bearer credentials are ``token``; an empty token matches nothing."""
425
+ if not token or credentials is None:
259
426
  return False
260
- scheme, _, presented = header.partition(" ")
261
- return scheme.lower() == "bearer" and hmac.compare_digest(presented.encode(), token)
427
+ return hmac.compare_digest(credentials.credentials.encode(), token)
262
428
 
263
429
 
264
430
  async def _parse(request: Request, model: type[ModelT]) -> ModelT:
@@ -22,7 +22,14 @@ class Address(BaseModel):
22
22
 
23
23
  display_name: str = ""
24
24
  address: str = Field(description="As written; may be invalid, never rejected")
25
- domain: str = Field(default="", description="Lower-cased, IDNA-decoded")
25
+ domain: str = Field(
26
+ default="",
27
+ description="As browsers read a host (WHATWG URL Standard): UTS 46-mapped"
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)",
32
+ )
26
33
 
27
34
 
28
35
  class Header(BaseModel):
@@ -71,7 +78,15 @@ class Url(BaseModel):
71
78
  """A URL found in the message."""
72
79
 
73
80
  url: str = Field(description="As found")
74
- host: str = Field(description='Lower-cased, IDNA-decoded ("" when unparsable)')
81
+ host: str = Field(
82
+ description="The host a browser would reach, as the WHATWG URL Standard"
83
+ " reads it: percent-decoded, UTS 46-mapped (lower-cased...), A-labels"
84
+ " decoded when UTS 46 leaves them as they are, no trailing dots; one"
85
+ " 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'
89
+ )
75
90
  found_in: Literal["text", "html_href", "html_text", "header"] = Field(
76
91
  description="html_text: written in the HTML's visible text, not in an attribute"
77
92
  )
@@ -142,7 +157,9 @@ class EmailView(BaseModel):
142
157
  default=None, description="First text/html body part, decoded"
143
158
  )
144
159
  visible_text: str = Field(
145
- default="", description="text, else the HTML's visible text"
160
+ default="",
161
+ description="text, else the HTML's visible text; without the characters"
162
+ " that render as nothing (spot_sdk.mime.visible)",
146
163
  )
147
164
  urls: list[Url] = Field(default_factory=list)
148
165
  truncated: bool = Field(
@@ -10,9 +10,9 @@ from __future__ import annotations
10
10
 
11
11
  import json
12
12
  from enum import StrEnum
13
- from typing import Any
13
+ from typing import Any, Self
14
14
 
15
- from pydantic import BaseModel, Field
15
+ from pydantic import BaseModel, Field, model_validator
16
16
 
17
17
  CONTRACT_VERSION = 2
18
18
 
@@ -58,8 +58,27 @@ class SettingType(StrEnum):
58
58
  SECRET = "secret"
59
59
 
60
60
 
61
+ # The values each setting type takes; a bool, which Python counts as an int,
62
+ # is a boolean's only.
63
+ _SETTING_VALUES: dict[SettingType, tuple[type, ...]] = {
64
+ SettingType.STRING: (str,),
65
+ SettingType.INTEGER: (int,),
66
+ SettingType.NUMBER: (int, float),
67
+ SettingType.BOOLEAN: (bool,),
68
+ SettingType.URL: (str,),
69
+ SettingType.SECRET: (str,),
70
+ }
71
+
72
+
61
73
  class SettingField(BaseModel):
62
- """One setting of a plugin, as ``spot.plugin.settings`` declares it."""
74
+ """One setting of a plugin, as ``spot.plugin.settings`` declares it.
75
+
76
+ An ``enum`` that no value can satisfy is refused, so that the plugin
77
+ fails when its manifest is built rather than when an admin configures it:
78
+ an empty one, or one whose values do not fit ``type`` (``integer``: int,
79
+ never bool; ``number``: int or float, never bool; ``boolean``: bool;
80
+ ``string``, ``url`` and ``secret``: str).
81
+ """
63
82
 
64
83
  type: SettingType
65
84
  required: bool = False
@@ -67,9 +86,27 @@ class SettingField(BaseModel):
67
86
  description: str = ""
68
87
  secret: bool = False
69
88
  enum: list[Any] | None = Field(
70
- default=None, description="Allowed values, on any type"
89
+ default=None, description="Allowed values, each of the setting's type"
71
90
  )
72
91
 
92
+ @model_validator(mode="after")
93
+ def _check_enum(self) -> Self:
94
+ if self.enum is None:
95
+ return self
96
+ if not self.enum:
97
+ raise ValueError("the enum is empty: no value can satisfy it")
98
+ for index, value in enumerate(self.enum):
99
+ if not _fits(value, self.type):
100
+ raise ValueError(f"enum[{index}] does not fit the type {self.type}")
101
+ return self
102
+
103
+
104
+ def _fits(value: Any, setting_type: SettingType) -> bool:
105
+ """Whether ``value`` is a value of ``setting_type``."""
106
+ if isinstance(value, bool):
107
+ return setting_type is SettingType.BOOLEAN
108
+ return isinstance(value, _SETTING_VALUES[setting_type])
109
+
73
110
 
74
111
  class Resources(BaseModel):
75
112
  """Memory and CPU a plugin container may use."""
@@ -42,6 +42,7 @@ import hashlib
42
42
  import json
43
43
  import math
44
44
  import re
45
+ import unicodedata
45
46
  from array import array
46
47
  from collections import Counter, defaultdict
47
48
  from collections.abc import Collection, Iterable, Iterator
@@ -53,9 +54,11 @@ from email.utils import decode_params, getaddresses, parsedate_to_datetime, unqu
53
54
  from html import unescape as unescape_html
54
55
  from itertools import chain
55
56
  from typing import Any, Literal, NamedTuple, TypeAlias, TypeVar, cast
57
+ from urllib.parse import unquote as unquote_url
56
58
  from urllib.parse import urlsplit
57
59
  from uuid import UUID
58
60
 
61
+ import idna
59
62
  from pydantic import BaseModel
60
63
  from pydantic_core import to_json
61
64
  from selectolax.lexbor import LexborHTMLParser, LexborNode
@@ -193,6 +196,10 @@ URL_LIMIT = "url_limit"
193
196
  """The message has more than MAX_URLS URLs, or they take more than
194
197
  MAX_URLS_BYTES: the later ones are not recorded. Or a URL was cut at 8 KiB of
195
198
  JSON, or its host or anchor text at 1 KiB."""
199
+ INVISIBLE_CHARACTERS = "invisible_characters"
200
+ """Characters that render as nothing (see :func:`visible`) were removed from
201
+ visible_text, or from a text part they left blank: noted at that part, with how
202
+ many as the detail."""
196
203
  ANOMALY_LIMIT = "anomaly_limit"
197
204
  """More than MAX_ANOMALIES anomalies were met: the later ones are not recorded."""
198
205
  ANOMALY_CODES = (
@@ -213,6 +220,7 @@ ANOMALY_CODES = (
213
220
  HTML_LIMIT,
214
221
  HTML_STYLE_UNRESOLVED,
215
222
  URL_LIMIT,
223
+ INVISIBLE_CHARACTERS,
216
224
  ANOMALY_LIMIT,
217
225
  )
218
226
  """Every anomaly code, in the order above."""
@@ -458,6 +466,49 @@ _BLOCK_TAGS = frozenset(
458
466
  )
459
467
  # A srcset candidate's URL: up to whitespace.
460
468
  _SRCSET_URL = re.compile(r"[\t\n\f\r ,]*+([^\t\n\f\r ]++)")
469
+ # The characters that render as nothing: Default_Ignorable_Code_Point, as
470
+ # Unicode 16.0.0 lists it in DerivedCoreProperties.txt
471
+ # (https://www.unicode.org/Public/16.0.0/ucd/DerivedCoreProperties.txt), 4 174
472
+ # code points, adjacent ranges merged. Zero-width space and joiners, the soft
473
+ # hyphen, bidi controls, fillers, variation selectors, tags, and code points
474
+ # reserved for more.
475
+ _DEFAULT_IGNORABLE = (
476
+ (0x00AD, 0x00AD),
477
+ (0x034F, 0x034F),
478
+ (0x061C, 0x061C),
479
+ (0x115F, 0x1160),
480
+ (0x17B4, 0x17B5),
481
+ (0x180B, 0x180F),
482
+ (0x200B, 0x200F),
483
+ (0x202A, 0x202E),
484
+ (0x2060, 0x206F),
485
+ (0x3164, 0x3164),
486
+ (0xFE00, 0xFE0F),
487
+ (0xFEFF, 0xFEFF),
488
+ (0xFFA0, 0xFFA0),
489
+ (0xFFF0, 0xFFF8),
490
+ (0x1BCA0, 0x1BCA3),
491
+ (0x1D173, 0x1D17A),
492
+ (0xE0000, 0xE0FFF),
493
+ )
494
+ _INVISIBLE = re.compile(
495
+ "["
496
+ + "".join(f"\\U{first:08x}-\\U{last:08x}" for first, last in _DEFAULT_IGNORABLE)
497
+ + "]+"
498
+ )
499
+ # The label separators: the full stop, and those UTS 46 maps to it.
500
+ _DOT_CHARACTERS = ".\u3002\uff0e\uff61"
501
+ _DOTS = re.compile(f"[{_DOT_CHARACTERS}]")
502
+ # A DNS name holds 253 characters at most, without its trailing dot, and a
503
+ # label 63 (RFC 1035). A host maps to no DNS name when it keeps more than
504
+ # four times as many characters once the invisible ones are removed: UTS 46
505
+ # removes no other, and NFC composes at most 4 code points into one (U+1F8F).
506
+ _MAX_DOMAIN = 4 * 253
507
+ _MAX_LABEL = 4 * 63
508
+ # An A-label is the DNS label itself.
509
+ _MAX_A_LABEL = 63
510
+ # The most characters idna maps a call (from 3.17).
511
+ _IDNA_INPUT = 1024
461
512
 
462
513
 
463
514
  # A parameter as email.utils decodes it: a value, or an RFC 2231
@@ -502,6 +553,12 @@ def parse(
502
553
  most that many bytes of their part, and their URLs and the visible text
503
554
  from all of what was read, not from the cut the view stores.
504
555
 
556
+ The visible text is the text, or the HTML's visible text when the text is
557
+ blank, without the characters that render as nothing (:func:`visible`): a
558
+ text of them alone is blank. Removing any is the anomaly
559
+ ``invisible_characters``, at the part they were removed from, the visible
560
+ text's or a blank text's; the other strings keep them, as written.
561
+
505
562
  The view's JSON (``model_dump_json(by_alias=True)``, in UTF-8) takes at
506
563
  most ``MAX_VIEW_BYTES``, about 6.84 MiB, whatever the message, with
507
564
  ``max_text_bytes`` at most its default. Each list and each long string has
@@ -568,17 +625,21 @@ def parse(
568
625
  html = bodies.get("text/html")
569
626
  cut_urls = _Cutter()
570
627
  found = [_header_urls(fields, cut_urls)] # the URLs, built as they are kept
628
+ shown: _Body | None = None # the body the visible text is read from
629
+ visible_text = ""
571
630
  if text:
572
631
  found.append(_urls_in(text.content, "text", text.part_id, cut_urls))
573
- shown = text if text and text.content.strip() else None # the visible text's
574
- visible_text = shown.content if shown else ""
632
+ seen = _visible_body(text, text.content)
633
+ if seen.strip():
634
+ shown, visible_text = text, seen
575
635
  if html:
576
636
  reader = _read_html(html.content)
577
637
  found.append(_html_urls(reader.links, html.part_id, cut_urls))
578
638
  html_text = reader.text
579
639
  found.append(_urls_in(html_text, "html_text", html.part_id, cut_urls))
580
640
  if shown is None:
581
- shown, visible_text = html, html_text
641
+ # Removing them may leave runs of spaces and empty lines.
642
+ shown, visible_text = html, _lines(_visible_body(html, html_text))
582
643
  if reader.hidden_text:
583
644
  html.part.note(HTML_HIDDEN_TEXT)
584
645
  if reader.limit:
@@ -877,6 +938,15 @@ class _Body:
877
938
  content: str
878
939
 
879
940
 
941
+ def _visible_body(body: _Body, text: str) -> str:
942
+ """``text`` read from a body, without the characters that render as
943
+ nothing; removing any is noted on the body's part."""
944
+ seen = visible(text)
945
+ if len(seen) < len(text):
946
+ body.part.note(INVISIBLE_CHARACTERS, None, str(len(text) - len(seen)))
947
+ return seen
948
+
949
+
880
950
  def _cut_body(body: _Body, content: str, max_bytes: int) -> str:
881
951
  """``content`` read from a body, cut to ``max_bytes`` of JSON; a cut is
882
952
  noted on the body's part."""
@@ -1392,12 +1462,98 @@ def _unquote(value: str) -> str:
1392
1462
 
1393
1463
 
1394
1464
  def _domain(host: str) -> str:
1395
- """``host`` lower-cased and IDNA-decoded, or lower-cased when it is not IDNA."""
1396
- host = host.lower()
1465
+ """``host`` as browsers read it, as the WHATWG URL Standard has them do,
1466
+ without its trailing dots: mapped as UTS 46 maps it (case, width, the
1467
+ characters it ignores...), each A-label decoded (``_decoded``). A label
1468
+ UTS 46 does not map stays as written, lower-cased.
1469
+
1470
+ A host that can be no DNS name (``_can_be_dns_name``) reaches no
1471
+ resolver: it is only lower-cased, neither mapped nor decoded. That bounds
1472
+ the work a host costs: CPython reorders combining marks for NFC in time
1473
+ quadratic in their run, and a run ends at a dot.
1474
+
1475
+ It reads the host, and does not decide whether a browser would accept it:
1476
+ a label against the Bidi rule, or with a joiner out of context, is read
1477
+ all the same."""
1478
+ host = host.rstrip(_DOT_CHARACTERS)
1479
+ if not _can_be_dns_name(host):
1480
+ return host.lower()
1397
1481
  try:
1398
- return host.encode("ascii").decode("idna")
1399
- except UnicodeError:
1400
- return host
1482
+ labels = _remapped(host).split(".")
1483
+ except ValueError: # a code point UTS 46 does not map: each label alone
1484
+ labels = [_mapped(label) for label in _DOTS.split(host)]
1485
+ while labels and not labels[-1]:
1486
+ labels.pop()
1487
+ return ".".join(map(_decoded, labels))
1488
+
1489
+
1490
+ def _can_be_dns_name(host: str) -> bool:
1491
+ """Whether ``host`` may map to a DNS name, counted on what a resolver could
1492
+ see: without its invisible characters (``visible``, which holds all those
1493
+ UTS 46 removes) and its trailing dots, it keeps at most ``_MAX_DOMAIN``
1494
+ characters, and each label at most ``_MAX_LABEL``. UTS 46 maps any other
1495
+ character to one or more, so the count never exceeds the mapped host's
1496
+ before NFC. It only counts: the host is read as UTS 46 maps it."""
1497
+ counted = visible(host).rstrip(_DOT_CHARACTERS)
1498
+ return len(counted) <= _MAX_DOMAIN and all(
1499
+ len(label) <= _MAX_LABEL for label in _DOTS.split(counted)
1500
+ )
1501
+
1502
+
1503
+ def _mapped(label: str) -> str:
1504
+ """A label mapped as UTS 46 maps it; as written, lower-cased, when it does
1505
+ not map."""
1506
+ try:
1507
+ return _remapped(label)
1508
+ except ValueError: # idna.IDNAError, a UnicodeError
1509
+ return label.lower()
1510
+
1511
+
1512
+ def _remapped(text: str) -> str:
1513
+ """``text`` mapped as UTS 46 maps it, however long it is written. idna maps
1514
+ at most ``_IDNA_INPUT`` characters a call, and UTS 46 maps each character
1515
+ alone: a longer text is mapped in pieces, then put in NFC whole. A code
1516
+ point UTS 46 does not map raises idna.IDNAError, a ValueError."""
1517
+ if len(text) <= _IDNA_INPUT:
1518
+ return idna.uts46_remap(text, std3_rules=False)
1519
+ pieces = (
1520
+ idna.uts46_remap(text[start : start + _IDNA_INPUT], std3_rules=False)
1521
+ for start in range(0, len(text), _IDNA_INPUT)
1522
+ )
1523
+ return unicodedata.normalize("NFC", "".join(pieces))
1524
+
1525
+
1526
+ def _decoded(label: str) -> str:
1527
+ """A mapped label as browsers show it: an A-label decoded from Punycode
1528
+ when what it decodes to is a label UTS 46 leaves as it is (its own
1529
+ criterion: valid code points, in NFC), and not ASCII alone; any other
1530
+ label as it is.
1531
+
1532
+ So an A-label that decodes to what UTS 46 would change stays encoded: a
1533
+ fullwidth or a capital letter, a soft hyphen, a full stop, a code point
1534
+ it does not map. Mapping it would read a dead A-label as another host,
1535
+ a brand's own (``xn--mi7ca7anb7c`` is a fullwidth ``paypal``).
1536
+
1537
+ An A-label over ``_MAX_A_LABEL`` characters is a DNS label no resolver
1538
+ reaches: it stays as it is. That bounds Python's Punycode decoding,
1539
+ quadratic in the label.
1540
+
1541
+ There is no other check: IDNA 2008's CONTEXTO rules rescanned a label for
1542
+ each code point they concern, and UTS 46's joiner checks took 70 us a
1543
+ joiner on idna 3.20, so hostile labels cost seconds a message.
1544
+ """
1545
+ if not label.startswith("xn--") or len(label) > _MAX_A_LABEL:
1546
+ return label
1547
+ try:
1548
+ decoded = label[4:].encode("ascii").decode("punycode")
1549
+ if (
1550
+ not decoded.isascii()
1551
+ and idna.uts46_remap(decoded, std3_rules=False) == decoded
1552
+ ):
1553
+ return decoded
1554
+ except ValueError: # a UnicodeError: not ASCII, not Punycode, not mapped
1555
+ pass
1556
+ return label
1401
1557
 
1402
1558
 
1403
1559
  def _addresses(value: str, cut: _Cutter) -> Iterator[Address]:
@@ -1631,10 +1787,39 @@ def _unclosed_parentheses(value: str) -> bytearray:
1631
1787
  # ----------------------------------------------------------------------- #
1632
1788
 
1633
1789
 
1790
+ def visible(text: str) -> str:
1791
+ """``text`` without the characters that render as nothing: Unicode 16.0's
1792
+ Default_Ignorable_Code_Point (zero-width space and joiners, the soft
1793
+ hyphen, bidi controls, variation selectors, tags...), which split words a
1794
+ reader sees whole. ``parse`` builds ``visible_text`` with it; the other
1795
+ strings of the view keep them, as written (the subject, the headers,
1796
+ display names, anchor text, the text and the HTML)."""
1797
+ return _INVISIBLE.sub("", text)
1798
+
1799
+
1800
+ def _lines(text: str) -> str:
1801
+ """``text`` with its whitespace collapsed, one line per line that is not blank."""
1802
+ lines = (" ".join(line.split()) for line in text.split("\n"))
1803
+ return "\n".join(line for line in lines if line)
1804
+
1805
+
1634
1806
  def url_host(text: str) -> str:
1635
- """The host a browser would reach for ``text``, lower-cased and IDNA-decoded;
1636
- "" when there is none. A backslash reads as a slash, and ``www.`` is a host
1637
- without a scheme: the host ``parse`` records in ``Url.host``."""
1807
+ """The host a browser would reach for ``text``; "" when there is none: the
1808
+ host ``parse`` records in ``Url.host``. A backslash reads as a slash, and
1809
+ ``www.`` is a host without a scheme.
1810
+
1811
+ As the WHATWG URL Standard has browsers do, the host is percent-decoded,
1812
+ as UTF-8 (a NUL becomes U+FFFD, as in the rest of the view), then mapped
1813
+ as UTS 46 maps it (lower-cased, among others), and each A-label decoded
1814
+ when it decodes to a label UTS 46 leaves as it is; its trailing dots are
1815
+ dropped. A label UTS 46 does not map stays as written, lower-cased, a
1816
+ host browsers refuse (a space, a slash, a label against the Bidi rule) is
1817
+ kept as read, and a host that can be no DNS name is only lower-cased:
1818
+ never an exception. That is a host that keeps over 1 012 characters, or a
1819
+ label over 252, once its invisible characters are removed: a DNS name
1820
+ holds 253, a label 63, and NFC composes at most 4 code points into one.
1821
+ An A-label over 63 characters stays encoded.
1822
+ """
1638
1823
  if text[:4].lower() == "www.":
1639
1824
  text = "http://" + text
1640
1825
  try:
@@ -1642,7 +1827,7 @@ def url_host(text: str) -> str:
1642
1827
  host = urlsplit(text.replace("\\", "/")).hostname
1643
1828
  except ValueError:
1644
1829
  return ""
1645
- return _domain(host) if host else ""
1830
+ return _domain(_clean(unquote_url(host))) if host else ""
1646
1831
 
1647
1832
 
1648
1833
  def _url(
@@ -1775,8 +1960,7 @@ class _HtmlReader:
1775
1960
  @property
1776
1961
  def text(self) -> str:
1777
1962
  """The visible text: one line per block, whitespace collapsed."""
1778
- lines = (" ".join(line.split()) for line in "".join(self._chunks).split("\n"))
1779
- return "\n".join(line for line in lines if line)
1963
+ return _lines("".join(self._chunks))
1780
1964
 
1781
1965
  def _read(self, nodes: list[LexborNode]) -> None:
1782
1966
  """Walk the tree depth-first, without recursion: the tree may be deep."""
@@ -10,7 +10,7 @@ from typing import Any
10
10
  from uuid import uuid4
11
11
 
12
12
  from ..email import EmailView
13
- from ..mime import parse
13
+ from ..mime import parse, visible
14
14
 
15
15
 
16
16
  def view_from_eml(eml: bytes | str | os.PathLike[str], **meta: Any) -> EmailView:
@@ -32,7 +32,7 @@ def make_view(**fields: Any) -> EmailView:
32
32
 
33
33
  The defaults are an empty message: a new id, received now from
34
34
  ``"test"``, no headers. As the parser does, ``visible_text`` is the
35
- ``text`` when only ``text`` is given.
35
+ ``text`` without its invisible characters when only ``text`` is given.
36
36
  """
37
37
  defaults: dict[str, Any] = {
38
38
  "id": uuid4(),
@@ -43,5 +43,5 @@ def make_view(**fields: Any) -> EmailView:
43
43
  "headers": [],
44
44
  }
45
45
  if fields.get("text"):
46
- defaults["visible_text"] = fields["text"]
46
+ defaults["visible_text"] = visible(fields["text"])
47
47
  return EmailView.model_validate({**defaults, **fields})