bowire-plugin 0.3.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.
- bowire_plugin/__init__.py +69 -0
- bowire_plugin/_http.py +127 -0
- bowire_plugin/_manual_entry.py +165 -0
- bowire_plugin/_models.py +194 -0
- bowire_plugin/_plugin.py +95 -0
- bowire_plugin/_runtime.py +258 -0
- bowire_plugin/_settings.py +203 -0
- bowire_plugin-0.3.0.dist-info/METADATA +486 -0
- bowire_plugin-0.3.0.dist-info/RECORD +11 -0
- bowire_plugin-0.3.0.dist-info/WHEEL +4 -0
- bowire_plugin-0.3.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# Copyright 2026 Küstenlogik
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The JSON-RPC dispatch + stdio runtime that drive a :class:`BowirePlugin`.
|
|
4
|
+
|
|
5
|
+
Wire contract: NDJSON (one JSON-RPC 2.0 envelope per ``\\n``-terminated
|
|
6
|
+
UTF-8 line) over the process's stdin/stdout, exactly the framing Bowire's
|
|
7
|
+
host (and MCP's stdio transport) use. See the Bowire repo's
|
|
8
|
+
``docs/architecture/sidecar-plugins.md`` for the full spec.
|
|
9
|
+
|
|
10
|
+
The transport-agnostic dispatch lives in :class:`_Dispatcher`; the stdio
|
|
11
|
+
runtime and the HTTP/SSE server (in ``_http``) both reuse it.
|
|
12
|
+
|
|
13
|
+
Methods handled: initialize, ping, shutdown, discover, invoke,
|
|
14
|
+
invokeStream (host-minted streamId in params; ack then emit
|
|
15
|
+
``$/stream/data`` notifications + ``$/stream/end``).
|
|
16
|
+
|
|
17
|
+
The ``initialize`` reply carries ``protocolVersion`` +
|
|
18
|
+
``capabilities`` (#416), so the host knows which contract it is talking to
|
|
19
|
+
and which calls it can skip.
|
|
20
|
+
|
|
21
|
+
``discover`` / ``invoke`` / ``invokeStream`` params may carry a ``settings``
|
|
22
|
+
object (Kuestenlogik/Bowire#693): the values set for the settings the plugin
|
|
23
|
+
declared. Every call hands the plugin a fresh :class:`Settings` built from it,
|
|
24
|
+
so a changed value applies from the next call on.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from typing import Any, TextIO
|
|
33
|
+
|
|
34
|
+
from ._models import PluginSetting
|
|
35
|
+
from ._plugin import BowirePlugin
|
|
36
|
+
from ._settings import Settings
|
|
37
|
+
|
|
38
|
+
# A notification sink: hands a fully-formed JSON-RPC notification
|
|
39
|
+
# envelope to whichever transport is in play (stdout line / SSE event).
|
|
40
|
+
EmitFn = Callable[[dict[str, Any]], None]
|
|
41
|
+
|
|
42
|
+
#: The sidecar wire-contract version this SDK speaks (#416). The host
|
|
43
|
+
#: accepts a sidecar inside its supported range and refuses one outside it
|
|
44
|
+
#: at the handshake, rather than failing at the first call. A sidecar that
|
|
45
|
+
#: sends none at all is tolerated as contract v1 — with a warning in the
|
|
46
|
+
#: host log on every boot, which is what this SDK used to earn.
|
|
47
|
+
SIDECAR_PROTOCOL_VERSION = 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _capabilities(plugin: BowirePlugin) -> dict[str, bool]:
|
|
51
|
+
"""What this plugin can actually answer, read off the subclass.
|
|
52
|
+
|
|
53
|
+
A flag set to ``False`` lets the host skip the call entirely. The base
|
|
54
|
+
class ships a polite default for every method — ``discover`` returns
|
|
55
|
+
``[]``, ``invoke`` an "not implemented" result — so "did the author
|
|
56
|
+
override it?" is the only honest answer to "can it do this?", and the
|
|
57
|
+
host gets to stop asking questions whose answer is a stub.
|
|
58
|
+
|
|
59
|
+
``channels`` is always ``False``: the Python SDK has no channel surface
|
|
60
|
+
at all, so the host's ``openChannel`` would round-trip to a method-not-
|
|
61
|
+
found every time an operator opened a duplex method.
|
|
62
|
+
"""
|
|
63
|
+
cls = type(plugin)
|
|
64
|
+
return {
|
|
65
|
+
"discover": cls.discover is not BowirePlugin.discover,
|
|
66
|
+
"invoke": cls.invoke is not BowirePlugin.invoke,
|
|
67
|
+
"invokeStream": cls.invoke_stream is not BowirePlugin.invoke_stream,
|
|
68
|
+
"channels": False,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _Dispatcher:
|
|
73
|
+
"""Transport-agnostic JSON-RPC dispatch over a :class:`BowirePlugin`.
|
|
74
|
+
|
|
75
|
+
:meth:`handle` turns one request into its reply envelope (or
|
|
76
|
+
``None`` for notifications / post-shutdown). Streaming frames are
|
|
77
|
+
pushed through the injected :data:`EmitFn` so stdio and HTTP route
|
|
78
|
+
them to their own wire.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, plugin: BowirePlugin, emit: EmitFn) -> None:
|
|
82
|
+
self._plugin = plugin
|
|
83
|
+
self._emit = emit
|
|
84
|
+
self._stop = threading.Event()
|
|
85
|
+
self._stream_threads: list[threading.Thread] = []
|
|
86
|
+
self._declared: list[PluginSetting] | None = None
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def stopped(self) -> bool:
|
|
90
|
+
return self._stop.is_set()
|
|
91
|
+
|
|
92
|
+
def _declared_settings(self) -> list[PluginSetting]:
|
|
93
|
+
"""What the plugin declared, asked once: the ``initialize`` reply and
|
|
94
|
+
every call's defaults have to agree on it. Built lazily rather than in
|
|
95
|
+
``__init__`` so a plugin whose ``settings()`` raises fails the
|
|
96
|
+
handshake as a JSON-RPC error instead of the process start."""
|
|
97
|
+
if self._declared is None:
|
|
98
|
+
self._declared = list(self._plugin.settings())
|
|
99
|
+
return self._declared
|
|
100
|
+
|
|
101
|
+
def _call_settings(self, params: dict[str, Any]) -> Settings:
|
|
102
|
+
# Absent when nobody set anything — the common case, not an error.
|
|
103
|
+
raw = params.get("settings")
|
|
104
|
+
return Settings(raw if isinstance(raw, dict) else None, self._declared_settings())
|
|
105
|
+
|
|
106
|
+
def join_streams(self, timeout: float = 5) -> None:
|
|
107
|
+
"""Let in-flight stream pumps flush their final frames on exit."""
|
|
108
|
+
for t in self._stream_threads:
|
|
109
|
+
t.join(timeout=timeout)
|
|
110
|
+
|
|
111
|
+
def handle(
|
|
112
|
+
self, method: str | None, req_id: Any, params: dict[str, Any]
|
|
113
|
+
) -> tuple[dict[str, Any] | None, Callable[[], None] | None]:
|
|
114
|
+
"""Return ``(reply, post_action)``. The caller writes ``reply``
|
|
115
|
+
(nothing if ``None``), then runs ``post_action`` if present.
|
|
116
|
+
Streaming starts in ``post_action`` so the ack is always on the
|
|
117
|
+
wire before the first ``$/stream/data`` frame."""
|
|
118
|
+
try:
|
|
119
|
+
result, post = self._invoke_method(method, params)
|
|
120
|
+
except _MethodNotFound:
|
|
121
|
+
return (None if req_id is None else _err(req_id, -32601, f"method not found: {method}")), None
|
|
122
|
+
except Exception as ex: # noqa: BLE001 — surface as JSON-RPC error, never crash the loop
|
|
123
|
+
return (None if req_id is None else _err(req_id, -32000, f"{type(ex).__name__}: {ex}")), None
|
|
124
|
+
reply = None if req_id is None else {"jsonrpc": "2.0", "id": req_id, "result": result}
|
|
125
|
+
return reply, post
|
|
126
|
+
|
|
127
|
+
def _invoke_method(
|
|
128
|
+
self, method: str | None, params: dict[str, Any]
|
|
129
|
+
) -> tuple[Any, Callable[[], None] | None]:
|
|
130
|
+
if method == "initialize":
|
|
131
|
+
reply: dict[str, Any] = {
|
|
132
|
+
"name": self._plugin.name,
|
|
133
|
+
"id": self._plugin.id,
|
|
134
|
+
"iconSvg": self._plugin.icon_svg,
|
|
135
|
+
"settings": [s.to_dict() for s in self._declared_settings()],
|
|
136
|
+
"protocolVersion": SIDECAR_PROTOCOL_VERSION,
|
|
137
|
+
"capabilities": _capabilities(self._plugin),
|
|
138
|
+
}
|
|
139
|
+
# Kuestenlogik/Bowire#1003 - absent when the plugin declares none.
|
|
140
|
+
form = self._plugin.manual_entry()
|
|
141
|
+
if form is not None:
|
|
142
|
+
reply["manualEntry"] = form.to_dict()
|
|
143
|
+
return reply, None
|
|
144
|
+
if method == "ping":
|
|
145
|
+
return "pong", None
|
|
146
|
+
if method == "shutdown":
|
|
147
|
+
try:
|
|
148
|
+
self._plugin.shutdown()
|
|
149
|
+
finally:
|
|
150
|
+
self._stop.set()
|
|
151
|
+
return True, None
|
|
152
|
+
if method == "discover":
|
|
153
|
+
services = self._plugin.discover(
|
|
154
|
+
params.get("serverUrl", ""),
|
|
155
|
+
bool(params.get("showInternalServices", False)),
|
|
156
|
+
self._call_settings(params),
|
|
157
|
+
)
|
|
158
|
+
return [s.to_dict() for s in services], None
|
|
159
|
+
if method == "invoke":
|
|
160
|
+
return self._plugin.invoke(
|
|
161
|
+
params.get("serverUrl", ""),
|
|
162
|
+
params.get("service", ""),
|
|
163
|
+
params.get("method", ""),
|
|
164
|
+
list(params.get("jsonMessages") or []),
|
|
165
|
+
bool(params.get("showInternalServices", False)),
|
|
166
|
+
dict(params.get("metadata") or {}),
|
|
167
|
+
self._call_settings(params),
|
|
168
|
+
).to_dict(), None
|
|
169
|
+
if method == "invokeStream":
|
|
170
|
+
stream_id = params.get("streamId")
|
|
171
|
+
|
|
172
|
+
def start_pump() -> None:
|
|
173
|
+
t = threading.Thread(target=self._pump_stream, args=(stream_id, params), daemon=True)
|
|
174
|
+
self._stream_threads.append(t)
|
|
175
|
+
t.start()
|
|
176
|
+
|
|
177
|
+
# ack now; the caller starts the pump after writing it
|
|
178
|
+
return {"streamId": stream_id}, start_pump
|
|
179
|
+
raise _MethodNotFound
|
|
180
|
+
|
|
181
|
+
def _pump_stream(self, stream_id: Any, params: dict[str, Any]) -> None:
|
|
182
|
+
error: dict[str, Any] | None = None
|
|
183
|
+
try:
|
|
184
|
+
for message in self._plugin.invoke_stream(
|
|
185
|
+
params.get("serverUrl", ""),
|
|
186
|
+
params.get("service", ""),
|
|
187
|
+
params.get("method", ""),
|
|
188
|
+
list(params.get("jsonMessages") or []),
|
|
189
|
+
bool(params.get("showInternalServices", False)),
|
|
190
|
+
dict(params.get("metadata") or {}),
|
|
191
|
+
self._call_settings(params),
|
|
192
|
+
):
|
|
193
|
+
if self._stop.is_set():
|
|
194
|
+
break
|
|
195
|
+
self._emit({"jsonrpc": "2.0", "method": "$/stream/data",
|
|
196
|
+
"params": {"streamId": stream_id, "message": message}})
|
|
197
|
+
except Exception as ex: # noqa: BLE001
|
|
198
|
+
error = {"code": -32000, "message": f"{type(ex).__name__}: {ex}"}
|
|
199
|
+
finally:
|
|
200
|
+
self._emit({"jsonrpc": "2.0", "method": "$/stream/end",
|
|
201
|
+
"params": {"streamId": stream_id, "error": error}})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class _MethodNotFound(Exception):
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _err(req_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
209
|
+
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class _StdioRuntime:
|
|
213
|
+
"""NDJSON JSON-RPC over stdin/stdout, driving a :class:`_Dispatcher`."""
|
|
214
|
+
|
|
215
|
+
def __init__(self, plugin: BowirePlugin, stdin: TextIO, stdout: TextIO) -> None:
|
|
216
|
+
self._stdin = stdin
|
|
217
|
+
self._stdout = stdout
|
|
218
|
+
self._write_lock = threading.Lock()
|
|
219
|
+
self._dispatcher = _Dispatcher(plugin, self._write)
|
|
220
|
+
|
|
221
|
+
def _write(self, envelope: dict[str, Any]) -> None:
|
|
222
|
+
line = json.dumps(envelope, separators=(",", ":"), ensure_ascii=False)
|
|
223
|
+
with self._write_lock:
|
|
224
|
+
self._stdout.write(line)
|
|
225
|
+
self._stdout.write("\n")
|
|
226
|
+
self._stdout.flush()
|
|
227
|
+
|
|
228
|
+
def run(self) -> int:
|
|
229
|
+
for raw in self._stdin:
|
|
230
|
+
line = raw.strip()
|
|
231
|
+
if not line:
|
|
232
|
+
continue
|
|
233
|
+
try:
|
|
234
|
+
msg = json.loads(line)
|
|
235
|
+
except json.JSONDecodeError:
|
|
236
|
+
continue
|
|
237
|
+
if not isinstance(msg, dict):
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
reply, post = self._dispatcher.handle(msg.get("method"), msg.get("id"), msg.get("params") or {})
|
|
241
|
+
if reply is not None:
|
|
242
|
+
self._write(reply)
|
|
243
|
+
if post is not None:
|
|
244
|
+
post() # start streaming only after the ack is on the wire
|
|
245
|
+
if self._dispatcher.stopped:
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
self._dispatcher.join_streams()
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def run(plugin: BowirePlugin, *, stdin: TextIO | None = None, stdout: TextIO | None = None) -> int:
|
|
253
|
+
"""Run ``plugin`` against the JSON-RPC-over-stdio contract until the
|
|
254
|
+
host sends ``shutdown`` (or stdin closes). Blocks; returns 0.
|
|
255
|
+
|
|
256
|
+
For the HTTP/SSE transport use :func:`bowire_plugin.run_http`.
|
|
257
|
+
"""
|
|
258
|
+
return _StdioRuntime(plugin, stdin or sys.stdin, stdout or sys.stdout).run()
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Copyright 2026 Küstenlogik
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The setting values the host sends with each call (Kuestenlogik/Bowire#693).
|
|
4
|
+
|
|
5
|
+
A plugin declares its settings once, in :meth:`BowirePlugin.settings`; the
|
|
6
|
+
host renders them in its Settings dialog and, on every ``discover`` /
|
|
7
|
+
``invoke`` / ``invokeStream``, puts the values somebody actually set in a
|
|
8
|
+
``settings`` object on the params. This module is the other half: what a
|
|
9
|
+
plugin reads those values through.
|
|
10
|
+
|
|
11
|
+
Three properties of the wire shape the API:
|
|
12
|
+
|
|
13
|
+
- **Per call, not per process.** The values travel with each request, so a
|
|
14
|
+
changed setting takes effect on the next call without a restart. The
|
|
15
|
+
runtime builds a fresh :class:`Settings` for every call.
|
|
16
|
+
- **Strings, whatever the declared type.** The dialog sends what a form
|
|
17
|
+
control produced; the plugin parses what it declared. The typed accessors
|
|
18
|
+
do that parse, with the same rules the host's own .NET accessors use.
|
|
19
|
+
- **Only keys that are set.** An absent key means "use the default you
|
|
20
|
+
declared" — so the accessors fall back to the declared ``default_value``,
|
|
21
|
+
and a plugin whose settings nobody touched sees an empty mapping.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
28
|
+
from typing import Any, TypeVar
|
|
29
|
+
|
|
30
|
+
from ._models import PluginSetting
|
|
31
|
+
|
|
32
|
+
_T = TypeVar("_T")
|
|
33
|
+
|
|
34
|
+
# ASCII digits only: Python's int()/float() also accept other Unicode digits,
|
|
35
|
+
# underscores, "nan" and "inf", none of which a form control produces and none
|
|
36
|
+
# of which the host's invariant-culture parse accepts.
|
|
37
|
+
_INT = re.compile(r"[+-]?[0-9]+", re.ASCII)
|
|
38
|
+
_FLOAT = re.compile(r"[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?", re.ASCII)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Unset:
|
|
42
|
+
"""Marks "no explicit default given" — ``None`` is a legal default."""
|
|
43
|
+
|
|
44
|
+
def __repr__(self) -> str:
|
|
45
|
+
return "<declared default>"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_UNSET = _Unset()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_bool(text: str) -> bool | None:
|
|
52
|
+
t = text.strip().lower()
|
|
53
|
+
if t == "true":
|
|
54
|
+
return True
|
|
55
|
+
if t == "false":
|
|
56
|
+
return False
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_int(text: str) -> int | None:
|
|
61
|
+
t = text.strip()
|
|
62
|
+
return int(t) if _INT.fullmatch(t) else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_float(text: str) -> float | None:
|
|
66
|
+
t = text.strip()
|
|
67
|
+
if not _FLOAT.fullmatch(t):
|
|
68
|
+
return None
|
|
69
|
+
value = float(t)
|
|
70
|
+
return value if math.isfinite(value) else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# The declared default is any JSON value the plugin wrote, so it is coerced
|
|
74
|
+
# rather than parsed: ``default_value=5`` for a number setting is an int, not
|
|
75
|
+
# the string "5". A bool is never taken as a number, although Python's bool
|
|
76
|
+
# is an int — ``True`` as a probe window is a mistake, not ``1``.
|
|
77
|
+
|
|
78
|
+
def _coerce_str(value: Any) -> str | None:
|
|
79
|
+
if isinstance(value, str):
|
|
80
|
+
return value
|
|
81
|
+
if isinstance(value, bool):
|
|
82
|
+
return "true" if value else "false"
|
|
83
|
+
if isinstance(value, (int, float)):
|
|
84
|
+
return str(value)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _coerce_bool(value: Any) -> bool | None:
|
|
89
|
+
if isinstance(value, bool):
|
|
90
|
+
return value
|
|
91
|
+
return _parse_bool(value) if isinstance(value, str) else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _coerce_int(value: Any) -> int | None:
|
|
95
|
+
if isinstance(value, bool):
|
|
96
|
+
return None
|
|
97
|
+
if isinstance(value, int):
|
|
98
|
+
return value
|
|
99
|
+
if isinstance(value, float):
|
|
100
|
+
return int(value) if value.is_integer() else None
|
|
101
|
+
return _parse_int(value) if isinstance(value, str) else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _coerce_float(value: Any) -> float | None:
|
|
105
|
+
if isinstance(value, bool):
|
|
106
|
+
return None
|
|
107
|
+
if isinstance(value, (int, float)):
|
|
108
|
+
return float(value)
|
|
109
|
+
return _parse_float(value) if isinstance(value, str) else None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Settings(Mapping[str, str]):
|
|
113
|
+
"""The values set for this plugin's declared settings, for one call.
|
|
114
|
+
|
|
115
|
+
A read-only mapping of the raw strings the host sent — ``settings["key"]``,
|
|
116
|
+
``"key" in settings``, ``settings.get("key")`` — plus typed accessors
|
|
117
|
+
that parse the string and fall back to the default the plugin declared::
|
|
118
|
+
|
|
119
|
+
def invoke(self, server_url, service, method, json_messages,
|
|
120
|
+
show_internal, metadata, settings):
|
|
121
|
+
window = settings.get_float("probeWindow") # 5.0 until somebody sets it
|
|
122
|
+
verbose = settings.get_bool("verbose")
|
|
123
|
+
|
|
124
|
+
Each accessor resolves in this order:
|
|
125
|
+
|
|
126
|
+
1. the value the host sent, when it parses as the requested type;
|
|
127
|
+
2. ``default``, when the caller passed one;
|
|
128
|
+
3. the ``default_value`` declared for that key in
|
|
129
|
+
:meth:`BowirePlugin.settings`;
|
|
130
|
+
4. otherwise :class:`KeyError` — the key has no value and nothing to fall
|
|
131
|
+
back to, which is a bug in the plugin rather than in its input.
|
|
132
|
+
|
|
133
|
+
A value that does not parse falls back exactly like an absent one, which
|
|
134
|
+
is what the host's .NET accessors do: an operator's typo in a probe
|
|
135
|
+
window degrades to the default instead of failing every discovery.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
values: Mapping[str, Any] | None = None,
|
|
141
|
+
declared: Iterable[PluginSetting] = (),
|
|
142
|
+
) -> None:
|
|
143
|
+
# Strings on the wire; a number or bool is tolerated the way the host
|
|
144
|
+
# tolerates one in a hand-edited settings file. Anything else has no
|
|
145
|
+
# text form and is dropped rather than guessed at.
|
|
146
|
+
self._values: dict[str, str] = {}
|
|
147
|
+
for key, value in (values or {}).items():
|
|
148
|
+
text = _coerce_str(value)
|
|
149
|
+
if isinstance(key, str) and text is not None:
|
|
150
|
+
self._values[key] = text
|
|
151
|
+
self._defaults: dict[str, Any] = {s.key: s.default_value for s in declared}
|
|
152
|
+
|
|
153
|
+
def __getitem__(self, key: str) -> str:
|
|
154
|
+
return self._values[key]
|
|
155
|
+
|
|
156
|
+
def __iter__(self) -> Iterator[str]:
|
|
157
|
+
return iter(self._values)
|
|
158
|
+
|
|
159
|
+
def __len__(self) -> int:
|
|
160
|
+
return len(self._values)
|
|
161
|
+
|
|
162
|
+
def __repr__(self) -> str:
|
|
163
|
+
return f"Settings({self._values!r})"
|
|
164
|
+
|
|
165
|
+
def get_str(self, key: str, default: str | _Unset = _UNSET) -> str:
|
|
166
|
+
"""The value as text. An empty string is a value someone set, not an
|
|
167
|
+
absent one."""
|
|
168
|
+
return self._resolve(key, _coerce_str, default, "a string")
|
|
169
|
+
|
|
170
|
+
def get_bool(self, key: str, default: bool | _Unset = _UNSET) -> bool:
|
|
171
|
+
"""The value as a flag: ``true`` / ``false``, case-insensitive."""
|
|
172
|
+
return self._resolve(key, _coerce_bool, default, "a bool")
|
|
173
|
+
|
|
174
|
+
def get_int(self, key: str, default: int | _Unset = _UNSET) -> int:
|
|
175
|
+
"""The value as a whole number (ASCII digits, optional sign)."""
|
|
176
|
+
return self._resolve(key, _coerce_int, default, "an int")
|
|
177
|
+
|
|
178
|
+
def get_float(self, key: str, default: float | _Unset = _UNSET) -> float:
|
|
179
|
+
"""The value as a finite number, decimal point and exponent allowed."""
|
|
180
|
+
return self._resolve(key, _coerce_float, default, "a float")
|
|
181
|
+
|
|
182
|
+
def _resolve(
|
|
183
|
+
self,
|
|
184
|
+
key: str,
|
|
185
|
+
convert: Callable[[Any], _T | None],
|
|
186
|
+
default: _T | _Unset,
|
|
187
|
+
kind: str,
|
|
188
|
+
) -> _T:
|
|
189
|
+
raw = self._values.get(key)
|
|
190
|
+
if raw is not None:
|
|
191
|
+
parsed = convert(raw)
|
|
192
|
+
if parsed is not None:
|
|
193
|
+
return parsed
|
|
194
|
+
if not isinstance(default, _Unset):
|
|
195
|
+
return default
|
|
196
|
+
if key in self._defaults:
|
|
197
|
+
declared = convert(self._defaults[key])
|
|
198
|
+
if declared is not None:
|
|
199
|
+
return declared
|
|
200
|
+
raise KeyError(
|
|
201
|
+
f"setting '{key}' has no value that reads as {kind} and declares no default "
|
|
202
|
+
f"that does; declare one in settings() or pass default="
|
|
203
|
+
)
|