spot-sdk-python 1.2.0__tar.gz → 2.0.0__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 (60) hide show
  1. spot_sdk_python-2.0.0/PKG-INFO +117 -0
  2. spot_sdk_python-2.0.0/README.md +93 -0
  3. spot_sdk_python-2.0.0/pyproject.toml +52 -0
  4. spot_sdk_python-2.0.0/pyproject.toml.orig +43 -0
  5. spot_sdk_python-2.0.0/spot_sdk/__init__.py +161 -0
  6. spot_sdk_python-2.0.0/spot_sdk/_json.py +46 -0
  7. spot_sdk_python-2.0.0/spot_sdk/app.py +669 -0
  8. spot_sdk_python-2.0.0/spot_sdk/artifacts.py +205 -0
  9. spot_sdk_python-2.0.0/spot_sdk/clients.py +267 -0
  10. {spot_sdk_python-1.2.0 → spot_sdk_python-2.0.0}/spot_sdk/config.py +0 -8
  11. spot_sdk_python-2.0.0/spot_sdk/correlation.py +65 -0
  12. spot_sdk_python-2.0.0/spot_sdk/email.py +185 -0
  13. {spot_sdk_python-1.2.0 → spot_sdk_python-2.0.0}/spot_sdk/knowledge.py +133 -120
  14. spot_sdk_python-2.0.0/spot_sdk/llm.py +514 -0
  15. spot_sdk_python-2.0.0/spot_sdk/logging.py +284 -0
  16. spot_sdk_python-2.0.0/spot_sdk/manifest.py +281 -0
  17. spot_sdk_python-2.0.0/spot_sdk/mime.py +2754 -0
  18. spot_sdk_python-2.0.0/spot_sdk/model_input.py +190 -0
  19. spot_sdk_python-2.0.0/spot_sdk/orchestrator.py +92 -0
  20. spot_sdk_python-2.0.0/spot_sdk/proxy.py +94 -0
  21. spot_sdk_python-2.0.0/spot_sdk/py.typed +0 -0
  22. spot_sdk_python-2.0.0/spot_sdk/retriever.py +133 -0
  23. spot_sdk_python-2.0.0/spot_sdk/settings.py +79 -0
  24. spot_sdk_python-2.0.0/spot_sdk/signals.py +42 -0
  25. spot_sdk_python-2.0.0/spot_sdk/testing/README.md +31 -0
  26. spot_sdk_python-2.0.0/spot_sdk/testing/__init__.py +20 -0
  27. spot_sdk_python-2.0.0/spot_sdk/testing/contract.py +219 -0
  28. spot_sdk_python-2.0.0/spot_sdk/testing/fake_knowledge_client.py +159 -0
  29. spot_sdk_python-2.0.0/spot_sdk/testing/fake_llm.py +70 -0
  30. spot_sdk_python-2.0.0/spot_sdk/testing/fake_spot.py +122 -0
  31. spot_sdk_python-2.0.0/spot_sdk/testing/settings.py +128 -0
  32. spot_sdk_python-2.0.0/spot_sdk/testing/views.py +47 -0
  33. spot_sdk_python-2.0.0/spot_sdk/urls.py +36 -0
  34. spot_sdk_python-2.0.0/spot_sdk/verdict.py +127 -0
  35. {spot_sdk_python-1.2.0 → spot_sdk_python-2.0.0}/spot_sdk/workflow.py +83 -23
  36. spot_sdk_python-1.2.0/PKG-INFO +0 -353
  37. spot_sdk_python-1.2.0/README.md +0 -331
  38. spot_sdk_python-1.2.0/pyproject.toml +0 -32
  39. spot_sdk_python-1.2.0/spot_sdk/__init__.py +0 -99
  40. spot_sdk_python-1.2.0/spot_sdk/analysis_context.py +0 -99
  41. spot_sdk_python-1.2.0/spot_sdk/analyzer.py +0 -106
  42. spot_sdk_python-1.2.0/spot_sdk/analyzer_base.py +0 -316
  43. spot_sdk_python-1.2.0/spot_sdk/api_gateway.py +0 -271
  44. spot_sdk_python-1.2.0/spot_sdk/config_client.py +0 -203
  45. spot_sdk_python-1.2.0/spot_sdk/config_helpers.py +0 -25
  46. spot_sdk_python-1.2.0/spot_sdk/email.py +0 -136
  47. spot_sdk_python-1.2.0/spot_sdk/logging.py +0 -133
  48. spot_sdk_python-1.2.0/spot_sdk/ollama.py +0 -58
  49. spot_sdk_python-1.2.0/spot_sdk/orchestrator.py +0 -79
  50. spot_sdk_python-1.2.0/spot_sdk/plugin.py +0 -35
  51. spot_sdk_python-1.2.0/spot_sdk/results.py +0 -129
  52. spot_sdk_python-1.2.0/spot_sdk/retriever.py +0 -267
  53. spot_sdk_python-1.2.0/spot_sdk/settings_schema.py +0 -56
  54. spot_sdk_python-1.2.0/spot_sdk/testing/README.md +0 -83
  55. spot_sdk_python-1.2.0/spot_sdk/testing/__init__.py +0 -22
  56. spot_sdk_python-1.2.0/spot_sdk/testing/factories.py +0 -177
  57. spot_sdk_python-1.2.0/spot_sdk/testing/fake_knowledge_client.py +0 -105
  58. spot_sdk_python-1.2.0/spot_sdk/threat_levels.py +0 -33
  59. {spot_sdk_python-1.2.0 → spot_sdk_python-2.0.0}/spot_sdk/errors.py +0 -0
  60. {spot_sdk_python-1.2.0 → spot_sdk_python-2.0.0}/spot_sdk/knowledge_tags.py +0 -0
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: spot-sdk-python
3
+ Version: 2.0.0
4
+ Summary: SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers
5
+ Author: SPOT Project
6
+ Author-email: SPOT Project <spot@sonn.lu>
7
+ License-Expression: Apache-2.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Programming Language :: Python :: 3.15
14
+ Requires-Dist: pydantic>=2.10,<3
15
+ Requires-Dist: pydantic-settings>=2.0.0,<3
16
+ Requires-Dist: httpx2>=2.13.1,<3
17
+ Requires-Dist: selectolax>=1.0,<1.1
18
+ Requires-Dist: idna>=3.11,<4
19
+ Requires-Dist: fastapi>=0.133.0,<1
20
+ Requires-Dist: starlette>=1.7.0,<2
21
+ Requires-Python: >=3.11, <3.16
22
+ Project-URL: Homepage, https://sonn.lu
23
+ Description-Content-Type: text/markdown
24
+
25
+ # spot-sdk-python
26
+
27
+ The SDK for writing plugins of SPOT, the Spear-Phishing Overwatching
28
+ Tool: analyzers, context providers, mail retrievers and responders.
29
+
30
+ SPOT runs each plugin as a container and calls its `/internal/*` routes
31
+ with the plugin's own token. This package holds what SPOT's core and its
32
+ plugins share:
33
+
34
+ - **the contract** (Pydantic models): the email view, verdicts and
35
+ signals, workflow policies, results, and the plugin manifest with its
36
+ OCI labels;
37
+ - **the parser** core runs on every message at intake
38
+ (`spot_sdk.mime.parse`), bounded against hostile input, which records
39
+ what mail clients may read differently as anomalies;
40
+ - **the plugin runtime**: `create_plugin_app` builds the FastAPI app with
41
+ the routes core calls, token-checked; `PluginSettings` reads the token
42
+ and secrets from `/run/secrets` and the settings from the environment;
43
+ - **the clients**: `SpotClient` (ingest, message content, results),
44
+ `KnowledgeClient` (the Knowledge Store) and `LLMClient`
45
+ (OpenAI-compatible chat models, with the email kept apart from the
46
+ instructions as untrusted data);
47
+ - **test helpers**: `view_from_eml`, `make_view`, `FakeSpot`,
48
+ `FakeKnowledgeClient`, `FakeLLM` and `assert_contract`.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pip install spot-sdk-python==2.0.0
54
+ ```
55
+
56
+ Python 3.11 to 3.15. Version 2 implements plugin contract 2, for
57
+ SPOT 2.0.
58
+
59
+ ## An analyzer
60
+
61
+ ```python
62
+ from spot_sdk import (
63
+ AnalyzerVerdict,
64
+ AnalyzeRequest,
65
+ Egress,
66
+ Manifest,
67
+ PluginKind,
68
+ PluginSettings,
69
+ Signal,
70
+ VerdictClass,
71
+ create_plugin_app,
72
+ )
73
+ from spot_sdk.signals import AUTH_DMARC_FAIL
74
+
75
+ MANIFEST = Manifest(
76
+ id="analyzer-dmarc",
77
+ version="1.0.0",
78
+ kind=PluginKind.ANALYZER,
79
+ egress=Egress.NONE,
80
+ )
81
+
82
+
83
+ def analyze(request: AnalyzeRequest, settings: PluginSettings) -> AnalyzerVerdict:
84
+ failed = [
85
+ result
86
+ for result in request.email.authentication
87
+ if result.method == "dmarc" and result.result == "fail"
88
+ ]
89
+ if not failed:
90
+ return AnalyzerVerdict(verdict=VerdictClass.BENIGN, score=10, confidence=60)
91
+ return AnalyzerVerdict(
92
+ verdict=VerdictClass.SUSPICIOUS,
93
+ score=70,
94
+ confidence=80,
95
+ signals=[Signal(name=AUTH_DMARC_FAIL, weight=60, evidence="dmarc=fail")],
96
+ )
97
+
98
+
99
+ app = create_plugin_app(MANIFEST, PluginSettings, analyze=analyze)
100
+ ```
101
+
102
+ Serve it with `uvicorn analyzer_dmarc:app --host 0.0.0.0 --port 8000`,
103
+ and test it with `spot_sdk.testing.assert_contract(app, "tests/phish.eml",
104
+ token=...)`.
105
+
106
+ ## Documentation
107
+
108
+ - [Documentation](https://spot-project.codeberg.page/documentation/sdk/):
109
+ the contract, writing each kind of plugin, the reference.
110
+ - [Migrating from 1.x](https://spot-project.codeberg.page/documentation/sdk/MIGRATION-2/):
111
+ every removed name and its replacement. SDK 2 has no compatibility
112
+ with contract 1.
113
+ - [Source and changelog](https://codeberg.org/SPOT_Project/sdk).
114
+
115
+ ## License
116
+
117
+ Apache License 2.0.
@@ -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.0
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
+ kind=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.
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.22,<0.13"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "spot-sdk-python"
7
+ version = "2.0.0"
8
+ description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.11,<3.16"
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Programming Language :: Python :: 3.15",
19
+ ]
20
+ dependencies = [
21
+ "pydantic>=2.10,<3",
22
+ "pydantic-settings>=2.0.0,<3",
23
+ "httpx2>=2.13.1,<3",
24
+ "selectolax>=1.0,<1.1",
25
+ "idna>=3.11,<4",
26
+ "fastapi>=0.133.0,<1",
27
+ "starlette>=1.7.0,<2",
28
+ ]
29
+
30
+ [[project.authors]]
31
+ name = "SPOT Project"
32
+ email = "spot@sonn.lu"
33
+
34
+ [project.urls]
35
+ Homepage = "https://sonn.lu"
36
+
37
+ [tool.uv.build-backend]
38
+ module-name = "spot_sdk"
39
+ module-root = ""
40
+
41
+ [tool.ruff]
42
+ target-version = "py311"
43
+
44
+ [tool.ruff.lint]
45
+ select = [
46
+ "E",
47
+ "F",
48
+ "I",
49
+ "N",
50
+ "W",
51
+ ]
52
+ ignore = ["E501"]
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.22,<0.13"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "spot-sdk-python"
7
+ version = "2.0.0"
8
+ description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ authors = [{ name = "SPOT Project", email = "spot@sonn.lu" }]
12
+ requires-python = ">=3.11,<3.16"
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Programming Language :: Python :: 3.15",
20
+ ]
21
+ dependencies = [
22
+ "pydantic>=2.10,<3",
23
+ "pydantic-settings>=2.0.0,<3",
24
+ "httpx2>=2.13.1,<3",
25
+ "selectolax>=1.0,<1.1",
26
+ "idna>=3.11,<4",
27
+ "fastapi>=0.133.0,<1",
28
+ "starlette>=1.7.0,<2",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://sonn.lu"
33
+
34
+ [tool.uv.build-backend]
35
+ module-name = "spot_sdk"
36
+ module-root = ""
37
+
38
+ [tool.ruff]
39
+ target-version = "py311"
40
+
41
+ [tool.ruff.lint]
42
+ select = ["E", "F", "I", "N", "W"]
43
+ ignore = ["E501"] # Line too long
@@ -0,0 +1,161 @@
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 .proxy import PROXY_SECRET, apply_proxy_credentials
29
+ from .retriever import ActionReport, IngestionRequest, IngestionResult, IngestionStatus
30
+ from .settings import PluginSettings
31
+ from .verdict import AnalyzerVerdict, Score, Signal, VerdictClass
32
+ from .workflow import (
33
+ AggregationMethod,
34
+ AnalyzerConfig,
35
+ DecisiveSignal,
36
+ FailureStrategy,
37
+ ResponseMode,
38
+ RetrievalLimits,
39
+ RetryConfig,
40
+ VerdictPolicy,
41
+ Workflow,
42
+ WorkflowStage,
43
+ )
44
+
45
+ if TYPE_CHECKING:
46
+ from .app import CorrelationMiddleware, PluginHealth, SyncState, create_plugin_app
47
+ from .clients import SpotAPIError, SpotClient
48
+ from .knowledge import KnowledgeClient, KnowledgeDocument, chunk_text, content_hash
49
+ from .llm import LLMClient, LLMOutputError, LLMToolCall, LLMToolCalls, LLMToolRound
50
+
51
+ # The modules that import FastAPI, Starlette or httpx2 load on first use, so importing
52
+ # `spot_sdk.mime` or the models stays light (PEP 562).
53
+ _LAZY = {
54
+ **dict.fromkeys(
55
+ ("CorrelationMiddleware", "PluginHealth", "SyncState", "create_plugin_app"),
56
+ ".app",
57
+ ),
58
+ **dict.fromkeys(("SpotAPIError", "SpotClient"), ".clients"),
59
+ **dict.fromkeys(
60
+ ("KnowledgeClient", "KnowledgeDocument", "chunk_text", "content_hash"),
61
+ ".knowledge",
62
+ ),
63
+ **dict.fromkeys(
64
+ ("LLMClient", "LLMOutputError", "LLMToolCall", "LLMToolCalls", "LLMToolRound"),
65
+ ".llm",
66
+ ),
67
+ }
68
+
69
+
70
+ def __getattr__(name: str) -> Any:
71
+ module = _LAZY.get(name)
72
+ if module is None:
73
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
74
+ value = getattr(import_module(module, __name__), name)
75
+ globals()[name] = value
76
+ return value
77
+
78
+
79
+ def __dir__() -> list[str]:
80
+ return sorted({*globals(), *_LAZY})
81
+
82
+
83
+ __all__ = [
84
+ # Email view
85
+ "Address",
86
+ "Anomaly",
87
+ "AuthResult",
88
+ "EmailView",
89
+ "Envelope",
90
+ "Header",
91
+ "Part",
92
+ "Url",
93
+ # Verdicts
94
+ "AnalyzerVerdict",
95
+ "Score",
96
+ "Signal",
97
+ "VerdictClass",
98
+ # Workflow
99
+ "AggregationMethod",
100
+ "AnalyzerConfig",
101
+ "DecisiveSignal",
102
+ "FailureStrategy",
103
+ "ResponseMode",
104
+ "RetrievalLimits",
105
+ "RetryConfig",
106
+ "VerdictPolicy",
107
+ "Workflow",
108
+ "WorkflowStage",
109
+ # Analyzer and responder calls, results
110
+ "AnalyzeRequest",
111
+ "AnalyzerOutcome",
112
+ "AttributedSignal",
113
+ "OrchestrationResult",
114
+ "RespondRequest",
115
+ "StageOutcome",
116
+ # Ingestion
117
+ "ActionReport",
118
+ "IngestionRequest",
119
+ "IngestionResult",
120
+ "IngestionStatus",
121
+ # Plugin manifest
122
+ "CONTRACT_VERSION",
123
+ "Egress",
124
+ "Manifest",
125
+ "PluginKind",
126
+ "Resources",
127
+ "SettingField",
128
+ "SettingType",
129
+ # Plugin runtime
130
+ "CorrelationMiddleware",
131
+ "PROXY_SECRET",
132
+ "PluginHealth",
133
+ "PluginSettings",
134
+ "SyncState",
135
+ "apply_proxy_credentials",
136
+ "create_plugin_app",
137
+ # Clients
138
+ "LLMClient",
139
+ "LLMOutputError",
140
+ "LLMToolCall",
141
+ "LLMToolCalls",
142
+ "LLMToolRound",
143
+ "SpotAPIError",
144
+ "SpotClient",
145
+ # Knowledge Store
146
+ "KnowledgeClient",
147
+ "KnowledgeDocument",
148
+ "KnowledgeTag",
149
+ "chunk_text",
150
+ "content_hash",
151
+ # Config models
152
+ "ConfigOption",
153
+ "ConfigStatus",
154
+ "ConfigUpdateRequest",
155
+ # Error response
156
+ "ErrorResponse",
157
+ # Logging
158
+ "configure_logging",
159
+ "get_logger",
160
+ "get_logging_config",
161
+ ]
@@ -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)