spot-sdk-python 1.2.1__tar.gz → 2.0.0b2__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 (50) hide show
  1. spot_sdk_python-2.0.0b2/PKG-INFO +117 -0
  2. spot_sdk_python-2.0.0b2/README.md +93 -0
  3. {spot_sdk_python-1.2.1 → spot_sdk_python-2.0.0b2}/pyproject.toml +11 -7
  4. spot_sdk_python-2.0.0b2/spot_sdk/__init__.py +152 -0
  5. spot_sdk_python-2.0.0b2/spot_sdk/_json.py +46 -0
  6. spot_sdk_python-2.0.0b2/spot_sdk/app.py +332 -0
  7. spot_sdk_python-2.0.0b2/spot_sdk/clients.py +211 -0
  8. {spot_sdk_python-1.2.1 → spot_sdk_python-2.0.0b2}/spot_sdk/config.py +0 -8
  9. spot_sdk_python-2.0.0b2/spot_sdk/email.py +167 -0
  10. {spot_sdk_python-1.2.1 → spot_sdk_python-2.0.0b2}/spot_sdk/knowledge.py +113 -115
  11. spot_sdk_python-2.0.0b2/spot_sdk/llm.py +514 -0
  12. spot_sdk_python-2.0.0b2/spot_sdk/manifest.py +122 -0
  13. spot_sdk_python-2.0.0b2/spot_sdk/mime.py +2381 -0
  14. spot_sdk_python-2.0.0b2/spot_sdk/orchestrator.py +92 -0
  15. spot_sdk_python-2.0.0b2/spot_sdk/py.typed +0 -0
  16. spot_sdk_python-2.0.0b2/spot_sdk/retriever.py +108 -0
  17. spot_sdk_python-2.0.0b2/spot_sdk/settings.py +79 -0
  18. spot_sdk_python-2.0.0b2/spot_sdk/signals.py +42 -0
  19. spot_sdk_python-2.0.0b2/spot_sdk/testing/README.md +29 -0
  20. spot_sdk_python-2.0.0b2/spot_sdk/testing/__init__.py +17 -0
  21. spot_sdk_python-2.0.0b2/spot_sdk/testing/contract.py +51 -0
  22. {spot_sdk_python-1.2.1 → spot_sdk_python-2.0.0b2}/spot_sdk/testing/fake_knowledge_client.py +42 -18
  23. spot_sdk_python-2.0.0b2/spot_sdk/testing/fake_llm.py +70 -0
  24. spot_sdk_python-2.0.0b2/spot_sdk/testing/fake_spot.py +108 -0
  25. spot_sdk_python-2.0.0b2/spot_sdk/testing/views.py +47 -0
  26. spot_sdk_python-2.0.0b2/spot_sdk/verdict.py +53 -0
  27. {spot_sdk_python-1.2.1 → spot_sdk_python-2.0.0b2}/spot_sdk/workflow.py +79 -14
  28. spot_sdk_python-1.2.1/PKG-INFO +0 -353
  29. spot_sdk_python-1.2.1/README.md +0 -331
  30. spot_sdk_python-1.2.1/spot_sdk/__init__.py +0 -99
  31. spot_sdk_python-1.2.1/spot_sdk/analysis_context.py +0 -99
  32. spot_sdk_python-1.2.1/spot_sdk/analyzer.py +0 -106
  33. spot_sdk_python-1.2.1/spot_sdk/analyzer_base.py +0 -323
  34. spot_sdk_python-1.2.1/spot_sdk/api_gateway.py +0 -271
  35. spot_sdk_python-1.2.1/spot_sdk/config_client.py +0 -203
  36. spot_sdk_python-1.2.1/spot_sdk/config_helpers.py +0 -34
  37. spot_sdk_python-1.2.1/spot_sdk/email.py +0 -136
  38. spot_sdk_python-1.2.1/spot_sdk/ollama.py +0 -58
  39. spot_sdk_python-1.2.1/spot_sdk/orchestrator.py +0 -79
  40. spot_sdk_python-1.2.1/spot_sdk/plugin.py +0 -35
  41. spot_sdk_python-1.2.1/spot_sdk/results.py +0 -129
  42. spot_sdk_python-1.2.1/spot_sdk/retriever.py +0 -267
  43. spot_sdk_python-1.2.1/spot_sdk/settings_schema.py +0 -56
  44. spot_sdk_python-1.2.1/spot_sdk/testing/README.md +0 -83
  45. spot_sdk_python-1.2.1/spot_sdk/testing/__init__.py +0 -22
  46. spot_sdk_python-1.2.1/spot_sdk/testing/factories.py +0 -177
  47. spot_sdk_python-1.2.1/spot_sdk/threat_levels.py +0 -33
  48. {spot_sdk_python-1.2.1 → spot_sdk_python-2.0.0b2}/spot_sdk/errors.py +0 -0
  49. {spot_sdk_python-1.2.1 → spot_sdk_python-2.0.0b2}/spot_sdk/knowledge_tags.py +0 -0
  50. {spot_sdk_python-1.2.1 → spot_sdk_python-2.0.0b2}/spot_sdk/logging.py +0 -0
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: spot-sdk-python
3
+ Version: 2.0.0b2
4
+ Summary: SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers
5
+ License: Apache-2.0
6
+ Author: SPOT Project
7
+ Author-email: spot@sonn.lu
8
+ Requires-Python: >=3.11,<3.16
9
+ Classifier: License :: OSI Approved :: Apache Software License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Requires-Dist: fastapi (>=0.115.3,<1)
17
+ Requires-Dist: httpx (>=0.25,<1)
18
+ Requires-Dist: pydantic (>=2.10,<3.0)
19
+ Requires-Dist: pydantic-settings (>=2.0.0,<3.0.0)
20
+ Requires-Dist: selectolax (>=1.0,<1.1)
21
+ Project-URL: Homepage, https://sonn.lu
22
+ Description-Content-Type: text/markdown
23
+
24
+ # spot-sdk-python
25
+
26
+ The SDK for writing plugins of SPOT, the Spear-Phishing Overwatching
27
+ Tool: analyzers, context providers, mail retrievers and responders.
28
+
29
+ SPOT runs each plugin as a container and calls its `/internal/*` routes
30
+ with the plugin's own token. This package holds what SPOT's core and its
31
+ plugins share:
32
+
33
+ - **the contract** (Pydantic models): the email view, verdicts and
34
+ signals, workflow policies, results, and the plugin manifest with its
35
+ OCI labels;
36
+ - **the parser** core runs on every message at intake
37
+ (`spot_sdk.mime.parse`), bounded against hostile input, which records
38
+ what mail clients may read differently as anomalies;
39
+ - **the plugin runtime**: `create_plugin_app` builds the FastAPI app with
40
+ the routes core calls, token-checked; `PluginSettings` reads the token
41
+ and secrets from `/run/secrets` and the settings from the environment;
42
+ - **the clients**: `SpotClient` (ingest, message content, results),
43
+ `KnowledgeClient` (the Knowledge Store) and `LLMClient`
44
+ (OpenAI-compatible chat models, with the email kept apart from the
45
+ instructions as untrusted data);
46
+ - **test helpers**: `view_from_eml`, `make_view`, `FakeSpot`,
47
+ `FakeKnowledgeClient`, `FakeLLM` and `assert_contract`.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install spot-sdk-python==2.0.0b2
53
+ ```
54
+
55
+ Python 3.11 to 3.15. Version 2 implements plugin contract 2, for
56
+ SPOT 2.0.
57
+
58
+ ## An analyzer
59
+
60
+ ```python
61
+ from spot_sdk import (
62
+ AnalyzerVerdict,
63
+ AnalyzeRequest,
64
+ Egress,
65
+ Manifest,
66
+ PluginKind,
67
+ PluginSettings,
68
+ Signal,
69
+ VerdictClass,
70
+ create_plugin_app,
71
+ )
72
+ from spot_sdk.signals import AUTH_DMARC_FAIL
73
+
74
+ MANIFEST = Manifest(
75
+ id="analyzer-dmarc",
76
+ version="1.0.0",
77
+ kinds=[PluginKind.ANALYZER],
78
+ egress=Egress.NONE,
79
+ )
80
+
81
+
82
+ def analyze(request: AnalyzeRequest, settings: PluginSettings) -> AnalyzerVerdict:
83
+ failed = [
84
+ result
85
+ for result in request.email.authentication
86
+ if result.method == "dmarc" and result.result == "fail"
87
+ ]
88
+ if not failed:
89
+ return AnalyzerVerdict(verdict=VerdictClass.BENIGN, score=10, confidence=60)
90
+ return AnalyzerVerdict(
91
+ verdict=VerdictClass.SUSPICIOUS,
92
+ score=70,
93
+ confidence=80,
94
+ signals=[Signal(name=AUTH_DMARC_FAIL, weight=60, evidence="dmarc=fail")],
95
+ )
96
+
97
+
98
+ app = create_plugin_app(MANIFEST, PluginSettings, analyze=analyze)
99
+ ```
100
+
101
+ Serve it with `uvicorn analyzer_dmarc:app --host 0.0.0.0 --port 8000`,
102
+ and test it with `spot_sdk.testing.assert_contract(app, "tests/phish.eml",
103
+ token=...)`.
104
+
105
+ ## Documentation
106
+
107
+ - [Documentation](https://spot-project.codeberg.page/documentation/sdk/):
108
+ the contract, writing each kind of plugin, the reference.
109
+ - [Migrating from 1.x](https://spot-project.codeberg.page/documentation/sdk/MIGRATION-2/):
110
+ every removed name and its replacement. SDK 2 has no compatibility
111
+ with contract 1.
112
+ - [Source and changelog](https://codeberg.org/SPOT_Project/sdk).
113
+
114
+ ## License
115
+
116
+ Apache License 2.0.
117
+
@@ -0,0 +1,93 @@
1
+ # spot-sdk-python
2
+
3
+ The SDK for writing plugins of SPOT, the Spear-Phishing Overwatching
4
+ Tool: analyzers, context providers, mail retrievers and responders.
5
+
6
+ SPOT runs each plugin as a container and calls its `/internal/*` routes
7
+ with the plugin's own token. This package holds what SPOT's core and its
8
+ plugins share:
9
+
10
+ - **the contract** (Pydantic models): the email view, verdicts and
11
+ signals, workflow policies, results, and the plugin manifest with its
12
+ OCI labels;
13
+ - **the parser** core runs on every message at intake
14
+ (`spot_sdk.mime.parse`), bounded against hostile input, which records
15
+ what mail clients may read differently as anomalies;
16
+ - **the plugin runtime**: `create_plugin_app` builds the FastAPI app with
17
+ the routes core calls, token-checked; `PluginSettings` reads the token
18
+ and secrets from `/run/secrets` and the settings from the environment;
19
+ - **the clients**: `SpotClient` (ingest, message content, results),
20
+ `KnowledgeClient` (the Knowledge Store) and `LLMClient`
21
+ (OpenAI-compatible chat models, with the email kept apart from the
22
+ instructions as untrusted data);
23
+ - **test helpers**: `view_from_eml`, `make_view`, `FakeSpot`,
24
+ `FakeKnowledgeClient`, `FakeLLM` and `assert_contract`.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install spot-sdk-python==2.0.0b2
30
+ ```
31
+
32
+ Python 3.11 to 3.15. Version 2 implements plugin contract 2, for
33
+ SPOT 2.0.
34
+
35
+ ## An analyzer
36
+
37
+ ```python
38
+ from spot_sdk import (
39
+ AnalyzerVerdict,
40
+ AnalyzeRequest,
41
+ Egress,
42
+ Manifest,
43
+ PluginKind,
44
+ PluginSettings,
45
+ Signal,
46
+ VerdictClass,
47
+ create_plugin_app,
48
+ )
49
+ from spot_sdk.signals import AUTH_DMARC_FAIL
50
+
51
+ MANIFEST = Manifest(
52
+ id="analyzer-dmarc",
53
+ version="1.0.0",
54
+ kinds=[PluginKind.ANALYZER],
55
+ egress=Egress.NONE,
56
+ )
57
+
58
+
59
+ def analyze(request: AnalyzeRequest, settings: PluginSettings) -> AnalyzerVerdict:
60
+ failed = [
61
+ result
62
+ for result in request.email.authentication
63
+ if result.method == "dmarc" and result.result == "fail"
64
+ ]
65
+ if not failed:
66
+ return AnalyzerVerdict(verdict=VerdictClass.BENIGN, score=10, confidence=60)
67
+ return AnalyzerVerdict(
68
+ verdict=VerdictClass.SUSPICIOUS,
69
+ score=70,
70
+ confidence=80,
71
+ signals=[Signal(name=AUTH_DMARC_FAIL, weight=60, evidence="dmarc=fail")],
72
+ )
73
+
74
+
75
+ app = create_plugin_app(MANIFEST, PluginSettings, analyze=analyze)
76
+ ```
77
+
78
+ Serve it with `uvicorn analyzer_dmarc:app --host 0.0.0.0 --port 8000`,
79
+ and test it with `spot_sdk.testing.assert_contract(app, "tests/phish.eml",
80
+ token=...)`.
81
+
82
+ ## Documentation
83
+
84
+ - [Documentation](https://spot-project.codeberg.page/documentation/sdk/):
85
+ the contract, writing each kind of plugin, the reference.
86
+ - [Migrating from 1.x](https://spot-project.codeberg.page/documentation/sdk/MIGRATION-2/):
87
+ every removed name and its replacement. SDK 2 has no compatibility
88
+ with contract 1.
89
+ - [Source and changelog](https://codeberg.org/SPOT_Project/sdk).
90
+
91
+ ## License
92
+
93
+ Apache License 2.0.
@@ -4,8 +4,8 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "spot-sdk-python"
7
- version = "1.2.1"
8
- description = "Python SDK for SPOT platform - API contracts, models, and utilities"
7
+ version = "2.0.0b2"
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"
11
11
  homepage = "https://sonn.lu"
@@ -15,18 +15,22 @@ packages = [
15
15
  ]
16
16
 
17
17
  [tool.poetry.dependencies]
18
- python = "^3.11"
19
- pydantic = {extras = ["email"], version = "^2.0.0"}
18
+ python = ">=3.11,<3.16"
19
+ pydantic = "^2.10"
20
20
  pydantic-settings = "^2.0.0"
21
- httpx = "^0.25.0"
22
- typing-extensions = "^4.8.0"
21
+ httpx = ">=0.25,<1"
22
+ selectolax = ">=1.0,<1.1"
23
+ fastapi = ">=0.115.3,<1"
23
24
 
24
25
  [tool.poetry.group.dev.dependencies]
25
26
  pytest = "^7.4.0"
26
27
  pytest-asyncio = "^0.21.0"
28
+ pytest-cov = "^4.1.0"
27
29
  mypy = "^1.5.0"
28
30
 
29
31
  [tool.ruff]
32
+ target-version = "py311"
33
+
34
+ [tool.ruff.lint]
30
35
  select = ["E", "F", "I", "N", "W"]
31
36
  ignore = ["E501"] # Line too long
32
- target-version = "py311"
@@ -0,0 +1,152 @@
1
+ """SPOT SDK: the plugin contract v2, the plugin runtime and its clients."""
2
+
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
8
+ from .errors import ErrorResponse
9
+ from .knowledge_tags import KnowledgeTag
10
+ from .logging import configure_logging, get_logger, get_logging_config
11
+ from .manifest import (
12
+ CONTRACT_VERSION,
13
+ Egress,
14
+ Manifest,
15
+ PluginKind,
16
+ Resources,
17
+ SettingField,
18
+ SettingType,
19
+ )
20
+ from .orchestrator import (
21
+ AnalyzeRequest,
22
+ AnalyzerOutcome,
23
+ AttributedSignal,
24
+ OrchestrationResult,
25
+ RespondRequest,
26
+ StageOutcome,
27
+ )
28
+ from .retriever import IngestionRequest, IngestionResult, IngestionStatus
29
+ from .settings import PluginSettings
30
+ from .verdict import AnalyzerVerdict, Score, Signal, VerdictClass
31
+ from .workflow import (
32
+ AggregationMethod,
33
+ AnalyzerConfig,
34
+ DecisiveSignal,
35
+ FailureStrategy,
36
+ ResponseMode,
37
+ RetrievalLimits,
38
+ RetryConfig,
39
+ VerdictPolicy,
40
+ Workflow,
41
+ WorkflowStage,
42
+ )
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
+
79
+ __all__ = [
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",
142
+ # Config models
143
+ "ConfigOption",
144
+ "ConfigStatus",
145
+ "ConfigUpdateRequest",
146
+ # Error response
147
+ "ErrorResponse",
148
+ # Logging
149
+ "configure_logging",
150
+ "get_logger",
151
+ "get_logging_config",
152
+ ]
@@ -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)