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.
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/PKG-INFO +3 -2
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/README.md +1 -1
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/pyproject.toml +2 -1
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/__init__.py +3 -2
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/app.py +183 -17
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/email.py +20 -3
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/manifest.py +41 -4
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/mime.py +198 -14
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/views.py +3 -3
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/_json.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/clients.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/config.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/errors.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/knowledge.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/knowledge_tags.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/llm.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/logging.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/orchestrator.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/py.typed +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/retriever.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/settings.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/signals.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/README.md +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/__init__.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/contract.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/fake_knowledge_client.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/fake_llm.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/fake_spot.py +0 -0
- {spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/verdict.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
@@ -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.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
|
|
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 =
|
|
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
|
|
122
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
246
|
-
|
|
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
|
|
257
|
-
"""Whether
|
|
258
|
-
if not token or
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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="",
|
|
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,
|
|
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
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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``
|
|
1396
|
-
|
|
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
|
-
|
|
1399
|
-
except
|
|
1400
|
-
|
|
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
|
|
1636
|
-
|
|
1637
|
-
|
|
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
|
-
|
|
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})
|
|
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
|
|
File without changes
|
|
File without changes
|
{spot_sdk_python-2.0.0b3 → spot_sdk_python-2.0.0b4}/spot_sdk/testing/fake_knowledge_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|