spot-sdk-python 2.0.0b8__tar.gz → 2.0.0b10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/PKG-INFO +2 -2
  2. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/README.md +1 -1
  3. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/pyproject.toml +1 -1
  4. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/__init__.py +2 -1
  5. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/clients.py +16 -7
  6. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/orchestrator.py +1 -1
  7. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/retriever.py +29 -4
  8. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/testing/fake_spot.py +19 -5
  9. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/workflow.py +1 -5
  10. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/_json.py +0 -0
  11. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/app.py +0 -0
  12. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/artifacts.py +0 -0
  13. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/config.py +0 -0
  14. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/email.py +0 -0
  15. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/errors.py +0 -0
  16. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/knowledge.py +0 -0
  17. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/knowledge_tags.py +0 -0
  18. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/llm.py +0 -0
  19. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/logging.py +0 -0
  20. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/manifest.py +0 -0
  21. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/mime.py +0 -0
  22. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/model_input.py +0 -0
  23. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/py.typed +0 -0
  24. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/settings.py +0 -0
  25. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/signals.py +0 -0
  26. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/testing/README.md +0 -0
  27. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/testing/__init__.py +0 -0
  28. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/testing/contract.py +0 -0
  29. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/testing/fake_knowledge_client.py +0 -0
  30. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/testing/fake_llm.py +0 -0
  31. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/testing/settings.py +0 -0
  32. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/testing/views.py +0 -0
  33. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/urls.py +0 -0
  34. {spot_sdk_python-2.0.0b8 → spot_sdk_python-2.0.0b10}/spot_sdk/verdict.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spot-sdk-python
3
- Version: 2.0.0b8
3
+ Version: 2.0.0b10
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
@@ -51,7 +51,7 @@ plugins share:
51
51
  ## Install
52
52
 
53
53
  ```bash
54
- pip install spot-sdk-python==2.0.0b8
54
+ pip install spot-sdk-python==2.0.0b10
55
55
  ```
56
56
 
57
57
  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.0b8
29
+ pip install spot-sdk-python==2.0.0b10
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.0b8"
7
+ version = "2.0.0b10"
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"
@@ -25,7 +25,7 @@ from .orchestrator import (
25
25
  RespondRequest,
26
26
  StageOutcome,
27
27
  )
28
- from .retriever import IngestionRequest, IngestionResult, IngestionStatus
28
+ from .retriever import ActionReport, IngestionRequest, IngestionResult, IngestionStatus
29
29
  from .settings import PluginSettings
30
30
  from .verdict import AnalyzerVerdict, Score, Signal, VerdictClass
31
31
  from .workflow import (
@@ -110,6 +110,7 @@ __all__ = [
110
110
  "RespondRequest",
111
111
  "StageOutcome",
112
112
  # Ingestion
113
+ "ActionReport",
113
114
  "IngestionRequest",
114
115
  "IngestionResult",
115
116
  "IngestionStatus",
@@ -27,7 +27,12 @@ from pydantic import BaseModel, SecretStr, ValidationError
27
27
 
28
28
  from .email import Envelope
29
29
  from .orchestrator import OrchestrationResult
30
- from .retriever import IngestionPriority, IngestionRequest, IngestionResult
30
+ from .retriever import (
31
+ ActionReport,
32
+ IngestionPriority,
33
+ IngestionRequest,
34
+ IngestionResult,
35
+ )
31
36
  from .settings import PluginSettings
32
37
 
33
38
  # The bytes of a raw message encoded at a time: a few milliseconds of a thread
@@ -111,7 +116,6 @@ def ingestion_request(
111
116
  raw: bytes,
112
117
  *,
113
118
  envelope: Envelope | None = None,
114
- workflow_id: str | None = None,
115
119
  priority: IngestionPriority = "normal",
116
120
  wait_for_verdict: bool = False,
117
121
  timeout_ms: int = 30_000,
@@ -121,7 +125,6 @@ def ingestion_request(
121
125
  return IngestionRequest(
122
126
  raw=base64.b64encode(raw).decode("ascii"),
123
127
  envelope=envelope,
124
- workflow_id=workflow_id,
125
128
  priority=priority,
126
129
  wait_for_verdict=wait_for_verdict,
127
130
  timeout_ms=timeout_ms,
@@ -132,7 +135,6 @@ def ingestion_body(
132
135
  raw: bytes,
133
136
  *,
134
137
  envelope: Envelope | None = None,
135
- workflow_id: str | None = None,
136
138
  priority: IngestionPriority = "normal",
137
139
  wait_for_verdict: bool = False,
138
140
  timeout_ms: int = 30_000,
@@ -149,7 +151,6 @@ def ingestion_body(
149
151
  request = IngestionRequest(
150
152
  raw=(pieces[0] if pieces else b"").decode("ascii"),
151
153
  envelope=envelope,
152
- workflow_id=workflow_id,
153
154
  priority=priority,
154
155
  wait_for_verdict=wait_for_verdict,
155
156
  timeout_ms=timeout_ms,
@@ -191,7 +192,6 @@ class SpotClient:
191
192
  raw: bytes,
192
193
  *,
193
194
  envelope: Envelope | None = None,
194
- workflow_id: str | None = None,
195
195
  priority: IngestionPriority = "normal",
196
196
  wait_for_verdict: bool = False,
197
197
  timeout_ms: int = 30_000,
@@ -211,7 +211,6 @@ class SpotClient:
211
211
  ingestion_body,
212
212
  raw,
213
213
  envelope=envelope,
214
- workflow_id=workflow_id,
215
214
  priority=priority,
216
215
  wait_for_verdict=wait_for_verdict,
217
216
  timeout_ms=timeout_ms,
@@ -225,6 +224,16 @@ class SpotClient:
225
224
  )
226
225
  return read_answer(IngestionResult, response, "POST", "/internal/ingest")
227
226
 
227
+ async def report_action(self, job_id: str, action: str, *, applied: bool) -> None:
228
+ """Tell SPOT what the retriever did with a message: ``action``, and whether
229
+ it was ``applied`` (in observe, it is not) (scope ``ingest``)."""
230
+ report = ActionReport(action=action, applied=applied)
231
+ await self._call(
232
+ "POST",
233
+ f"/internal/jobs/{quote(job_id, safe='')}/action",
234
+ content=json_body(report.model_dump(mode="json")),
235
+ )
236
+
228
237
  async def raw(self, email_id: UUID) -> bytes:
229
238
  """The stored message, as received (scope ``content:read``)."""
230
239
  response = await self._call("GET", f"/internal/emails/{email_id}/raw")
@@ -88,5 +88,5 @@ class RespondRequest(BaseModel):
88
88
  decided_by: str
89
89
  signals: list[AttributedSignal]
90
90
  mode: ResponseMode = Field(
91
- description="The workflow's response_mode; a responder acts only in enforce"
91
+ description="The job's mode (its connection's and its route's); a responder acts only in enforce"
92
92
  )
@@ -21,7 +21,7 @@ from __future__ import annotations
21
21
  import base64
22
22
  import binascii
23
23
  from enum import StrEnum
24
- from typing import Annotated, Literal
24
+ from typing import Annotated, Any, Literal
25
25
  from uuid import UUID
26
26
 
27
27
  from pydantic import (
@@ -30,10 +30,12 @@ from pydantic import (
30
30
  Field,
31
31
  PlainSerializer,
32
32
  WithJsonSchema,
33
+ model_validator,
33
34
  )
34
35
 
35
36
  from .email import Envelope
36
37
  from .verdict import Score, VerdictClass
38
+ from .workflow import ResponseMode
37
39
 
38
40
 
39
41
  def _decode_message(value: object) -> bytes:
@@ -78,9 +80,6 @@ class IngestionRequest(BaseModel):
78
80
 
79
81
  raw: Base64Message = Field(description="The RFC 5322 message")
80
82
  envelope: Envelope | None = None
81
- workflow_id: str | None = Field(
82
- default=None, description="None: the platform's default workflow"
83
- )
84
83
  priority: IngestionPriority = "normal"
85
84
  wait_for_verdict: bool = False
86
85
  timeout_ms: int = Field(
@@ -90,6 +89,17 @@ class IngestionRequest(BaseModel):
90
89
  description="The caller's budget when wait_for_verdict is set",
91
90
  )
92
91
 
92
+ @model_validator(mode="before")
93
+ @classmethod
94
+ def _refuse_workflow_id(cls, data: Any) -> Any:
95
+ """Refuse ``workflow_id`` rather than drop it as other unknown fields are:
96
+ a retriever that still names a workflow would otherwise be silently routed."""
97
+ if isinstance(data, dict) and "workflow_id" in data:
98
+ raise ValueError(
99
+ "workflow_id is no longer taken: SPOT's routes choose the workflow"
100
+ )
101
+ return data
102
+
93
103
 
94
104
  class IngestionResult(BaseModel):
95
105
  """The response of ``POST /internal/ingest`` for a stored message."""
@@ -106,3 +116,18 @@ class IngestionResult(BaseModel):
106
116
  error: str | None = Field(
107
117
  default=None, description="Why there is no verdict, when the status is timeout"
108
118
  )
119
+ mode: ResponseMode = Field(
120
+ default="observe",
121
+ description=(
122
+ "The job's mode: enforce only when its connection and its route both "
123
+ "are; a retriever applies its actions only in enforce"
124
+ ),
125
+ )
126
+
127
+
128
+ class ActionReport(BaseModel):
129
+ """The body of ``POST /internal/jobs/{job_id}/action``: what a retriever
130
+ did with a message (or, in observe, would have done)."""
131
+
132
+ action: str = Field(pattern=r"^[a-z][a-z0-9_]{0,31}$")
133
+ applied: bool
@@ -21,24 +21,33 @@ from ..email import Envelope
21
21
  from ..mime import PartNotFoundError, part_bytes
22
22
  from ..orchestrator import OrchestrationResult
23
23
  from ..retriever import (
24
+ ActionReport,
24
25
  IngestionPriority,
25
26
  IngestionRequest,
26
27
  IngestionResult,
27
28
  IngestionStatus,
28
29
  )
29
30
  from ..verdict import VerdictClass
31
+ from ..workflow import ResponseMode
30
32
 
31
33
 
32
34
  class FakeSpot:
33
35
  """In-memory gateway with the methods of ``SpotClient``."""
34
36
 
35
37
  def __init__(
36
- self, *, verdict: VerdictClass = VerdictClass.BENIGN, score: int = 0
38
+ self,
39
+ *,
40
+ verdict: VerdictClass = VerdictClass.BENIGN,
41
+ score: int = 0,
42
+ mode: ResponseMode = "observe",
37
43
  ) -> None:
38
- """``verdict`` and ``score`` answer an ingest that waits for the verdict."""
44
+ """``verdict`` and ``score`` answer an ingest that waits for the verdict;
45
+ ``mode`` is the mode of every job."""
39
46
  self.verdict = verdict
40
47
  self.score = score
48
+ self.mode = mode
41
49
  self.ingested: list[IngestionRequest] = []
50
+ self.actions: list[tuple[str, ActionReport]] = []
42
51
  self._emails: dict[UUID, bytes] = {}
43
52
  self._results: dict[str, OrchestrationResult] = {}
44
53
 
@@ -57,7 +66,6 @@ class FakeSpot:
57
66
  raw: bytes,
58
67
  *,
59
68
  envelope: Envelope | None = None,
60
- workflow_id: str | None = None,
61
69
  priority: IngestionPriority = "normal",
62
70
  wait_for_verdict: bool = False,
63
71
  timeout_ms: int = 30_000,
@@ -65,7 +73,6 @@ class FakeSpot:
65
73
  request = ingestion_request(
66
74
  raw,
67
75
  envelope=envelope,
68
- workflow_id=workflow_id,
69
76
  priority=priority,
70
77
  wait_for_verdict=wait_for_verdict,
71
78
  timeout_ms=timeout_ms,
@@ -75,7 +82,10 @@ class FakeSpot:
75
82
  email_id = self.add_email(request.raw)
76
83
  if not wait_for_verdict:
77
84
  return IngestionResult(
78
- job_id=job_id, email_id=email_id, status=IngestionStatus.ACCEPTED
85
+ job_id=job_id,
86
+ email_id=email_id,
87
+ status=IngestionStatus.ACCEPTED,
88
+ mode=self.mode,
79
89
  )
80
90
  return IngestionResult(
81
91
  job_id=job_id,
@@ -83,8 +93,12 @@ class FakeSpot:
83
93
  status=IngestionStatus.ANALYZED,
84
94
  verdict=self.verdict,
85
95
  score=self.score,
96
+ mode=self.mode,
86
97
  )
87
98
 
99
+ async def report_action(self, job_id: str, action: str, *, applied: bool) -> None:
100
+ self.actions.append((job_id, ActionReport(action=action, applied=applied)))
101
+
88
102
  async def raw(self, email_id: UUID) -> bytes:
89
103
  return self._stored(email_id, f"/internal/emails/{email_id}/raw")
90
104
 
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, model_validator
10
10
  from .verdict import VerdictClass
11
11
 
12
12
  ResponseMode = Literal["observe", "enforce"]
13
- """Whether responders act on verdicts (``enforce``) or only record them (``observe``)."""
13
+ """Whether responders and retrievers act on verdicts (``enforce``) or only record them (``observe``): the mode of a job, from its connection and its route."""
14
14
 
15
15
  DECISIVE_SIGNAL_PATTERN = r"^[a-z0-9_]+(\.[a-z0-9_]+)*\.([a-z0-9_]+|\*)$"
16
16
  """A signal name, or a prefix ending in ``.*``."""
@@ -181,10 +181,6 @@ class Workflow(BaseModel):
181
181
  on_verdict: dict[VerdictClass, list[str]] = Field(
182
182
  default_factory=dict, description="Responder ids to call, by verdict"
183
183
  )
184
- response_mode: ResponseMode = Field(
185
- default="observe",
186
- description="Sent to responders as mode; a responder acts only in enforce",
187
- )
188
184
 
189
185
  @model_validator(mode="after")
190
186
  def _decision_stages_exist(self) -> Self: