monosmith-plugin-sdk 0.1.0__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.
Files changed (91) hide show
  1. monosmith_plugin_sdk/__init__.py +204 -0
  2. monosmith_plugin_sdk/build_dir/__init__.py +5 -0
  3. monosmith_plugin_sdk/build_dir/build_dir_algorithm.py +30 -0
  4. monosmith_plugin_sdk/cancellation/__init__.py +6 -0
  5. monosmith_plugin_sdk/cancellation/cancel_token_model.py +75 -0
  6. monosmith_plugin_sdk/cancellation/request_cancelled_error.py +11 -0
  7. monosmith_plugin_sdk/ci/__init__.py +5 -0
  8. monosmith_plugin_sdk/ci/ci_contribution_algorithm.py +57 -0
  9. monosmith_plugin_sdk/codec/__init__.py +34 -0
  10. monosmith_plugin_sdk/codec/encode_by_schema_algorithm.py +126 -0
  11. monosmith_plugin_sdk/codec/encode_error.py +11 -0
  12. monosmith_plugin_sdk/codec/encode_json_value_algorithm.py +81 -0
  13. monosmith_plugin_sdk/codec/message_parse_error.py +10 -0
  14. monosmith_plugin_sdk/codec/method_codec_algorithm.py +53 -0
  15. monosmith_plugin_sdk/codec/normalize_by_schema_algorithm.py +114 -0
  16. monosmith_plugin_sdk/codec/params_error.py +10 -0
  17. monosmith_plugin_sdk/codec/received_message_contract.py +38 -0
  18. monosmith_plugin_sdk/codec/schema_lookup_algorithm.py +62 -0
  19. monosmith_plugin_sdk/codec/wire_message_mapper.py +90 -0
  20. monosmith_plugin_sdk/concurrency/__init__.py +21 -0
  21. monosmith_plugin_sdk/concurrency/concurrency_policy.py +44 -0
  22. monosmith_plugin_sdk/conformance/__init__.py +7 -0
  23. monosmith_plugin_sdk/conformance/check_generation_use_case.py +128 -0
  24. monosmith_plugin_sdk/conformance/check_plugin_use_case.py +131 -0
  25. monosmith_plugin_sdk/conformance/check_scheduling_use_case.py +141 -0
  26. monosmith_plugin_sdk/conformance/conformance_report_model.py +53 -0
  27. monosmith_plugin_sdk/conformance/find_manifests_algorithm.py +38 -0
  28. monosmith_plugin_sdk/conformance/go_list_mapper.py +17 -0
  29. monosmith_plugin_sdk/framing/__init__.py +7 -0
  30. monosmith_plugin_sdk/framing/frame_message_algorithm.py +12 -0
  31. monosmith_plugin_sdk/framing/frame_reader_store.py +64 -0
  32. monosmith_plugin_sdk/framing/framing_error.py +11 -0
  33. monosmith_plugin_sdk/host/__init__.py +29 -0
  34. monosmith_plugin_sdk/host/call_interrupted_error.py +30 -0
  35. monosmith_plugin_sdk/host/connect_plugin_use_case.py +103 -0
  36. monosmith_plugin_sdk/host/host_options_contract.py +47 -0
  37. monosmith_plugin_sdk/host/plugin_client_client.py +358 -0
  38. monosmith_plugin_sdk/host/start_plugin_use_case.py +73 -0
  39. monosmith_plugin_sdk/jsondoc/__init__.py +6 -0
  40. monosmith_plugin_sdk/jsondoc/indent_raw_json_algorithm.py +51 -0
  41. monosmith_plugin_sdk/jsondoc/json_document_error.py +7 -0
  42. monosmith_plugin_sdk/jsondoc/json_document_model.py +129 -0
  43. monosmith_plugin_sdk/jsondoc/scan_json_object_algorithm.py +165 -0
  44. monosmith_plugin_sdk/plugin_refs/__init__.py +5 -0
  45. monosmith_plugin_sdk/plugin_refs/plugin_ref_policy.py +31 -0
  46. monosmith_plugin_sdk/plugin_roles/__init__.py +12 -0
  47. monosmith_plugin_sdk/plugin_roles/plugin_role_policy.py +51 -0
  48. monosmith_plugin_sdk/projects/__init__.py +19 -0
  49. monosmith_plugin_sdk/projects/audit_settings_policy.py +15 -0
  50. monosmith_plugin_sdk/projects/manifest_match_algorithm.py +46 -0
  51. monosmith_plugin_sdk/projects/project_kind_policy.py +25 -0
  52. monosmith_plugin_sdk/projects/publishes_policy.py +15 -0
  53. monosmith_plugin_sdk/projects/set_toolchain_algorithm.py +21 -0
  54. monosmith_plugin_sdk/projects/stack_plugins_algorithm.py +14 -0
  55. monosmith_plugin_sdk/protocol/__init__.py +8 -0
  56. monosmith_plugin_sdk/protocol/protocol_schema_contract.py +1991 -0
  57. monosmith_plugin_sdk/protocol/protocol_types_contract.py +1350 -0
  58. monosmith_plugin_sdk/protocol/rpc_error.py +22 -0
  59. monosmith_plugin_sdk/py.typed +0 -0
  60. monosmith_plugin_sdk/scaffold/__init__.py +6 -0
  61. monosmith_plugin_sdk/scaffold/scaffold_data_mapper.py +30 -0
  62. monosmith_plugin_sdk/scaffold/scaffold_project_use_case.py +35 -0
  63. monosmith_plugin_sdk/serve/__init__.py +45 -0
  64. monosmith_plugin_sdk/serve/capabilities_of_algorithm.py +48 -0
  65. monosmith_plugin_sdk/serve/claim_stdio_use_case.py +36 -0
  66. monosmith_plugin_sdk/serve/dispatch_request_use_case.py +72 -0
  67. monosmith_plugin_sdk/serve/frame_writer_store.py +27 -0
  68. monosmith_plugin_sdk/serve/initialize_plugin_use_case.py +29 -0
  69. monosmith_plugin_sdk/serve/plugin_contract.py +135 -0
  70. monosmith_plugin_sdk/serve/plugin_members_config.py +35 -0
  71. monosmith_plugin_sdk/serve/protocol_streams_contract.py +35 -0
  72. monosmith_plugin_sdk/serve/request_outcome_contract.py +29 -0
  73. monosmith_plugin_sdk/serve/request_pool_store.py +154 -0
  74. monosmith_plugin_sdk/serve/serve_use_case.py +215 -0
  75. monosmith_plugin_sdk/tasks/__init__.py +6 -0
  76. monosmith_plugin_sdk/tasks/contribute_tasks_algorithm.py +28 -0
  77. monosmith_plugin_sdk/tasks/task_algorithm.py +71 -0
  78. monosmith_plugin_sdk/templating/__init__.py +8 -0
  79. monosmith_plugin_sdk/templating/file_exists_error.py +15 -0
  80. monosmith_plugin_sdk/templating/parse_template_algorithm.py +223 -0
  81. monosmith_plugin_sdk/templating/render_template_algorithm.py +143 -0
  82. monosmith_plugin_sdk/templating/template_error.py +13 -0
  83. monosmith_plugin_sdk/templating/template_functions_algorithm.py +177 -0
  84. monosmith_plugin_sdk/templating/template_syntax_contract.py +78 -0
  85. monosmith_plugin_sdk/templating/tokenize_action_algorithm.py +81 -0
  86. monosmith_plugin_sdk/templating/write_templates_use_case.py +47 -0
  87. monosmith_plugin_sdk-0.1.0.dist-info/METADATA +134 -0
  88. monosmith_plugin_sdk-0.1.0.dist-info/RECORD +91 -0
  89. monosmith_plugin_sdk-0.1.0.dist-info/WHEEL +5 -0
  90. monosmith_plugin_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. monosmith_plugin_sdk-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,204 @@
1
+ """The Python SDK for writing smith plugins.
2
+
3
+ A plugin is an object with `describe` and one method for each thing it does. `serve` frames the
4
+ JSON-RPC messages, answers `initialize`, derives the capabilities from the methods the object has and
5
+ keeps standard output for the protocol:
6
+
7
+ from monosmith_plugin_sdk import contribute_to, serve, task
8
+
9
+ class WidgetLint:
10
+ def describe(self, params):
11
+ return {"plugin": {"name": "widget-lint", "version": "0.1.0"}, "kind": "convention",
12
+ "roles": ["structure"], "languages": ["widget"]}
13
+
14
+ def contribute(self, params):
15
+ return contribute_to(params["projects"], lambda project: {
16
+ "tasks": {"layout-check": task(["python", "check.py"], cache=True)},
17
+ "dependsOn": {"lint": ["layout-check"]},
18
+ })
19
+
20
+ if __name__ == "__main__":
21
+ serve(WidgetLint())
22
+
23
+ Test it in-process, with the real handshake and framing, and run the checks of `smith plugin check`:
24
+
25
+ from monosmith_plugin_sdk import HostOptions, check_plugin, connect_in_process
26
+
27
+ with connect_in_process(WidgetLint(), HostOptions(workspace_root=str(tmp_path))) as client:
28
+ report = check_plugin(client)
29
+ assert report.ok, report.failures()
30
+
31
+ The messages of the protocol are `TypedDict`s generated from the protocol's JSON Schema; the wire
32
+ form is the one the Go and TypeScript SDKs write, byte for byte. No runtime dependencies.
33
+ """
34
+
35
+ from . import protocol as _protocol
36
+ from .build_dir import BUILD_DIR, build_dir_from_project, in_build_dir, project_build_dir
37
+ from .cancellation import CancelToken, RequestCancelledError
38
+ from .ci import action_step, ci_step, credential
39
+ from .codec import (
40
+ EncodeError,
41
+ MessageParseError,
42
+ ParamsError,
43
+ decode_params,
44
+ decode_result,
45
+ encode_params,
46
+ encode_result,
47
+ )
48
+ from .concurrency import (
49
+ CONCURRENCY_UNLIMITED,
50
+ UNLIMITED_IN_FLIGHT,
51
+ UNLIMITED_WORKERS,
52
+ in_flight_limit,
53
+ is_valid_concurrency,
54
+ worker_limit,
55
+ )
56
+ from .conformance import CONFORMANCE_STACK, CheckResult, ConformanceReport, check_plugin
57
+ from .framing import FrameReader, FramingError, frame_message
58
+ from .host import (
59
+ DEFAULT_CALL_TIMEOUT,
60
+ DEFAULT_CANCEL_GRACE,
61
+ CallInterruptedError,
62
+ HostOptions,
63
+ PluginClient,
64
+ PluginStreams,
65
+ StartOptions,
66
+ connect_in_process,
67
+ connect_plugin,
68
+ start_plugin,
69
+ )
70
+ from .jsondoc import JsonDocument, JsonDocumentError
71
+ from .plugin_refs import conflicts_with, describe_ref, is_empty_ref, requires_language
72
+ from .plugin_roles import (
73
+ ROLES,
74
+ default_roles,
75
+ effective_roles,
76
+ is_additive_role,
77
+ is_exclusive_role,
78
+ is_known_role,
79
+ )
80
+ from .projects import (
81
+ audit_blocks,
82
+ audit_enabled,
83
+ is_manifest,
84
+ project_kind_directory,
85
+ publishes,
86
+ resolve_project_kind,
87
+ set_toolchain,
88
+ stack_plugins,
89
+ )
90
+ from .protocol import * # noqa: F403
91
+ from .scaffold import scaffold_data, scaffold_project
92
+ from .serve import (
93
+ CIContributor,
94
+ Contributor,
95
+ DeclarationEditor,
96
+ DependencyManager,
97
+ Discoverer,
98
+ DoctorContributor,
99
+ Generator,
100
+ LatestReader,
101
+ Plugin,
102
+ Publisher,
103
+ RegistryReader,
104
+ Remover,
105
+ ServeStreams,
106
+ Versioner,
107
+ capabilities_of,
108
+ serve,
109
+ serve_streams,
110
+ )
111
+ from .tasks import build_task, contribute_to, task
112
+ from .templating import TemplateError, TemplateFileExistsError, render_template, write_templates
113
+
114
+ __all__ = [
115
+ *_protocol.__all__,
116
+ # Writing a plugin.
117
+ "serve",
118
+ "Plugin",
119
+ "Discoverer",
120
+ "Generator",
121
+ "Versioner",
122
+ "Publisher",
123
+ "RegistryReader",
124
+ "CIContributor",
125
+ "DoctorContributor",
126
+ "Remover",
127
+ "DependencyManager",
128
+ "DeclarationEditor",
129
+ "LatestReader",
130
+ "Contributor",
131
+ "CancelToken",
132
+ "RequestCancelledError",
133
+ "CONCURRENCY_UNLIMITED",
134
+ "task",
135
+ "build_task",
136
+ "contribute_to",
137
+ "scaffold_project",
138
+ "scaffold_data",
139
+ "action_step",
140
+ "ci_step",
141
+ "credential",
142
+ "BUILD_DIR",
143
+ "project_build_dir",
144
+ "build_dir_from_project",
145
+ "in_build_dir",
146
+ "ROLES",
147
+ "default_roles",
148
+ "effective_roles",
149
+ "is_known_role",
150
+ "is_exclusive_role",
151
+ "is_additive_role",
152
+ "requires_language",
153
+ "conflicts_with",
154
+ "describe_ref",
155
+ "is_empty_ref",
156
+ "audit_enabled",
157
+ "audit_blocks",
158
+ "is_manifest",
159
+ "project_kind_directory",
160
+ "publishes",
161
+ "resolve_project_kind",
162
+ "set_toolchain",
163
+ "stack_plugins",
164
+ "render_template",
165
+ "write_templates",
166
+ "TemplateError",
167
+ "TemplateFileExistsError",
168
+ "JsonDocument",
169
+ "JsonDocumentError",
170
+ # Testing a plugin.
171
+ "HostOptions",
172
+ "StartOptions",
173
+ "PluginClient",
174
+ "PluginStreams",
175
+ "CallInterruptedError",
176
+ "DEFAULT_CALL_TIMEOUT",
177
+ "DEFAULT_CANCEL_GRACE",
178
+ "start_plugin",
179
+ "connect_plugin",
180
+ "connect_in_process",
181
+ "check_plugin",
182
+ "ConformanceReport",
183
+ "CheckResult",
184
+ "CONFORMANCE_STACK",
185
+ # Building blocks: the wire form of the messages, for tools that speak the protocol themselves.
186
+ "serve_streams",
187
+ "ServeStreams",
188
+ "capabilities_of",
189
+ "decode_params",
190
+ "decode_result",
191
+ "encode_params",
192
+ "encode_result",
193
+ "EncodeError",
194
+ "MessageParseError",
195
+ "ParamsError",
196
+ "FrameReader",
197
+ "FramingError",
198
+ "frame_message",
199
+ "UNLIMITED_IN_FLIGHT",
200
+ "UNLIMITED_WORKERS",
201
+ "in_flight_limit",
202
+ "is_valid_concurrency",
203
+ "worker_limit",
204
+ ]
@@ -0,0 +1,5 @@
1
+ """The directory every build writes into."""
2
+
3
+ from .build_dir_algorithm import BUILD_DIR, build_dir_from_project, in_build_dir, project_build_dir
4
+
5
+ __all__ = ["BUILD_DIR", "build_dir_from_project", "in_build_dir", "project_build_dir"]
@@ -0,0 +1,30 @@
1
+ """Where every build writes its output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ BUILD_DIR = "dist"
6
+ """The directory at the workspace root every build writes into.
7
+
8
+ It is smith's opinion, so all output is in one place, outside every source tree, and cleaning is
9
+ deleting one folder. A plugin builds a project into `project_build_dir`; `smith doctor` fails a task
10
+ whose outputs fall outside it.
11
+ """
12
+
13
+
14
+ def project_build_dir(root: str) -> str:
15
+ """Where a project's build goes, workspace-relative: `dist/<project dir>` (`dist/apps/web`)."""
16
+ return f"{BUILD_DIR}/{root.strip('/')}"
17
+
18
+
19
+ def build_dir_from_project(root: str) -> str:
20
+ """The same directory relative to the project (`../../dist/apps/web`).
21
+
22
+ It is what a tool configured from inside the project, or a task's `outputs`, name.
23
+ """
24
+ trimmed = root.strip("/")
25
+ return "../" * len(trimmed.split("/")) + project_build_dir(trimmed)
26
+
27
+
28
+ def in_build_dir(path: str) -> bool:
29
+ """Whether a workspace-relative path is inside the build directory."""
30
+ return path == BUILD_DIR or path.startswith(f"{BUILD_DIR}/")
@@ -0,0 +1,6 @@
1
+ """Cooperative cancellation: the token a method watches, and the error it raises when it stops."""
2
+
3
+ from .cancel_token_model import CancelToken
4
+ from .request_cancelled_error import RequestCancelledError
5
+
6
+ __all__ = ["CancelToken", "RequestCancelledError"]
@@ -0,0 +1,75 @@
1
+ """The token that carries a cancel to the work it concerns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Callable
7
+
8
+ from .request_cancelled_error import RequestCancelledError
9
+
10
+
11
+ class CancelToken:
12
+ """A flag one thread sets and another watches, to stop work cooperatively.
13
+
14
+ The serve loop gives a method one for the request it is answering, and sets it when the
15
+ host sends `cancel` for the request (or when the plugin shuts down). A host in a test
16
+ passes one to `PluginClient.call` and sets it to cancel the call.
17
+
18
+ The usual loop checks it between steps, and waits on it instead of sleeping:
19
+
20
+ for path in files:
21
+ cancel.raise_if_cancelled()
22
+ scan(path)
23
+ if cancel.wait(0.1): # sleeps up to 0.1 s, and wakes at once on a cancel
24
+ return partial_result
25
+ """
26
+
27
+ def __init__(self) -> None:
28
+ self._event = threading.Event()
29
+ self._lock = threading.Lock()
30
+ self._callbacks: list[Callable[[], None]] = []
31
+
32
+ @property
33
+ def cancelled(self) -> bool:
34
+ """Whether the work was asked to stop."""
35
+ return self._event.is_set()
36
+
37
+ def cancel(self) -> None:
38
+ """Ask the work to stop. Calling it again does nothing."""
39
+ with self._lock:
40
+ if self._event.is_set():
41
+ return
42
+ self._event.set()
43
+ callbacks, self._callbacks = self._callbacks, []
44
+ for callback in callbacks:
45
+ callback()
46
+
47
+ def wait(self, timeout: float | None = None) -> bool:
48
+ """Wait up to `timeout` seconds (forever for `None`); return whether it was cancelled.
49
+
50
+ A cancellable sleep: it returns early, with `True`, the moment the token is cancelled.
51
+ """
52
+ return self._event.wait(timeout)
53
+
54
+ def raise_if_cancelled(self, message: str = "the request was cancelled") -> None:
55
+ """Raise `RequestCancelledError` when the work was asked to stop."""
56
+ if self._event.is_set():
57
+ raise RequestCancelledError(message)
58
+
59
+ def on_cancel(self, callback: Callable[[], None]) -> Callable[[], None]:
60
+ """Call `callback` (in the cancelling thread) when the token is cancelled, at once if it already is.
61
+
62
+ Returns a function that removes the callback again.
63
+ """
64
+ with self._lock:
65
+ if not self._event.is_set():
66
+ self._callbacks.append(callback)
67
+
68
+ def remove() -> None:
69
+ with self._lock:
70
+ if callback in self._callbacks:
71
+ self._callbacks.remove(callback)
72
+
73
+ return remove
74
+ callback()
75
+ return lambda: None
@@ -0,0 +1,11 @@
1
+ """The error a method raises to say it stopped because the request was cancelled."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class RequestCancelledError(Exception):
7
+ """Raised by a method that noticed its request was cancelled (`CancelToken.raise_if_cancelled`).
8
+
9
+ The serve loop answers a cancelled request that fails with any error with the code
10
+ `REQUEST_CANCELLED`, so raising this is a convenience, not a requirement.
11
+ """
@@ -0,0 +1,5 @@
1
+ """Building the answers of `ci`: steps, provider-native steps and credentials."""
2
+
3
+ from .ci_contribution_algorithm import action_step, ci_step, credential
4
+
5
+ __all__ = ["action_step", "ci_step", "credential"]
@@ -0,0 +1,57 @@
1
+ """Building the answers of `ci`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+
7
+ from ..protocol import ActionStep, Credential, Step
8
+
9
+
10
+ def action_step(uses: str, inputs: Mapping[str, str] | Sequence[tuple[str, str]] | None = None) -> ActionStep:
11
+ """A provider-native step: a GitHub action (`"actions/setup-node@v6"`) or an Azure task (`"NodeTool@0"`).
12
+
13
+ The inputs are written in the order given. The values of a pipeline are written as quoted YAML
14
+ strings, except `true` and `false`, so pass strings.
15
+ """
16
+ pairs = list(inputs.items()) if isinstance(inputs, Mapping) else list(inputs or [])
17
+ if not pairs:
18
+ return {"uses": uses}
19
+ return {"uses": uses, "inputs": [{"name": name, "value": value} for name, value in pairs]}
20
+
21
+
22
+ def ci_step(
23
+ name: str,
24
+ command: Sequence[str],
25
+ *,
26
+ dir: str | None = None, # noqa: A002 - the protocol's member name
27
+ blocking: bool = True,
28
+ cache_files: Sequence[str] | None = None,
29
+ ) -> Step:
30
+ """One command a CI phase (`setup`, `audit`, `postRelease`) runs. It blocks unless `blocking=False`.
31
+
32
+ `dir` is the workspace-relative directory to run in (default: the workspace root). `cache_files`
33
+ are workspace-relative files whose content decides what an audit step finds (a lockfile): smith
34
+ skips a passing step while they are unchanged.
35
+ """
36
+ step: Step = {"name": name, "command": list(command), "blocking": blocking}
37
+ if dir is not None:
38
+ step["dir"] = dir
39
+ if cache_files is not None:
40
+ step["cacheFiles"] = list(cache_files)
41
+ return step
42
+
43
+
44
+ def credential(
45
+ env: str, description: str, *, required: bool = True, setup: bool = False, tasks: bool = False
46
+ ) -> Credential:
47
+ """A secret the release phase needs, read from the environment variable `env`.
48
+
49
+ `required=False` says release works without it. `setup` also gives it to `smith ci setup` (installs
50
+ from a private registry need it); `tasks` also gives it to every step that runs tasks.
51
+ """
52
+ result: Credential = {"env": env, "description": description, "required": required}
53
+ if setup:
54
+ result["setup"] = True
55
+ if tasks:
56
+ result["tasks"] = True
57
+ return result
@@ -0,0 +1,34 @@
1
+ """The wire form of the messages: the bytes the Go SDK writes, and params shaped as the types promise."""
2
+
3
+ from .encode_error import EncodeError
4
+ from .message_parse_error import MessageParseError
5
+ from .method_codec_algorithm import decode_params, decode_result, encode_params, encode_result
6
+ from .params_error import ParamsError
7
+ from .received_message_contract import ReceivedError, ReceivedRequest, ReceivedResponse
8
+ from .wire_message_mapper import (
9
+ parse_request,
10
+ parse_response,
11
+ write_failure,
12
+ write_notification,
13
+ write_request,
14
+ write_result,
15
+ )
16
+
17
+ __all__ = [
18
+ "EncodeError",
19
+ "MessageParseError",
20
+ "ParamsError",
21
+ "ReceivedError",
22
+ "ReceivedRequest",
23
+ "ReceivedResponse",
24
+ "decode_params",
25
+ "decode_result",
26
+ "encode_params",
27
+ "encode_result",
28
+ "parse_request",
29
+ "parse_response",
30
+ "write_failure",
31
+ "write_notification",
32
+ "write_request",
33
+ "write_result",
34
+ ]
@@ -0,0 +1,126 @@
1
+ """Writing a value as the JSON the Go SDK writes for the same message."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from .encode_error import EncodeError
10
+ from .encode_json_value_algorithm import encode_json_string, encode_json_value, format_number
11
+ from .schema_lookup_algorithm import SchemaNode, base_node, is_nullable, kind_of
12
+
13
+
14
+ def encode_by_schema(schema: SchemaNode, node: SchemaNode, value: Any, path: str) -> str:
15
+ """Write a value as the JSON the Go SDK would write for the same message.
16
+
17
+ The members of a message come in the order the schema lists them (the order of the Go
18
+ fields); an optional member is left out when it is absent or holds the zero value Go's
19
+ `omitempty` drops (`""`, `0`, `false`, an empty list or dict); the keys of a map are sorted;
20
+ HTML characters are escaped. So the bytes a Python plugin writes equal the bytes a Go
21
+ plugin writes for the same answer.
22
+
23
+ Raises `EncodeError` for a required member that is missing or a value of the wrong type.
24
+ """
25
+ kind = kind_of(schema, node)
26
+ if value is None:
27
+ if is_nullable(node) or kind == "any":
28
+ return "null"
29
+ raise EncodeError(f"{path} is required and cannot be null")
30
+ base = base_node(schema, node)
31
+ if kind == "string":
32
+ if not isinstance(value, str):
33
+ raise _wrong_type(path, "a string", value)
34
+ return encode_json_string(value)
35
+ if kind == "integer":
36
+ if not _is_integer(value):
37
+ raise _wrong_type(path, "an integer", value)
38
+ return str(value)
39
+ if kind == "number":
40
+ if isinstance(value, bool) or not isinstance(value, int | float) or not math.isfinite(value):
41
+ raise _wrong_type(path, "a number", value)
42
+ return format_number(value, path)
43
+ if kind == "boolean":
44
+ if not isinstance(value, bool):
45
+ raise _wrong_type(path, "a boolean", value)
46
+ return "true" if value else "false"
47
+ if kind == "array":
48
+ if not isinstance(value, list | tuple):
49
+ raise _wrong_type(path, "an array", value)
50
+ items = base.get("items") or {}
51
+ return (
52
+ "[" + ",".join(encode_by_schema(schema, items, item, f"{path}[{i}]") for i, item in enumerate(value)) + "]"
53
+ )
54
+ if kind == "map":
55
+ if not isinstance(value, Mapping):
56
+ raise _wrong_type(path, "an object", value)
57
+ values = base.get("additionalProperties")
58
+ member_schema: SchemaNode = values if isinstance(values, dict) else {}
59
+ members = [
60
+ f"{encode_json_string(str(name))}:{encode_by_schema(schema, member_schema, member, f'{path}.{name}')}"
61
+ for name, member in sorted(value.items(), key=lambda item: str(item[0]))
62
+ ]
63
+ return "{" + ",".join(members) + "}"
64
+ if kind == "struct":
65
+ if not isinstance(value, Mapping):
66
+ raise _wrong_type(path, "an object", value)
67
+ return _encode_struct(schema, base, value, path)
68
+ return encode_json_value(value, path)
69
+
70
+
71
+ def _encode_struct(schema: SchemaNode, base: SchemaNode, value: Mapping[str, Any], path: str) -> str:
72
+ properties: dict[str, SchemaNode] = base.get("properties") or {}
73
+ required = set(base.get("required") or [])
74
+ members: list[str] = []
75
+ for name, property_schema in properties.items():
76
+ member = value.get(name)
77
+ if name not in required and _is_omitted(schema, property_schema, member):
78
+ continue
79
+ if name in required and name not in value:
80
+ raise EncodeError(f"{path}.{name} is required")
81
+ members.append(
82
+ f"{encode_json_string(name)}:{encode_by_schema(schema, property_schema, member, f'{path}.{name}')}"
83
+ )
84
+ # Members the schema does not know (a protocol that grew past this SDK) are kept, after the known ones.
85
+ for name in sorted(str(n) for n in value if n not in properties and value[n] is not None):
86
+ members.append(f"{encode_json_string(name)}:{encode_json_value(value[name], f'{path}.{name}')}")
87
+ return "{" + ",".join(members) + "}"
88
+
89
+
90
+ def _is_omitted(schema: SchemaNode, property_schema: SchemaNode, member: Any) -> bool:
91
+ """Go's `omitempty`: absent, null, or the zero value of a scalar, array or map. A struct is never empty."""
92
+ if member is None:
93
+ return True
94
+ kind = kind_of(schema, property_schema)
95
+ if kind == "string":
96
+ return bool(member == "")
97
+ if kind in ("integer", "number"):
98
+ return not isinstance(member, bool) and member == 0
99
+ if kind == "boolean":
100
+ return member is False
101
+ if kind == "array":
102
+ return isinstance(member, list | tuple) and len(member) == 0
103
+ return bool(kind == "map" and isinstance(member, Mapping) and len(member) == 0)
104
+
105
+
106
+ def _is_integer(value: Any) -> bool:
107
+ return isinstance(value, int) and not isinstance(value, bool)
108
+
109
+
110
+ def _wrong_type(path: str, expected: str, value: Any) -> EncodeError:
111
+ return EncodeError(f"{path} must be {expected}, not {describe_type(value)}")
112
+
113
+
114
+ def describe_type(value: Any) -> str:
115
+ """How an error names the type of a JSON value."""
116
+ if isinstance(value, bool):
117
+ return "a boolean"
118
+ if isinstance(value, str):
119
+ return "a string"
120
+ if isinstance(value, int | float):
121
+ return "a number"
122
+ if isinstance(value, list | tuple):
123
+ return "an array"
124
+ if isinstance(value, Mapping):
125
+ return "an object"
126
+ return f"a {type(value).__name__}"
@@ -0,0 +1,11 @@
1
+ """The error of a value that cannot be written as the message the schema describes."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class EncodeError(Exception):
7
+ """A value that cannot be written as the message the schema describes.
8
+
9
+ A required member is missing, or a member has the wrong type. The serve loop answers it
10
+ with the code `INTERNAL_ERROR`.
11
+ """
@@ -0,0 +1,81 @@
1
+ """Writing JSON the way Go's `encoding/json` writes it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ from collections.abc import Mapping
9
+ from decimal import Decimal
10
+ from typing import Any
11
+
12
+ from .encode_error import EncodeError
13
+
14
+ _LONE_SURROGATE = re.compile("[\ud800-\udfff]")
15
+ _GO_ESCAPES = {
16
+ "<": "\\u003c",
17
+ ">": "\\u003e",
18
+ "&": "\\u0026",
19
+ "
": "\\u2028",
20
+ "
": "\\u2029",
21
+ }
22
+ _GO_ESCAPED = re.compile("[<>&

]")
23
+
24
+
25
+ def encode_json_string(text: str) -> str:
26
+ """A string as the Go SDK writes it.
27
+
28
+ JSON escaping with the characters left as they are (never `\\uXXXX` for a non-ASCII
29
+ letter), and `<`, `>`, `&`, U+2028 and U+2029 as `\\u` escapes, because Go's `json.Marshal`
30
+ escapes HTML by default. Both forms decode to the same text; writing Go's makes the bytes
31
+ of the two SDKs equal. A lone surrogate, which has no UTF-8 form, becomes U+FFFD as Go
32
+ writes invalid UTF-8.
33
+ """
34
+ cleaned = _LONE_SURROGATE.sub("�", text)
35
+ return _GO_ESCAPED.sub(lambda match: _GO_ESCAPES[match.group()], json.dumps(cleaned, ensure_ascii=False))
36
+
37
+
38
+ def format_number(value: int | float, path: str) -> str:
39
+ """A number as Go writes it: an integer as digits, a float in its shortest form."""
40
+ if isinstance(value, int):
41
+ return str(value)
42
+ if not math.isfinite(value):
43
+ raise EncodeError(f"{path}: {value} cannot be written as JSON")
44
+ if value == 0:
45
+ return "-0" if math.copysign(1.0, value) < 0 else "0"
46
+ magnitude = abs(value)
47
+ shortest = Decimal(repr(value))
48
+ if magnitude < 1e-6 or magnitude >= 1e21:
49
+ digits = shortest.as_tuple().digits
50
+ exponent = shortest.adjusted()
51
+ mantissa = f"{digits[0]}" + (f".{''.join(map(str, digits[1:])).rstrip('0')}" if len(digits) > 1 else "")
52
+ mantissa = mantissa.removesuffix(".")
53
+ sign = "-" if value < 0 else ""
54
+ # Go writes the exponent with at least two digits, except e-0N, which it cleans up to e-N.
55
+ exponent_text = f"{abs(exponent):02d}"
56
+ if exponent < 0 and exponent_text.startswith("0"):
57
+ exponent_text = exponent_text[1:]
58
+ return f"{sign}{mantissa}e{'-' if exponent < 0 else '+'}{exponent_text}"
59
+ text = format(shortest, "f")
60
+ return text.rstrip("0").rstrip(".") if "." in text else text
61
+
62
+
63
+ def encode_json_value(value: Any, path: str) -> str:
64
+ """Any JSON value as the Go SDK writes a `map[string]any`: object keys sorted by their UTF-8 bytes."""
65
+ if value is None:
66
+ return "null"
67
+ if isinstance(value, bool):
68
+ return "true" if value else "false"
69
+ if isinstance(value, str):
70
+ return encode_json_string(value)
71
+ if isinstance(value, int | float):
72
+ return format_number(value, path)
73
+ if isinstance(value, Mapping):
74
+ members = [
75
+ f"{encode_json_string(str(name))}:{encode_json_value(member, f'{path}.{name}')}"
76
+ for name, member in sorted(value.items(), key=lambda item: str(item[0]))
77
+ ]
78
+ return "{" + ",".join(members) + "}"
79
+ if isinstance(value, list | tuple):
80
+ return "[" + ",".join(encode_json_value(item, f"{path}[{i}]") for i, item in enumerate(value)) + "]"
81
+ raise EncodeError(f"{path}: a {type(value).__name__} cannot be written as JSON")
@@ -0,0 +1,10 @@
1
+ """The error of a frame body that is not a JSON-RPC 2.0 message."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class MessageParseError(Exception):
7
+ """A frame whose body is not a JSON-RPC 2.0 message of the shape the protocol uses.
8
+
9
+ The serve loop answers it with the code `PARSE_ERROR`.
10
+ """