spot-sdk-python 1.2.1__py3-none-any.whl → 2.0.0b2__py3-none-any.whl

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/__init__.py CHANGED
@@ -1,99 +1,152 @@
1
- """SPOT API interfaces and data models."""
1
+ """SPOT SDK: the plugin contract v2, the plugin runtime and its clients."""
2
2
 
3
- from .analysis_context import AnalysisContextReader
4
- from .analyzer import AnalyzerCapability, AnalyzerInterface, ThreatLevel
5
- from .config import ConfigOption, ConfigReloadResult, ConfigStatus, ConfigUpdateRequest
6
- from .config_client import ConfigClient
7
- from .config_helpers import merge_settings
8
- from .email import Attachment, Email, EmailHeader
3
+ from importlib import import_module
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from .config import ConfigOption, ConfigStatus, ConfigUpdateRequest
7
+ from .email import Address, Anomaly, AuthResult, EmailView, Envelope, Header, Part, Url
9
8
  from .errors import ErrorResponse
10
- from .knowledge import KnowledgeClient, KnowledgeDocument, chunk_text, content_hash
11
9
  from .knowledge_tags import KnowledgeTag
12
10
  from .logging import configure_logging, get_logger, get_logging_config
13
- from .ollama import LLMResponse, OllamaClientProtocol
14
- from .orchestrator import AnalyzerResult, OrchestrationResult, WorkflowStageResult
15
- from .plugin import PluginKind
16
- from .results import (
17
- AnalysisIndicator,
18
- AnalysisMetadata,
19
- AnalysisResult,
20
- IndicatorType,
11
+ from .manifest import (
12
+ CONTRACT_VERSION,
13
+ Egress,
14
+ Manifest,
15
+ PluginKind,
16
+ Resources,
17
+ SettingField,
18
+ SettingType,
21
19
  )
22
- from .retriever import (
23
- IngestionRequest,
24
- IngestionResult,
25
- IngestionStatus,
26
- MailRetrieverClient,
20
+ from .orchestrator import (
21
+ AnalyzeRequest,
22
+ AnalyzerOutcome,
23
+ AttributedSignal,
24
+ OrchestrationResult,
25
+ RespondRequest,
26
+ StageOutcome,
27
27
  )
28
- from .settings_schema import register_settings_schema
29
- from .threat_levels import confidence_to_threat_level
28
+ from .retriever import IngestionRequest, IngestionResult, IngestionStatus
29
+ from .settings import PluginSettings
30
+ from .verdict import AnalyzerVerdict, Score, Signal, VerdictClass
30
31
  from .workflow import (
32
+ AggregationMethod,
31
33
  AnalyzerConfig,
34
+ DecisiveSignal,
32
35
  FailureStrategy,
36
+ ResponseMode,
33
37
  RetrievalLimits,
34
38
  RetryConfig,
39
+ VerdictPolicy,
35
40
  Workflow,
36
41
  WorkflowStage,
37
42
  )
38
43
 
44
+ if TYPE_CHECKING:
45
+ from .app import SyncState, create_plugin_app
46
+ from .clients import SpotAPIError, SpotClient
47
+ from .knowledge import KnowledgeClient, KnowledgeDocument, chunk_text, content_hash
48
+ from .llm import LLMClient, LLMOutputError, LLMToolCall, LLMToolCalls, LLMToolRound
49
+
50
+ # The modules that import FastAPI, Starlette or httpx load on first use, so importing
51
+ # `spot_sdk.mime` or the models stays light (PEP 562).
52
+ _LAZY = {
53
+ **dict.fromkeys(("SyncState", "create_plugin_app"), ".app"),
54
+ **dict.fromkeys(("SpotAPIError", "SpotClient"), ".clients"),
55
+ **dict.fromkeys(
56
+ ("KnowledgeClient", "KnowledgeDocument", "chunk_text", "content_hash"),
57
+ ".knowledge",
58
+ ),
59
+ **dict.fromkeys(
60
+ ("LLMClient", "LLMOutputError", "LLMToolCall", "LLMToolCalls", "LLMToolRound"),
61
+ ".llm",
62
+ ),
63
+ }
64
+
65
+
66
+ def __getattr__(name: str) -> Any:
67
+ module = _LAZY.get(name)
68
+ if module is None:
69
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
70
+ value = getattr(import_module(module, __name__), name)
71
+ globals()[name] = value
72
+ return value
73
+
74
+
75
+ def __dir__() -> list[str]:
76
+ return sorted({*globals(), *_LAZY})
77
+
78
+
39
79
  __all__ = [
40
- # Analyzer interface
41
- "AnalyzerInterface",
42
- "AnalyzerCapability",
43
- "ThreatLevel",
80
+ # Email view
81
+ "Address",
82
+ "Anomaly",
83
+ "AuthResult",
84
+ "EmailView",
85
+ "Envelope",
86
+ "Header",
87
+ "Part",
88
+ "Url",
89
+ # Verdicts
90
+ "AnalyzerVerdict",
91
+ "Score",
92
+ "Signal",
93
+ "VerdictClass",
94
+ # Workflow
95
+ "AggregationMethod",
96
+ "AnalyzerConfig",
97
+ "DecisiveSignal",
98
+ "FailureStrategy",
99
+ "ResponseMode",
100
+ "RetrievalLimits",
101
+ "RetryConfig",
102
+ "VerdictPolicy",
103
+ "Workflow",
104
+ "WorkflowStage",
105
+ # Analyzer and responder calls, results
106
+ "AnalyzeRequest",
107
+ "AnalyzerOutcome",
108
+ "AttributedSignal",
109
+ "OrchestrationResult",
110
+ "RespondRequest",
111
+ "StageOutcome",
112
+ # Ingestion
113
+ "IngestionRequest",
114
+ "IngestionResult",
115
+ "IngestionStatus",
116
+ # Plugin manifest
117
+ "CONTRACT_VERSION",
118
+ "Egress",
119
+ "Manifest",
120
+ "PluginKind",
121
+ "Resources",
122
+ "SettingField",
123
+ "SettingType",
124
+ # Plugin runtime
125
+ "PluginSettings",
126
+ "SyncState",
127
+ "create_plugin_app",
128
+ # Clients
129
+ "LLMClient",
130
+ "LLMOutputError",
131
+ "LLMToolCall",
132
+ "LLMToolCalls",
133
+ "LLMToolRound",
134
+ "SpotAPIError",
135
+ "SpotClient",
136
+ # Knowledge Store
137
+ "KnowledgeClient",
138
+ "KnowledgeDocument",
139
+ "KnowledgeTag",
140
+ "chunk_text",
141
+ "content_hash",
44
142
  # Config models
45
143
  "ConfigOption",
46
144
  "ConfigStatus",
47
145
  "ConfigUpdateRequest",
48
- "ConfigReloadResult",
49
- "ConfigClient",
50
146
  # Error response
51
147
  "ErrorResponse",
52
- # Email models
53
- "Email",
54
- "EmailHeader",
55
- "Attachment",
56
- "AnalysisContextReader",
57
- # Analysis results
58
- "AnalysisResult",
59
- "AnalysisIndicator",
60
- "AnalysisMetadata",
61
- "IndicatorType",
62
148
  # Logging
63
149
  "configure_logging",
64
150
  "get_logger",
65
151
  "get_logging_config",
66
- # Workflow
67
- "Workflow",
68
- "WorkflowStage",
69
- "AnalyzerConfig",
70
- "RetryConfig",
71
- "RetrievalLimits",
72
- "FailureStrategy",
73
- # Knowledge Store
74
- "KnowledgeDocument",
75
- "KnowledgeClient",
76
- "KnowledgeTag",
77
- "chunk_text",
78
- "content_hash",
79
- # Plugin vocabulary
80
- "PluginKind",
81
- # Mail-retriever ingest contract
82
- "IngestionRequest",
83
- "IngestionResult",
84
- "IngestionStatus",
85
- "MailRetrieverClient",
86
- # Orchestration
87
- "OrchestrationResult",
88
- "AnalyzerResult",
89
- "WorkflowStageResult",
90
- # Ollama protocol
91
- "OllamaClientProtocol",
92
- "LLMResponse",
93
- # Settings schema
94
- "register_settings_schema",
95
- # Config helpers
96
- "merge_settings",
97
- # Threat levels
98
- "confidence_to_threat_level",
99
152
  ]
spot_sdk/_json.py ADDED
@@ -0,0 +1,46 @@
1
+ """Strings measured and cut by the size of their JSON.
2
+
3
+ The view and the LLM client's rendering are sent as JSON, where escapes make
4
+ a string larger than its text: a control character takes six bytes
5
+ (``\\u0001``). ``json.dumps`` with ``ensure_ascii=False`` and pydantic's
6
+ ``model_dump_json`` escape strings alike, so sizes here are theirs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ _MAX_CHARACTER_BYTES = 6
14
+ """The most bytes one character takes: a control character's escape."""
15
+
16
+
17
+ def json_size(text: str) -> int:
18
+ """The UTF-8 bytes of ``text``'s JSON string between its quotes, escapes
19
+ included; a lone surrogate (possible in a string built in Python) counts
20
+ as three."""
21
+ escaped = json.dumps(text, ensure_ascii=False)[1:-1]
22
+ return len(escaped.encode("utf-8", "surrogatepass"))
23
+
24
+
25
+ def cut_json(text: str, max_bytes: int) -> tuple[str, bool]:
26
+ """The longest start of ``text`` whose JSON string takes at most
27
+ ``max_bytes`` bytes between its quotes (``json_size``), and whether that
28
+ cut anything.
29
+
30
+ A character is kept or dropped whole, so neither an escape nor a
31
+ character's UTF-8 bytes are split. The text is measured in chunks that
32
+ surely fit, a sixth of the room left each, then character by character:
33
+ the work follows ``max_bytes``, whatever the text's length.
34
+ """
35
+ if len(text) * _MAX_CHARACTER_BYTES <= max_bytes:
36
+ return text, False # it surely fits
37
+ kept = 0
38
+ room = max_bytes
39
+ while kept < len(text):
40
+ chunk = text[kept : kept + max(room // _MAX_CHARACTER_BYTES, 1)]
41
+ used = json_size(chunk)
42
+ if used > room: # one character, which does not fit
43
+ break
44
+ kept += len(chunk)
45
+ room -= used
46
+ return text[:kept], kept < len(text)
spot_sdk/app.py ADDED
@@ -0,0 +1,332 @@
1
+ """The plugin runtime: one FastAPI app with the routes core calls.
2
+
3
+ ``create_plugin_app`` builds it from the plugin's manifest, its settings
4
+ class and its handlers:
5
+
6
+ - ``GET /health`` and ``GET /manifest`` are open;
7
+ - ``POST /internal/analyze``, ``POST /internal/respond``, and
8
+ ``POST /internal/sync`` with ``GET /internal/state``, exist for the
9
+ handlers given. Each checks ``Authorization: Bearer <the plugin's own
10
+ token>`` before it reads the request, and answers 401 otherwise; an empty
11
+ configured token refuses every call.
12
+
13
+ An ``async def`` handler, or an object whose ``__call__`` is ``async def``,
14
+ runs on the event loop; any other callable runs in a worker thread, so it may
15
+ block. Either way the clients send the call's ``X-Correlation-ID`` on to SPOT.
16
+
17
+ Neither the logs nor the error answers hold the request's content: a
18
+ handler's exception is logged by its type and place only, and an invalid
19
+ request is answered with the fields refused, never the values sent.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import hmac
26
+ import inspect
27
+ import traceback
28
+ from collections.abc import Awaitable, Callable, Iterator
29
+ from contextlib import contextmanager
30
+ from datetime import UTC, datetime
31
+ from typing import Any, TypeAlias, TypeVar
32
+
33
+ from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response
34
+ from fastapi.responses import JSONResponse
35
+ from pydantic import BaseModel, Field, SecretStr, ValidationError
36
+ from pydantic_core import PydanticSerializationError
37
+ from starlette.concurrency import run_in_threadpool
38
+ from starlette.types import Lifespan
39
+
40
+ from .clients import correlation_id
41
+ from .logging import get_logger
42
+ from .manifest import Manifest, PluginKind
43
+ from .orchestrator import AnalyzeRequest, RespondRequest
44
+ from .settings import PluginSettings
45
+ from .verdict import AnalyzerVerdict
46
+
47
+ logger = get_logger(__name__)
48
+
49
+ SettingsT = TypeVar("SettingsT", bound=PluginSettings)
50
+ ModelT = TypeVar("ModelT", bound=BaseModel)
51
+
52
+ AnalyzeHandler: TypeAlias = Callable[
53
+ [AnalyzeRequest, SettingsT], AnalyzerVerdict | Awaitable[AnalyzerVerdict]
54
+ ]
55
+ """``analyze(request, settings) -> AnalyzerVerdict``; an exception is answered 500."""
56
+ RespondHandler: TypeAlias = Callable[
57
+ [RespondRequest, SettingsT], None | Awaitable[None]
58
+ ]
59
+ """``respond(request, settings) -> None``; it acts only when ``request.mode`` is enforce."""
60
+ SyncHandler: TypeAlias = Callable[[SettingsT], int | Awaitable[int]]
61
+ """``sync(settings) -> int``: the number of documents written."""
62
+
63
+
64
+ class SyncState(BaseModel):
65
+ """A context provider's syncs, as ``/internal/sync`` and ``/internal/state`` answer."""
66
+
67
+ in_progress: bool = False
68
+ last_sync_at: datetime | None = Field(
69
+ default=None, description="When the last sync started"
70
+ )
71
+ last_finished_at: datetime | None = Field(
72
+ default=None, description="When the last sync ended"
73
+ )
74
+ last_sync_count: int = Field(
75
+ default=0, description="Documents written by the last sync that succeeded"
76
+ )
77
+ last_error: str | None = Field(
78
+ default=None,
79
+ description="The exception's type when the last sync failed; None when it succeeded",
80
+ )
81
+
82
+
83
+ def create_plugin_app(
84
+ manifest: Manifest,
85
+ settings_cls: type[SettingsT],
86
+ *,
87
+ analyze: AnalyzeHandler[SettingsT] | None = None,
88
+ respond: RespondHandler[SettingsT] | None = None,
89
+ sync: SyncHandler[SettingsT] | None = None,
90
+ lifespan: Lifespan[FastAPI] | None = None,
91
+ ) -> FastAPI:
92
+ """Build the plugin's app; the settings are read once, here.
93
+
94
+ Each kind the manifest declares needs its handler (analyzer: ``analyze``,
95
+ responder: ``respond``, context provider: ``sync``; a mail retriever has
96
+ none), and each handler its kind, or ``ValueError`` is raised.
97
+
98
+ ``lifespan`` is passed to FastAPI as is: an async context manager run
99
+ around the app's life, to load a model at startup, say.
100
+
101
+ ``POST /internal/sync`` answers 202 at once and runs ``sync`` in the
102
+ background. While a sync runs, another call starts nothing and answers
103
+ 202 with the running sync's state.
104
+ """
105
+ _check_kinds(manifest, {"analyze": analyze, "respond": respond, "sync": sync})
106
+ settings = settings_cls()
107
+ if not settings.spot_plugin_token.get_secret_value():
108
+ logger.warning(
109
+ "The plugin has no token: every /internal call will be refused. Core "
110
+ "mounts it as /run/secrets/spot_plugin_token; outside a container, set "
111
+ "SPOT_PLUGIN_TOKEN."
112
+ )
113
+ app = FastAPI(
114
+ title=manifest.id,
115
+ version=manifest.version,
116
+ openapi_url=None,
117
+ lifespan=lifespan,
118
+ )
119
+
120
+ @app.get("/health")
121
+ async def health() -> dict[str, str]:
122
+ return {"status": "healthy", "version": manifest.version}
123
+
124
+ @app.get("/manifest")
125
+ async def get_manifest() -> Manifest:
126
+ return manifest
127
+
128
+ internal = APIRouter(
129
+ prefix="/internal",
130
+ dependencies=[Depends(_token_check(settings.spot_plugin_token))],
131
+ )
132
+
133
+ if analyze is not None:
134
+
135
+ @internal.post("/analyze")
136
+ async def analyze_route(request: Request) -> Response:
137
+ call = await _parse(request, AnalyzeRequest)
138
+ with _correlated(request):
139
+ try:
140
+ answer = await _run(analyze, call, settings)
141
+ except Exception as error:
142
+ _log_failure(f"analysis of job {call.job_id}", error)
143
+ return JSONResponse({"detail": "analysis failed"}, status_code=500)
144
+ try:
145
+ verdict = AnalyzerVerdict.model_validate(answer)
146
+ except ValidationError as error:
147
+ _log_invalid_answer(f"analysis of job {call.job_id}", error)
148
+ return JSONResponse({"detail": "analysis failed"}, status_code=500)
149
+ try:
150
+ body = verdict.model_dump_json()
151
+ except PydanticSerializationError as error:
152
+ _log_failure(f"analysis of job {call.job_id}", error)
153
+ return JSONResponse({"detail": "analysis failed"}, status_code=500)
154
+ return Response(body, media_type="application/json")
155
+
156
+ if respond is not None:
157
+
158
+ @internal.post("/respond")
159
+ async def respond_route(request: Request) -> Response:
160
+ call = await _parse(request, RespondRequest)
161
+ with _correlated(request):
162
+ try:
163
+ await _run(respond, call, settings)
164
+ except Exception as error:
165
+ _log_failure(f"response to job {call.job_id}", error)
166
+ return JSONResponse({"detail": "response failed"}, status_code=500)
167
+ return Response(status_code=204)
168
+
169
+ if sync is not None:
170
+ syncs = _SyncRunner(sync, settings)
171
+
172
+ @internal.post("/sync", status_code=202)
173
+ async def sync_route(request: Request) -> SyncState:
174
+ with _correlated(request):
175
+ return syncs.start()
176
+
177
+ @internal.get("/state")
178
+ async def state_route() -> SyncState:
179
+ return syncs.state.model_copy()
180
+
181
+ app.include_router(internal)
182
+ return app
183
+
184
+
185
+ _HANDLER_KINDS = {
186
+ "analyze": PluginKind.ANALYZER,
187
+ "respond": PluginKind.RESPONDER,
188
+ "sync": PluginKind.CONTEXT_PROVIDER,
189
+ }
190
+ """The kind each handler serves; a mail retriever has no handler."""
191
+
192
+
193
+ def _check_kinds(manifest: Manifest, handlers: dict[str, object]) -> None:
194
+ """Each kind the manifest declares has its handler, and each handler its kind."""
195
+ for name, kind in _HANDLER_KINDS.items():
196
+ declared = kind in manifest.kinds
197
+ given = handlers[name] is not None
198
+ if declared and not given:
199
+ raise ValueError(
200
+ f"the manifest declares {kind}, but no {name} handler is given"
201
+ )
202
+ if given and not declared:
203
+ raise ValueError(
204
+ f"the {name} handler is given, but the manifest does not declare {kind}"
205
+ )
206
+
207
+
208
+ class _SyncRunner:
209
+ """Runs the sync handler in the background, one run at a time."""
210
+
211
+ def __init__(self, handler: SyncHandler[Any], settings: PluginSettings) -> None:
212
+ self._handler = handler
213
+ self._settings = settings
214
+ self._task: asyncio.Task[None] | None = None
215
+ self.state = SyncState()
216
+
217
+ def start(self) -> SyncState:
218
+ """Start a sync unless one runs; the state either way."""
219
+ if not self.state.in_progress:
220
+ self.state.in_progress = True
221
+ self.state.last_sync_at = datetime.now(UTC)
222
+ self.state.last_error = None
223
+ self._task = asyncio.create_task(self._run())
224
+ return self.state.model_copy()
225
+
226
+ async def _run(self) -> None:
227
+ try:
228
+ count = await _run(self._handler, self._settings)
229
+ if not isinstance(count, int):
230
+ raise TypeError(
231
+ f"the sync handler returned {type(count).__name__}, not int"
232
+ )
233
+ self.state.last_sync_count = count
234
+ except Exception as error:
235
+ self.state.last_error = type(error).__name__
236
+ _log_failure("sync", error)
237
+ finally:
238
+ self.state.last_finished_at = datetime.now(UTC)
239
+ self.state.in_progress = False
240
+
241
+
242
+ def _token_check(token: SecretStr) -> Callable[[Request], Awaitable[None]]:
243
+ expected = token.get_secret_value().encode()
244
+
245
+ async def check_token(request: Request) -> None:
246
+ if not _is_bearer(request.headers.get("authorization"), expected):
247
+ raise HTTPException(
248
+ status_code=401,
249
+ detail="unauthorized",
250
+ headers={"WWW-Authenticate": "Bearer"},
251
+ )
252
+
253
+ return check_token
254
+
255
+
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:
259
+ return False
260
+ scheme, _, presented = header.partition(" ")
261
+ return scheme.lower() == "bearer" and hmac.compare_digest(presented.encode(), token)
262
+
263
+
264
+ async def _parse(request: Request, model: type[ModelT]) -> ModelT:
265
+ """The request body as ``model``; a 422 names the fields refused, never the values sent.
266
+
267
+ The 422 is raised after the handler, so the validation error, which holds
268
+ the request's content, is not attached as its context.
269
+ """
270
+ try:
271
+ return model.model_validate_json(await request.body())
272
+ except ValidationError as error:
273
+ detail = error.errors(
274
+ include_url=False, include_context=False, include_input=False
275
+ )
276
+ raise HTTPException(status_code=422, detail=detail)
277
+
278
+
279
+ async def _run(handler: Callable[..., Any], *args: Any) -> Any:
280
+ """Await an async handler; run any other in a worker thread, in this context.
281
+
282
+ A handler is async when it is an ``async def`` function, or an object whose
283
+ ``__call__`` is one, as FastAPI tells them apart.
284
+ """
285
+ if inspect.iscoroutinefunction(handler) or inspect.iscoroutinefunction(
286
+ getattr(handler, "__call__", None)
287
+ ):
288
+ return await handler(*args)
289
+ return await run_in_threadpool(handler, *args)
290
+
291
+
292
+ @contextmanager
293
+ def _correlated(request: Request) -> Iterator[None]:
294
+ """Make the call's ``X-Correlation-ID`` the one the clients send."""
295
+ reset = correlation_id.set(request.headers.get("x-correlation-id"))
296
+ try:
297
+ yield
298
+ finally:
299
+ correlation_id.reset(reset)
300
+
301
+
302
+ def _log_failure(what: str, error: Exception) -> None:
303
+ """Log a handler's failure by its type and place only: its message, or the
304
+ locations of a validation error, may quote the email, or a directory URL
305
+ with its credentials."""
306
+ frame = traceback.extract_tb(error.__traceback__)[-1]
307
+ logger.error(
308
+ "%s failed: %s at %s:%s in %s",
309
+ what,
310
+ type(error).__name__,
311
+ frame.filename,
312
+ frame.lineno,
313
+ frame.name,
314
+ )
315
+
316
+
317
+ def _log_invalid_answer(what: str, error: ValidationError) -> None:
318
+ """Log a handler's answer that does not validate by the fields refused and
319
+ why (``loc`` and ``type``), never their values. The locations are the
320
+ answer model's own fields, so they hold none of the email."""
321
+ fields = ", ".join(
322
+ f"{'.'.join(str(part) for part in detail['loc'])} ({detail['type']})"
323
+ for detail in error.errors(
324
+ include_url=False, include_context=False, include_input=False
325
+ )
326
+ )
327
+ logger.error(
328
+ "%s failed: the handler's answer is not a valid %s; invalid: %s",
329
+ what,
330
+ error.title,
331
+ fields,
332
+ )