spot-sdk-python 2.1.0b3__tar.gz → 2.2.0b1__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 (43) hide show
  1. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/PKG-INFO +1 -1
  2. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/pyproject.toml +1 -1
  3. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/pyproject.toml.orig +1 -1
  4. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/__init__.py +3 -2
  5. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/clients.py +31 -1
  6. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/hosting.py +13 -6
  7. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/mime.py +12 -0
  8. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/testing/README.md +1 -1
  9. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/testing/fake_spot.py +1 -1
  10. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/README.md +0 -0
  11. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/_json.py +0 -0
  12. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/app.py +0 -0
  13. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/artifacts.py +0 -0
  14. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/config.py +0 -0
  15. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/correlation.py +0 -0
  16. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/email.py +0 -0
  17. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/errors.py +0 -0
  18. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/knowledge.py +0 -0
  19. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/knowledge_tags.py +0 -0
  20. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/llm.py +0 -0
  21. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/logging.py +0 -0
  22. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/manifest.py +0 -0
  23. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/model_input.py +0 -0
  24. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/orchestrator.py +0 -0
  25. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/proxy.py +0 -0
  26. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/py.typed +0 -0
  27. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/retriever.py +0 -0
  28. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/settings.py +0 -0
  29. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/signals.py +0 -0
  30. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/testing/__init__.py +0 -0
  31. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/testing/container.py +0 -0
  32. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/testing/contract.py +0 -0
  33. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/testing/corpus.py +0 -0
  34. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/testing/engine.py +0 -0
  35. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/testing/fake_knowledge_client.py +0 -0
  36. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/testing/fake_llm.py +0 -0
  37. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/testing/metrics.py +0 -0
  38. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/testing/report.py +0 -0
  39. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/testing/settings.py +0 -0
  40. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/testing/views.py +0 -0
  41. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/urls.py +0 -0
  42. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/spot_sdk/verdict.py +0 -0
  43. {spot_sdk_python-2.1.0b3 → spot_sdk_python-2.2.0b1}/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.1.0b3
3
+ Version: 2.2.0b1
4
4
  Summary: SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers
5
5
  Author: SPOT Project
6
6
  Author-email: SPOT Project <spot@sonn.lu>
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "spot-sdk-python"
7
- version = "2.1.0b3"
7
+ version = "2.2.0b1"
8
8
  description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "spot-sdk-python"
7
- version = "2.1.0b3"
7
+ version = "2.2.0b1"
8
8
  description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -44,7 +44,7 @@ from .workflow import (
44
44
 
45
45
  if TYPE_CHECKING:
46
46
  from .app import CorrelationMiddleware, PluginHealth, SyncState, create_plugin_app
47
- from .clients import SpotAPIError, SpotClient
47
+ from .clients import SpotAPIError, SpotClient, SpotGateway
48
48
  from .knowledge import KnowledgeClient, KnowledgeDocument, chunk_text, content_hash
49
49
  from .llm import LLMClient, LLMOutputError, LLMToolCall, LLMToolCalls, LLMToolRound
50
50
 
@@ -55,7 +55,7 @@ _LAZY = {
55
55
  ("CorrelationMiddleware", "PluginHealth", "SyncState", "create_plugin_app"),
56
56
  ".app",
57
57
  ),
58
- **dict.fromkeys(("SpotAPIError", "SpotClient"), ".clients"),
58
+ **dict.fromkeys(("SpotAPIError", "SpotClient", "SpotGateway"), ".clients"),
59
59
  **dict.fromkeys(
60
60
  ("KnowledgeClient", "KnowledgeDocument", "chunk_text", "content_hash"),
61
61
  ".knowledge",
@@ -142,6 +142,7 @@ __all__ = [
142
142
  "LLMToolRound",
143
143
  "SpotAPIError",
144
144
  "SpotClient",
145
+ "SpotGateway",
145
146
  # Knowledge Store
146
147
  "KnowledgeClient",
147
148
  "KnowledgeDocument",
@@ -10,6 +10,7 @@ the route and the status, never the content.
10
10
  - Analyzers fetch the raw message or one decoded part with
11
11
  :meth:`SpotClient.raw` and :meth:`SpotClient.part`.
12
12
  - Responders read a workflow's result with :meth:`SpotClient.result`.
13
+ - :class:`SpotGateway` types a plugin's client parameter, so a test passes a fake.
13
14
  """
14
15
 
15
16
  from __future__ import annotations
@@ -17,7 +18,7 @@ from __future__ import annotations
17
18
  import asyncio
18
19
  import base64
19
20
  import json
20
- from typing import Any, TypeVar
21
+ from typing import Any, Protocol, TypeVar
21
22
  from urllib.parse import quote
22
23
  from uuid import UUID
23
24
 
@@ -155,6 +156,35 @@ def ingestion_body(
155
156
  return b"".join([b'{"raw": "', *pieces, b'", ', others[1:]])
156
157
 
157
158
 
159
+ class SpotGateway(Protocol):
160
+ """The gateway's plugin routes, as a plugin calls them.
161
+
162
+ A plugin types its parameter with it. ``SpotClient`` calls the gateway over
163
+ HTTP, and ``spot_sdk.testing.FakeSpot`` holds it in memory, so a test passes
164
+ a ``FakeSpot`` with no cast.
165
+ """
166
+
167
+ async def ingest(
168
+ self,
169
+ raw: bytes,
170
+ *,
171
+ envelope: Envelope | None = None,
172
+ priority: IngestionPriority = "normal",
173
+ wait_for_verdict: bool = False,
174
+ timeout_ms: int = 30_000,
175
+ ) -> IngestionResult: ...
176
+
177
+ async def report_action(
178
+ self, job_id: str, action: str, *, applied: bool
179
+ ) -> None: ...
180
+
181
+ async def raw(self, email_id: UUID) -> bytes: ...
182
+
183
+ async def part(self, email_id: UUID, part_id: str) -> tuple[bytes, str]: ...
184
+
185
+ async def result(self, job_id: str) -> OrchestrationResult: ...
186
+
187
+
158
188
  class SpotClient:
159
189
  """The api-gateway's plugin routes, called with the plugin's own token."""
160
190
 
@@ -44,7 +44,11 @@ could expand past the bound once decoded."""
44
44
 
45
45
 
46
46
  class AnswerRefusedError(Exception):
47
- """A plugin's answer that is not read: encoded, or longer than ``MAX_ANSWER_BYTES``."""
47
+ """A plugin's answer that is not read: encoded, or longer than its bound."""
48
+
49
+
50
+ class AnswerTooLargeError(AnswerRefusedError):
51
+ """An answer longer than its bound, which a caller may treat apart from an encoded one."""
48
52
 
49
53
 
50
54
  class InvalidResponseError(Exception):
@@ -121,11 +125,14 @@ def secrets_archive(secrets: Mapping[str, str]) -> bytes:
121
125
  return buffer.getvalue()
122
126
 
123
127
 
124
- async def read_answer(response: httpx2.Response) -> bytes:
125
- """A streamed answer's body, as sent, read up to ``MAX_ANSWER_BYTES``.
128
+ async def read_answer(
129
+ response: httpx2.Response, max_bytes: int = MAX_ANSWER_BYTES
130
+ ) -> bytes:
131
+ """A streamed answer's body, as sent, read up to ``max_bytes``.
126
132
 
127
133
  Raises:
128
- AnswerRefusedError: the answer is encoded, or longer than ``MAX_ANSWER_BYTES``
134
+ AnswerRefusedError: the answer is encoded
135
+ AnswerTooLargeError: the answer is longer than ``max_bytes``
129
136
  """
130
137
  if (
131
138
  response.headers.get("content-encoding", "identity").strip().lower()
@@ -135,8 +142,8 @@ async def read_answer(response: httpx2.Response) -> bytes:
135
142
  body = bytearray()
136
143
  async for chunk in response.aiter_raw():
137
144
  body += chunk
138
- if len(body) > MAX_ANSWER_BYTES:
139
- raise AnswerRefusedError("response too large")
145
+ if len(body) > max_bytes:
146
+ raise AnswerTooLargeError(f"response too large: over {max_bytes} bytes")
140
147
  return bytes(body)
141
148
 
142
149
 
@@ -1772,6 +1772,18 @@ def _addresses(value: str, cut: _Cutter, punycode: _Punycode) -> Iterator[Addres
1772
1772
  )
1773
1773
 
1774
1774
 
1775
+ def mailbox(value: str) -> Address | None:
1776
+ """The first mailbox of an address header's ``value``, as :func:`parse` reads
1777
+ a From into ``EmailView.from_``.
1778
+
1779
+ ``value`` is a header's value as the view keeps it (unfolded, RFC 2047
1780
+ decoded). A value that does not parse is kept whole, with no domain; an
1781
+ empty one is None. Its strings are cut to the view's budgets, its A-labels
1782
+ decoded within one message's budget, as :func:`parse` does.
1783
+ """
1784
+ return next(_addresses(value, _Cutter(), _Punycode()), None)
1785
+
1786
+
1775
1787
  def _date(value: str) -> datetime | None:
1776
1788
  try:
1777
1789
  return parsedate_to_datetime(value)
@@ -11,7 +11,7 @@ shows them in use.
11
11
  | `assert_contract(app, eml, *, token, timeout=10.0)` | waits until the plugin has started (`/health` is `healthy`), runs the app's `/internal/analyze` on a parsed `.eml` as core does, and checks the token and the `AnalyzerVerdict` |
12
12
  | `assert_bounds_agree(manifest, settings_cls)` | checks that the manifest states the bounds (`ge`, `le`, `pattern`...) of the settings model |
13
13
  | `assert_provider_contract(app, knowledge, *, token)` | runs two syncs of a context provider as core does, and checks the token, `/internal/sync`, `/internal/state` and the documents written to `knowledge` |
14
- | `FakeSpot` | `SpotClient` in memory: `ingest`, `raw`, `part`, `result`; `app(token=, knowledge=)` serves it over HTTP |
14
+ | `FakeSpot` | `SpotClient` in memory: `ingest`, `report_action`, `raw`, `part`, `result`; `app(token=, knowledge=)` serves it over HTTP |
15
15
  | `FakeKnowledgeClient` | `KnowledgeClient` in memory, with naive word matching |
16
16
  | `FakeLLM` | `LLMClient` with scripted answers, recording each request |
17
17
 
@@ -58,7 +58,7 @@ class _Query(BaseModel):
58
58
 
59
59
 
60
60
  class FakeSpot:
61
- """In-memory gateway with the methods of ``SpotClient``."""
61
+ """In-memory gateway with the methods of ``SpotClient``: a ``SpotGateway``."""
62
62
 
63
63
  def __init__(
64
64
  self,