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.
- monosmith_plugin_sdk/__init__.py +204 -0
- monosmith_plugin_sdk/build_dir/__init__.py +5 -0
- monosmith_plugin_sdk/build_dir/build_dir_algorithm.py +30 -0
- monosmith_plugin_sdk/cancellation/__init__.py +6 -0
- monosmith_plugin_sdk/cancellation/cancel_token_model.py +75 -0
- monosmith_plugin_sdk/cancellation/request_cancelled_error.py +11 -0
- monosmith_plugin_sdk/ci/__init__.py +5 -0
- monosmith_plugin_sdk/ci/ci_contribution_algorithm.py +57 -0
- monosmith_plugin_sdk/codec/__init__.py +34 -0
- monosmith_plugin_sdk/codec/encode_by_schema_algorithm.py +126 -0
- monosmith_plugin_sdk/codec/encode_error.py +11 -0
- monosmith_plugin_sdk/codec/encode_json_value_algorithm.py +81 -0
- monosmith_plugin_sdk/codec/message_parse_error.py +10 -0
- monosmith_plugin_sdk/codec/method_codec_algorithm.py +53 -0
- monosmith_plugin_sdk/codec/normalize_by_schema_algorithm.py +114 -0
- monosmith_plugin_sdk/codec/params_error.py +10 -0
- monosmith_plugin_sdk/codec/received_message_contract.py +38 -0
- monosmith_plugin_sdk/codec/schema_lookup_algorithm.py +62 -0
- monosmith_plugin_sdk/codec/wire_message_mapper.py +90 -0
- monosmith_plugin_sdk/concurrency/__init__.py +21 -0
- monosmith_plugin_sdk/concurrency/concurrency_policy.py +44 -0
- monosmith_plugin_sdk/conformance/__init__.py +7 -0
- monosmith_plugin_sdk/conformance/check_generation_use_case.py +128 -0
- monosmith_plugin_sdk/conformance/check_plugin_use_case.py +131 -0
- monosmith_plugin_sdk/conformance/check_scheduling_use_case.py +141 -0
- monosmith_plugin_sdk/conformance/conformance_report_model.py +53 -0
- monosmith_plugin_sdk/conformance/find_manifests_algorithm.py +38 -0
- monosmith_plugin_sdk/conformance/go_list_mapper.py +17 -0
- monosmith_plugin_sdk/framing/__init__.py +7 -0
- monosmith_plugin_sdk/framing/frame_message_algorithm.py +12 -0
- monosmith_plugin_sdk/framing/frame_reader_store.py +64 -0
- monosmith_plugin_sdk/framing/framing_error.py +11 -0
- monosmith_plugin_sdk/host/__init__.py +29 -0
- monosmith_plugin_sdk/host/call_interrupted_error.py +30 -0
- monosmith_plugin_sdk/host/connect_plugin_use_case.py +103 -0
- monosmith_plugin_sdk/host/host_options_contract.py +47 -0
- monosmith_plugin_sdk/host/plugin_client_client.py +358 -0
- monosmith_plugin_sdk/host/start_plugin_use_case.py +73 -0
- monosmith_plugin_sdk/jsondoc/__init__.py +6 -0
- monosmith_plugin_sdk/jsondoc/indent_raw_json_algorithm.py +51 -0
- monosmith_plugin_sdk/jsondoc/json_document_error.py +7 -0
- monosmith_plugin_sdk/jsondoc/json_document_model.py +129 -0
- monosmith_plugin_sdk/jsondoc/scan_json_object_algorithm.py +165 -0
- monosmith_plugin_sdk/plugin_refs/__init__.py +5 -0
- monosmith_plugin_sdk/plugin_refs/plugin_ref_policy.py +31 -0
- monosmith_plugin_sdk/plugin_roles/__init__.py +12 -0
- monosmith_plugin_sdk/plugin_roles/plugin_role_policy.py +51 -0
- monosmith_plugin_sdk/projects/__init__.py +19 -0
- monosmith_plugin_sdk/projects/audit_settings_policy.py +15 -0
- monosmith_plugin_sdk/projects/manifest_match_algorithm.py +46 -0
- monosmith_plugin_sdk/projects/project_kind_policy.py +25 -0
- monosmith_plugin_sdk/projects/publishes_policy.py +15 -0
- monosmith_plugin_sdk/projects/set_toolchain_algorithm.py +21 -0
- monosmith_plugin_sdk/projects/stack_plugins_algorithm.py +14 -0
- monosmith_plugin_sdk/protocol/__init__.py +8 -0
- monosmith_plugin_sdk/protocol/protocol_schema_contract.py +1991 -0
- monosmith_plugin_sdk/protocol/protocol_types_contract.py +1350 -0
- monosmith_plugin_sdk/protocol/rpc_error.py +22 -0
- monosmith_plugin_sdk/py.typed +0 -0
- monosmith_plugin_sdk/scaffold/__init__.py +6 -0
- monosmith_plugin_sdk/scaffold/scaffold_data_mapper.py +30 -0
- monosmith_plugin_sdk/scaffold/scaffold_project_use_case.py +35 -0
- monosmith_plugin_sdk/serve/__init__.py +45 -0
- monosmith_plugin_sdk/serve/capabilities_of_algorithm.py +48 -0
- monosmith_plugin_sdk/serve/claim_stdio_use_case.py +36 -0
- monosmith_plugin_sdk/serve/dispatch_request_use_case.py +72 -0
- monosmith_plugin_sdk/serve/frame_writer_store.py +27 -0
- monosmith_plugin_sdk/serve/initialize_plugin_use_case.py +29 -0
- monosmith_plugin_sdk/serve/plugin_contract.py +135 -0
- monosmith_plugin_sdk/serve/plugin_members_config.py +35 -0
- monosmith_plugin_sdk/serve/protocol_streams_contract.py +35 -0
- monosmith_plugin_sdk/serve/request_outcome_contract.py +29 -0
- monosmith_plugin_sdk/serve/request_pool_store.py +154 -0
- monosmith_plugin_sdk/serve/serve_use_case.py +215 -0
- monosmith_plugin_sdk/tasks/__init__.py +6 -0
- monosmith_plugin_sdk/tasks/contribute_tasks_algorithm.py +28 -0
- monosmith_plugin_sdk/tasks/task_algorithm.py +71 -0
- monosmith_plugin_sdk/templating/__init__.py +8 -0
- monosmith_plugin_sdk/templating/file_exists_error.py +15 -0
- monosmith_plugin_sdk/templating/parse_template_algorithm.py +223 -0
- monosmith_plugin_sdk/templating/render_template_algorithm.py +143 -0
- monosmith_plugin_sdk/templating/template_error.py +13 -0
- monosmith_plugin_sdk/templating/template_functions_algorithm.py +177 -0
- monosmith_plugin_sdk/templating/template_syntax_contract.py +78 -0
- monosmith_plugin_sdk/templating/tokenize_action_algorithm.py +81 -0
- monosmith_plugin_sdk/templating/write_templates_use_case.py +47 -0
- monosmith_plugin_sdk-0.1.0.dist-info/METADATA +134 -0
- monosmith_plugin_sdk-0.1.0.dist-info/RECORD +91 -0
- monosmith_plugin_sdk-0.1.0.dist-info/WHEEL +5 -0
- monosmith_plugin_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- monosmith_plugin_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Encoding and decoding the params and the result of each method of the protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..protocol import PROTOCOL_SCHEMA
|
|
8
|
+
from .encode_by_schema_algorithm import encode_by_schema
|
|
9
|
+
from .encode_json_value_algorithm import encode_json_value
|
|
10
|
+
from .normalize_by_schema_algorithm import normalize_by_schema
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _side(method: str, side: str) -> dict[str, Any] | None:
|
|
14
|
+
rows = PROTOCOL_SCHEMA["x-methods"]
|
|
15
|
+
row = rows.get(method) if isinstance(method, str) else None
|
|
16
|
+
node = None if row is None else row.get(side)
|
|
17
|
+
return node if isinstance(node, dict) else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _encode(method: str, side: str, value: Any) -> str:
|
|
21
|
+
node = _side(method, side)
|
|
22
|
+
return encode_json_value(value, side) if node is None else encode_by_schema(PROTOCOL_SCHEMA, node, value, side)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _decode(method: str, side: str, value: Any) -> Any:
|
|
26
|
+
node = _side(method, side)
|
|
27
|
+
return value if node is None else normalize_by_schema(PROTOCOL_SCHEMA, node, value, side)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def encode_params(method: str, params: Any) -> str:
|
|
31
|
+
"""The params of a request, written as the Go SDK writes them.
|
|
32
|
+
|
|
33
|
+
A method the protocol does not know is written as plain JSON.
|
|
34
|
+
"""
|
|
35
|
+
return _encode(method, "params", params)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def encode_result(method: str, result: Any) -> str:
|
|
39
|
+
"""The result of a method, written as the Go SDK writes it."""
|
|
40
|
+
return _encode(method, "result", result)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def decode_params(method: str, params: Any) -> Any:
|
|
44
|
+
"""The params of a received request, shaped as the generated types promise.
|
|
45
|
+
|
|
46
|
+
Absent params are an empty object. Raises `ParamsError` for params of the wrong shape.
|
|
47
|
+
"""
|
|
48
|
+
return _decode(method, "params", {} if params is None else params)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def decode_result(method: str, result: Any) -> Any:
|
|
52
|
+
"""The result of a received response, shaped as the generated types promise."""
|
|
53
|
+
return _decode(method, "result", {} if result is None else result)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Shaping a received value the way the Go SDK's decoder does."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .encode_by_schema_algorithm import describe_type
|
|
8
|
+
from .params_error import ParamsError
|
|
9
|
+
from .schema_lookup_algorithm import SchemaNode, base_node, is_nullable, kind_of
|
|
10
|
+
|
|
11
|
+
MAX_DEPTH = 64
|
|
12
|
+
|
|
13
|
+
_SCALARS = {"string": "a string", "integer": "an integer", "number": "a number", "boolean": "a boolean"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_scalar(kind: str, value: Any) -> bool:
|
|
17
|
+
if kind == "string":
|
|
18
|
+
return isinstance(value, str)
|
|
19
|
+
if kind == "integer":
|
|
20
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
21
|
+
if kind == "number":
|
|
22
|
+
return isinstance(value, int | float) and not isinstance(value, bool)
|
|
23
|
+
return isinstance(value, bool)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def normalize_by_schema(schema: SchemaNode, node: SchemaNode, value: Any, path: str, depth: int = 0) -> Any:
|
|
27
|
+
"""Shape a received value so that the types the generated `TypedDict`s promise hold.
|
|
28
|
+
|
|
29
|
+
- a required member that is absent or `null` is its zero value (`[]`, `{}`, `""`, `0`,
|
|
30
|
+
`False`): the Go host writes a nil slice as `null`;
|
|
31
|
+
- an optional member that is `null` is removed;
|
|
32
|
+
- members the schema does not know stay as they are.
|
|
33
|
+
|
|
34
|
+
Raises `ParamsError` where a value has the wrong JSON type. The value is edited in place
|
|
35
|
+
and returned.
|
|
36
|
+
"""
|
|
37
|
+
if depth > MAX_DEPTH:
|
|
38
|
+
raise ParamsError(f"{path} is nested too deeply")
|
|
39
|
+
if value is None:
|
|
40
|
+
zero = _zero_of(schema, node, path, depth)
|
|
41
|
+
return None if zero is _NO_ZERO else zero
|
|
42
|
+
kind = kind_of(schema, node)
|
|
43
|
+
base = base_node(schema, node)
|
|
44
|
+
if kind in _SCALARS:
|
|
45
|
+
if not _is_scalar(kind, value):
|
|
46
|
+
raise _wrong_type(path, _SCALARS[kind], value)
|
|
47
|
+
return value
|
|
48
|
+
if kind == "array":
|
|
49
|
+
if not isinstance(value, list):
|
|
50
|
+
raise _wrong_type(path, "an array", value)
|
|
51
|
+
items = base.get("items") or {}
|
|
52
|
+
return [normalize_by_schema(schema, items, item, f"{path}[{i}]", depth + 1) for i, item in enumerate(value)]
|
|
53
|
+
if kind == "map":
|
|
54
|
+
return _normalize_map(schema, base, value, path, depth)
|
|
55
|
+
if kind == "struct":
|
|
56
|
+
return _normalize_struct(schema, base, value, path, depth)
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize_map(schema: SchemaNode, base: SchemaNode, value: Any, path: str, depth: int) -> Any:
|
|
61
|
+
if not isinstance(value, dict):
|
|
62
|
+
raise _wrong_type(path, "an object", value)
|
|
63
|
+
values = base.get("additionalProperties")
|
|
64
|
+
member_schema: SchemaNode = values if isinstance(values, dict) else {}
|
|
65
|
+
keeps_null = kind_of(schema, member_schema) == "any"
|
|
66
|
+
for name in list(value):
|
|
67
|
+
member = value[name]
|
|
68
|
+
if member is not None:
|
|
69
|
+
value[name] = normalize_by_schema(schema, member_schema, member, f"{path}.{name}", depth + 1)
|
|
70
|
+
elif not keeps_null:
|
|
71
|
+
del value[name]
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _normalize_struct(schema: SchemaNode, base: SchemaNode, value: Any, path: str, depth: int) -> Any:
|
|
76
|
+
if not isinstance(value, dict):
|
|
77
|
+
raise _wrong_type(path, "an object", value)
|
|
78
|
+
required = set(base.get("required") or [])
|
|
79
|
+
for name, property_schema in (base.get("properties") or {}).items():
|
|
80
|
+
member = value.get(name)
|
|
81
|
+
where = f"{path}.{name}"
|
|
82
|
+
if member is not None:
|
|
83
|
+
value[name] = normalize_by_schema(schema, property_schema, member, where, depth + 1)
|
|
84
|
+
elif name in required:
|
|
85
|
+
zero = _zero_of(schema, property_schema, where, depth + 1)
|
|
86
|
+
if zero is not _NO_ZERO:
|
|
87
|
+
value[name] = zero
|
|
88
|
+
else:
|
|
89
|
+
value.pop(name, None)
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_NO_ZERO: Any = object()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _zero_of(schema: SchemaNode, node: SchemaNode, path: str, depth: int) -> Any:
|
|
97
|
+
"""The value Go's decoder leaves in a member the message did not carry."""
|
|
98
|
+
if is_nullable(node):
|
|
99
|
+
return None
|
|
100
|
+
kind = kind_of(schema, node)
|
|
101
|
+
zeros: dict[str, Any] = {"string": "", "integer": 0, "number": 0, "boolean": False}
|
|
102
|
+
if kind in zeros:
|
|
103
|
+
return zeros[kind]
|
|
104
|
+
if kind == "array":
|
|
105
|
+
return []
|
|
106
|
+
if kind == "map":
|
|
107
|
+
return {}
|
|
108
|
+
if kind == "struct":
|
|
109
|
+
return normalize_by_schema(schema, node, {}, path, depth + 1)
|
|
110
|
+
return _NO_ZERO
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _wrong_type(path: str, expected: str, value: Any) -> ParamsError:
|
|
114
|
+
return ParamsError(f"{path} must be {expected}, not {describe_type(value)}")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""The error of received params, or a received result, of the wrong shape."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ParamsError(Exception):
|
|
7
|
+
"""A received message whose params (or result) do not have the shape the schema describes.
|
|
8
|
+
|
|
9
|
+
The serve loop answers it with the code `INVALID_PARAMS`.
|
|
10
|
+
"""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The messages as they were read from a frame."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ReceivedRequest:
|
|
11
|
+
"""A request or notification as it was received."""
|
|
12
|
+
|
|
13
|
+
id: int | None
|
|
14
|
+
"""The request id; `None` for a notification, which gets no response."""
|
|
15
|
+
method: str
|
|
16
|
+
"""The method name; empty when the message carried none."""
|
|
17
|
+
params: Any
|
|
18
|
+
"""The params as the JSON parser returned them; `None` when absent."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ReceivedError:
|
|
23
|
+
"""The error object of a response."""
|
|
24
|
+
|
|
25
|
+
code: int
|
|
26
|
+
message: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ReceivedResponse:
|
|
31
|
+
"""A response as it was received."""
|
|
32
|
+
|
|
33
|
+
id: int | None
|
|
34
|
+
"""The id of the request answered; `None` when the request could not be read."""
|
|
35
|
+
result: Any
|
|
36
|
+
"""The result, when the response is not an error."""
|
|
37
|
+
error: ReceivedError | None
|
|
38
|
+
"""The error, when the response is one."""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Reading a schema node: what it holds, with `$ref` followed and `null` set aside."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
SchemaNode = dict[str, Any]
|
|
8
|
+
"""A node of the JSON Schema, as the JSON parser returns it."""
|
|
9
|
+
|
|
10
|
+
NodeKind = Literal["string", "integer", "number", "boolean", "array", "struct", "map", "any"]
|
|
11
|
+
"""What a schema node holds, as far as the codec cares."""
|
|
12
|
+
|
|
13
|
+
REF_PREFIX = "#/$defs/"
|
|
14
|
+
MAX_REF_HOPS = 16
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_node(schema: SchemaNode, node: SchemaNode) -> SchemaNode:
|
|
18
|
+
"""Follow `$ref` to the definition it names."""
|
|
19
|
+
current = node
|
|
20
|
+
hops = 0
|
|
21
|
+
while "$ref" in current:
|
|
22
|
+
name = str(current["$ref"])[len(REF_PREFIX) :]
|
|
23
|
+
target = schema["$defs"].get(name)
|
|
24
|
+
if target is None or hops > MAX_REF_HOPS:
|
|
25
|
+
raise ValueError(f"the schema has no definition {current['$ref']}")
|
|
26
|
+
current = target
|
|
27
|
+
hops += 1
|
|
28
|
+
return current
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_nullable(node: SchemaNode) -> bool:
|
|
32
|
+
"""Whether `null` is a value of the node: `["integer", "null"]`, or an `anyOf` with null."""
|
|
33
|
+
kind = node.get("type")
|
|
34
|
+
if isinstance(kind, list):
|
|
35
|
+
return "null" in kind
|
|
36
|
+
return any(branch.get("type") == "null" for branch in node.get("anyOf") or [])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def without_null(schema: SchemaNode, node: SchemaNode) -> SchemaNode:
|
|
40
|
+
"""The node without its `null` alternative."""
|
|
41
|
+
kind = node.get("type")
|
|
42
|
+
if isinstance(kind, list):
|
|
43
|
+
types = [t for t in kind if t != "null"]
|
|
44
|
+
return {**node, "type": types[0] if len(types) == 1 else types}
|
|
45
|
+
branches = [branch for branch in node.get("anyOf") or [] if branch.get("type") != "null"]
|
|
46
|
+
return resolve_node(schema, branches[0]) if len(branches) == 1 else node
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def base_node(schema: SchemaNode, node: SchemaNode) -> SchemaNode:
|
|
50
|
+
"""The node as the codec walks it: nullability removed, references followed."""
|
|
51
|
+
return resolve_node(schema, without_null(schema, node))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def kind_of(schema: SchemaNode, node: SchemaNode) -> NodeKind:
|
|
55
|
+
"""The kind of value a node describes, after `$ref` and null are set aside."""
|
|
56
|
+
base = base_node(schema, node)
|
|
57
|
+
kind = base.get("type")
|
|
58
|
+
if kind in ("string", "integer", "number", "boolean", "array"):
|
|
59
|
+
return kind # type: ignore[no-any-return]
|
|
60
|
+
if kind == "object":
|
|
61
|
+
return "map" if "properties" not in base else "struct"
|
|
62
|
+
return "any"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The JSON-RPC 2.0 envelopes: written the way the Go SDK writes them, and read."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .encode_json_value_algorithm import encode_json_string
|
|
9
|
+
from .message_parse_error import MessageParseError
|
|
10
|
+
from .received_message_contract import ReceivedError, ReceivedRequest, ReceivedResponse
|
|
11
|
+
|
|
12
|
+
_INT64_MIN = -(2**63)
|
|
13
|
+
_INT64_MAX = 2**63 - 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def write_request(request_id: int, method: str, params_json: str | None) -> str:
|
|
17
|
+
"""A request as the Go SDK writes it: `jsonrpc`, `id`, `method`, `params` (left out when there are none)."""
|
|
18
|
+
params = "" if params_json is None else f',"params":{params_json}'
|
|
19
|
+
return f'{{"jsonrpc":"2.0","id":{request_id},"method":{encode_json_string(method)}{params}}}'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def write_notification(method: str, params_json: str) -> str:
|
|
23
|
+
"""A notification as the Go SDK writes it: a request with no `id`, which gets no response."""
|
|
24
|
+
return f'{{"jsonrpc":"2.0","method":{encode_json_string(method)},"params":{params_json}}}'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def write_result(request_id: int | None, result_json: str) -> str:
|
|
28
|
+
"""A successful response; `id` is `None` only when the request could not be read."""
|
|
29
|
+
return f'{{"jsonrpc":"2.0","id":{_id(request_id)},"result":{result_json}}}'
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_failure(request_id: int | None, code: int, message: str) -> str:
|
|
33
|
+
"""An error response."""
|
|
34
|
+
return (
|
|
35
|
+
f'{{"jsonrpc":"2.0","id":{_id(request_id)},"error":{{"code":{code},"message":{encode_json_string(message)}}}}}'
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _id(request_id: int | None) -> str:
|
|
40
|
+
return "null" if request_id is None else str(request_id)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _reject_constant(name: str) -> Any:
|
|
44
|
+
raise ValueError(f"{name} is not JSON")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_object(body: str) -> dict[str, Any]:
|
|
48
|
+
try:
|
|
49
|
+
value = json.loads(body, parse_constant=_reject_constant)
|
|
50
|
+
except ValueError as error:
|
|
51
|
+
raise MessageParseError(str(error)) from error
|
|
52
|
+
if not isinstance(value, dict):
|
|
53
|
+
raise MessageParseError("a message is a JSON object")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read_id(message: dict[str, Any]) -> int | None:
|
|
58
|
+
value = message.get("id")
|
|
59
|
+
if value is None:
|
|
60
|
+
return None
|
|
61
|
+
if isinstance(value, bool) or not isinstance(value, int) or not _INT64_MIN <= value <= _INT64_MAX:
|
|
62
|
+
raise MessageParseError("id must be an integer")
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_request(body: str) -> ReceivedRequest:
|
|
67
|
+
"""Read a request frame. A `null` or missing id makes it a notification."""
|
|
68
|
+
message = _parse_object(body)
|
|
69
|
+
request_id = _read_id(message)
|
|
70
|
+
method = message.get("method")
|
|
71
|
+
if method is None:
|
|
72
|
+
method = ""
|
|
73
|
+
if not isinstance(method, str):
|
|
74
|
+
raise MessageParseError("method must be a string")
|
|
75
|
+
return ReceivedRequest(request_id, method, message.get("params"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_response(body: str) -> ReceivedResponse:
|
|
79
|
+
"""Read a response frame."""
|
|
80
|
+
message = _parse_object(body)
|
|
81
|
+
request_id = _read_id(message)
|
|
82
|
+
error = message.get("error")
|
|
83
|
+
if error is None:
|
|
84
|
+
return ReceivedResponse(request_id, message.get("result"), None)
|
|
85
|
+
if not isinstance(error, dict):
|
|
86
|
+
raise MessageParseError("error must be an object")
|
|
87
|
+
code, text = error.get("code"), error.get("message")
|
|
88
|
+
if isinstance(code, bool) or not isinstance(code, int) or not isinstance(text, str):
|
|
89
|
+
raise MessageParseError("error needs a numeric code and a message")
|
|
90
|
+
return ReceivedResponse(request_id, None, ReceivedError(code, text))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""What a declared `concurrency` means: one at a time by default, a count, or unlimited."""
|
|
2
|
+
|
|
3
|
+
from .concurrency_policy import (
|
|
4
|
+
CONCURRENCY_UNLIMITED,
|
|
5
|
+
UNLIMITED_IN_FLIGHT,
|
|
6
|
+
UNLIMITED_WORKERS,
|
|
7
|
+
concurrency_limit,
|
|
8
|
+
in_flight_limit,
|
|
9
|
+
is_valid_concurrency,
|
|
10
|
+
worker_limit,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CONCURRENCY_UNLIMITED",
|
|
15
|
+
"UNLIMITED_IN_FLIGHT",
|
|
16
|
+
"UNLIMITED_WORKERS",
|
|
17
|
+
"concurrency_limit",
|
|
18
|
+
"in_flight_limit",
|
|
19
|
+
"is_valid_concurrency",
|
|
20
|
+
"worker_limit",
|
|
21
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""What a declared `concurrency` means."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
CONCURRENCY_UNLIMITED = -1
|
|
6
|
+
"""The `concurrency` value that leaves the number of simultaneous requests to the host."""
|
|
7
|
+
|
|
8
|
+
UNLIMITED_IN_FLIGHT = 32
|
|
9
|
+
"""How many requests the host keeps in flight to a plugin that declared unlimited concurrency."""
|
|
10
|
+
|
|
11
|
+
UNLIMITED_WORKERS = 1024
|
|
12
|
+
"""The most workers a plugin that declared unlimited concurrency runs: the pool stays bounded."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_valid_concurrency(declared: int) -> bool:
|
|
16
|
+
"""Whether a declared concurrency is one the protocol defines.
|
|
17
|
+
|
|
18
|
+
Absent (0), a count (1 and above) or unlimited (-1).
|
|
19
|
+
"""
|
|
20
|
+
return declared >= CONCURRENCY_UNLIMITED
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def concurrency_limit(declared: int | None) -> tuple[int, bool]:
|
|
24
|
+
"""Read a declared concurrency: `(limit, unlimited)`.
|
|
25
|
+
|
|
26
|
+
An absent, zero or invalid value is 1, the default of every plugin that says nothing.
|
|
27
|
+
"""
|
|
28
|
+
if declared == CONCURRENCY_UNLIMITED:
|
|
29
|
+
return 0, True
|
|
30
|
+
if declared is None or isinstance(declared, bool) or declared < 1:
|
|
31
|
+
return 1, False
|
|
32
|
+
return declared, False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def in_flight_limit(declared: int | None) -> int:
|
|
36
|
+
"""How many requests the host keeps in flight to a plugin that declared `declared`."""
|
|
37
|
+
limit, unlimited = concurrency_limit(declared)
|
|
38
|
+
return UNLIMITED_IN_FLIGHT if unlimited else limit
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def worker_limit(declared: int | None) -> int:
|
|
42
|
+
"""How many requests a plugin that declared `declared` works on at once (its pool size)."""
|
|
43
|
+
limit, unlimited = concurrency_limit(declared)
|
|
44
|
+
return UNLIMITED_WORKERS if unlimited else limit
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""The checks of `smith plugin check`, to run in a plugin's own tests."""
|
|
2
|
+
|
|
3
|
+
from .check_generation_use_case import CONFORMANCE_STACK
|
|
4
|
+
from .check_plugin_use_case import check_plugin
|
|
5
|
+
from .conformance_report_model import CheckResult, ConformanceReport
|
|
6
|
+
|
|
7
|
+
__all__ = ["CONFORMANCE_STACK", "CheckResult", "ConformanceReport", "check_plugin"]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""The checks of a plugin that generates projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..host import PluginClient
|
|
11
|
+
from ..plugin_roles import effective_roles
|
|
12
|
+
from ..projects import project_kind_directory
|
|
13
|
+
from ..protocol import GenerateParams, Project, ProjectKind
|
|
14
|
+
from .conformance_report_model import ConformanceReport
|
|
15
|
+
from .find_manifests_algorithm import find_manifests
|
|
16
|
+
from .go_list_mapper import go_list, go_quote
|
|
17
|
+
|
|
18
|
+
CONFORMANCE_STACK = "conformance"
|
|
19
|
+
"""The stack name generated projects are asked to record."""
|
|
20
|
+
|
|
21
|
+
LONG = 20 * 60.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _article(kind: ProjectKind) -> str:
|
|
25
|
+
return "an app" if kind == "app" else f"a {kind}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_generation(client: PluginClient, workspace: str | Path, report: ConformanceReport) -> None:
|
|
29
|
+
"""Ask the plugin for one project of each kind and check the files it reports exist.
|
|
30
|
+
|
|
31
|
+
For a language plugin, also that its own `discover` returns what it made, with the kind and the
|
|
32
|
+
stack it was asked for, tasks with commands, and that the dry runs of `version/write`, `remove`,
|
|
33
|
+
`publish` and `published` answer.
|
|
34
|
+
"""
|
|
35
|
+
language = "language" in effective_roles(client.info)
|
|
36
|
+
made: list[GenerateParams] = []
|
|
37
|
+
kinds: tuple[ProjectKind, ...] = ("app", "lib", "package")
|
|
38
|
+
for kind in kinds:
|
|
39
|
+
name = f"c{kind}"
|
|
40
|
+
params: GenerateParams = {
|
|
41
|
+
"kind": kind,
|
|
42
|
+
"name": name,
|
|
43
|
+
"directory": f"{project_kind_directory(kind)}/{name}",
|
|
44
|
+
"stack": CONFORMANCE_STACK,
|
|
45
|
+
"workspace": {"name": "conformance", "scope": "@conformance", "modulePrefix": "example.com/conformance"},
|
|
46
|
+
}
|
|
47
|
+
try:
|
|
48
|
+
files = client.call("generate", params, timeout=LONG)["files"]
|
|
49
|
+
except Exception as error:
|
|
50
|
+
report.add(f"generates {_article(kind)}", False, str(error))
|
|
51
|
+
continue
|
|
52
|
+
missing = [file for file in files if not Path(workspace, *file.split("/")).exists()]
|
|
53
|
+
report.add(
|
|
54
|
+
f"generates {_article(kind)}",
|
|
55
|
+
len(files) > 0 and not missing,
|
|
56
|
+
f"files reported but not written: {go_list(missing)} (of {go_list(files)})",
|
|
57
|
+
)
|
|
58
|
+
made.append(params)
|
|
59
|
+
if not language or not made:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
manifests = find_manifests(workspace, client.info.get("manifests") or [])
|
|
64
|
+
projects: list[Project] = client.call("discover", {"manifests": manifests}, timeout=LONG)["projects"]
|
|
65
|
+
except Exception as error:
|
|
66
|
+
report.add("discovers what it generated", False, str(error))
|
|
67
|
+
return
|
|
68
|
+
found = {project["root"]: project for project in projects}
|
|
69
|
+
for params in made:
|
|
70
|
+
kind = params["kind"]
|
|
71
|
+
project = found.get(params["directory"])
|
|
72
|
+
report.add(
|
|
73
|
+
f"discovers the generated {kind}",
|
|
74
|
+
project is not None,
|
|
75
|
+
f"no project at {params['directory']} among {len(projects)} discovered",
|
|
76
|
+
)
|
|
77
|
+
if project is None:
|
|
78
|
+
continue
|
|
79
|
+
report.add(
|
|
80
|
+
f"the generated {kind} keeps its kind",
|
|
81
|
+
project["kind"] == kind,
|
|
82
|
+
f"discovered as {go_quote(project['kind'])}",
|
|
83
|
+
)
|
|
84
|
+
report.add(
|
|
85
|
+
f"the generated {kind} records its stack",
|
|
86
|
+
project.get("stack") == CONFORMANCE_STACK,
|
|
87
|
+
f"discovered with stack {go_quote(project.get('stack', ''))}; "
|
|
88
|
+
"generate must record params.stack in the manifest",
|
|
89
|
+
)
|
|
90
|
+
empty = sorted(name for name, task in (project.get("tasks") or {}).items() if not task["command"])
|
|
91
|
+
report.add(
|
|
92
|
+
f"the generated {kind}'s tasks have commands", not empty, f"tasks without a command: {go_list(empty)}"
|
|
93
|
+
)
|
|
94
|
+
call = functools.partial(client.call, timeout=60.0)
|
|
95
|
+
if client.supports("version/write"):
|
|
96
|
+
_attempt(
|
|
97
|
+
report,
|
|
98
|
+
f"writes the {kind}'s version (dry run)",
|
|
99
|
+
functools.partial(call, "version/write", {"project": project, "version": "1.2.3", "dryRun": True}),
|
|
100
|
+
)
|
|
101
|
+
if client.supports("remove"):
|
|
102
|
+
_attempt(
|
|
103
|
+
report,
|
|
104
|
+
f"would remove the {kind} (dry run)",
|
|
105
|
+
functools.partial(call, "remove", {"project": project, "dryRun": True}),
|
|
106
|
+
)
|
|
107
|
+
if kind == "package" and client.supports("publish"):
|
|
108
|
+
publish = functools.partial(client.call, timeout=300.0)
|
|
109
|
+
_attempt(
|
|
110
|
+
report,
|
|
111
|
+
"publishes the package (dry run)",
|
|
112
|
+
functools.partial(publish, "publish", {"project": project, "version": "1.2.3", "dryRun": True}),
|
|
113
|
+
)
|
|
114
|
+
if kind == "package" and client.supports("published"):
|
|
115
|
+
_attempt(
|
|
116
|
+
report,
|
|
117
|
+
"says what the registry has of the package",
|
|
118
|
+
functools.partial(call, "published", {"project": project}),
|
|
119
|
+
" (an unreachable registry is a result, not an error)",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _attempt(report: ConformanceReport, check: str, call: Callable[[], Any], hint: str = "") -> None:
|
|
124
|
+
try:
|
|
125
|
+
call()
|
|
126
|
+
report.add(check, True, "")
|
|
127
|
+
except Exception as error:
|
|
128
|
+
report.add(check, False, f"{error}{hint}")
|