command-router 1.0.0b1__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.
- command_router/__init__.py +654 -0
- command_router/__main__.py +17 -0
- command_router/_example/fixtures/README.md +72 -0
- command_router/_example/fixtures/__init__.py +86 -0
- command_router/_example/fixtures/err.json5 +28 -0
- command_router/_example/fixtures/grammars.json5 +15 -0
- command_router/_example/fixtures/grammars.py +164 -0
- command_router/_example/fixtures/grammars.toml +8 -0
- command_router/lib/commands/__init__.py +73 -0
- command_router/lib/commands/context.py +159 -0
- command_router/lib/commands/dispatcher/__init__.py +277 -0
- command_router/lib/commands/dispatcher/nodes/__init__.py +16 -0
- command_router/lib/commands/dispatcher/nodes/argument.py +55 -0
- command_router/lib/commands/dispatcher/nodes/command.py +94 -0
- command_router/lib/commands/dispatcher/nodes/kinds.py +17 -0
- command_router/lib/commands/dispatcher/nodes/literal.py +23 -0
- command_router/lib/commands/dispatcher/nodes/root.py +21 -0
- command_router/lib/commands/typing.py +123 -0
- command_router/lib/control/__init__.py +24 -0
- command_router/lib/control/api/__init__.py +103 -0
- command_router/lib/control/api/context.py +182 -0
- command_router/lib/control/api/control.py +577 -0
- command_router/lib/control/api/result.py +352 -0
- command_router/lib/control/compiler.py +356 -0
- command_router/lib/control/fixture_loader.py +137 -0
- command_router/lib/control/grammar.py +421 -0
- command_router/lib/grammar/loader.py +84 -0
- command_router/lib/grammar/parsers.py +58 -0
- command_router/py.typed +0 -0
- command_router/sdk/__init__.py +74 -0
- command_router/sdk/backend/__init__.py +24 -0
- command_router/sdk/backend/builder.py +253 -0
- command_router/sdk/backend/holder.py +73 -0
- command_router/sdk/backend/loader.py +55 -0
- command_router/sdk/backend/settings.py +300 -0
- command_router/sdk/backend/setup.py +149 -0
- command_router/sdk/user.py +106 -0
- command_router/suggestions/__init__.py +314 -0
- command_router/suggestions/algo.py +173 -0
- command_router/suggestions/context.py +98 -0
- command_router/suite/__init__.py +392 -0
- command_router/suite/css.py +98 -0
- command_router/utils/__init__.py +10 -0
- command_router/utils/cli/__init__.py +426 -0
- command_router/utils/cli/env_flags.py +284 -0
- command_router/utils/context.py +183 -0
- command_router/utils/lazy_server.py +60 -0
- command_router/utils/logger.py +383 -0
- command_router/utils/status.py +119 -0
- command_router-1.0.0b1.dist-info/METADATA +120 -0
- command_router-1.0.0b1.dist-info/RECORD +53 -0
- command_router-1.0.0b1.dist-info/WHEEL +4 -0
- command_router-1.0.0b1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
# The Clear BSD License
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2026 Ian Hylton
|
|
4
|
+
# All rights reserved.
|
|
5
|
+
|
|
6
|
+
__all__ = ("start", "init_flags")
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import tomllib
|
|
13
|
+
from http.server import HTTPServer
|
|
14
|
+
from importlib import resources
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
import json5
|
|
20
|
+
|
|
21
|
+
from command_router.lib.control import ControlInitialization, ControlResult
|
|
22
|
+
from command_router.lib.control.api import *
|
|
23
|
+
from command_router.lib.grammar.loader import *
|
|
24
|
+
from command_router.sdk import Fixtures
|
|
25
|
+
from command_router.suggestions import *
|
|
26
|
+
from command_router.suite import *
|
|
27
|
+
from command_router.utils import (
|
|
28
|
+
LazyServer,
|
|
29
|
+
Status,
|
|
30
|
+
default_fixtures_dir,
|
|
31
|
+
describe_flags,
|
|
32
|
+
flag_names,
|
|
33
|
+
flags,
|
|
34
|
+
init_flags,
|
|
35
|
+
log,
|
|
36
|
+
normalize_bare_options,
|
|
37
|
+
paths,
|
|
38
|
+
stat,
|
|
39
|
+
uctx,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_Dict = dict[str, Any]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _check_genesis_dir(path: Path) -> str | None:
|
|
46
|
+
"""Return an error message when *path* cannot serve as a fixture directory."""
|
|
47
|
+
if not path.exists():
|
|
48
|
+
return f"genesis given, but directory {path} does not exist"
|
|
49
|
+
if not (path / "__init__.py").exists():
|
|
50
|
+
return "genesis given, but the existing directory does not contain its `__init__.py` file"
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class __Router:
|
|
55
|
+
grammars: _Dict = {}
|
|
56
|
+
info: _Dict = {}
|
|
57
|
+
control = Api.Control
|
|
58
|
+
|
|
59
|
+
def normalize(self, *t: _Dict) -> None:
|
|
60
|
+
grammars, info = t
|
|
61
|
+
self.grammars.update(grammars)
|
|
62
|
+
self.info.update(info)
|
|
63
|
+
log.debug(
|
|
64
|
+
"normalized grammar batch (commands=%d, info_keys=%s, totals=%d)",
|
|
65
|
+
len(grammars),
|
|
66
|
+
tuple(info),
|
|
67
|
+
len(self.grammars),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def lazy_init(self) -> None:
|
|
71
|
+
"""Load the first valid grammar received through the local HTTP endpoint."""
|
|
72
|
+
|
|
73
|
+
log.info("starting lazy grammar server")
|
|
74
|
+
# Keep HTTP grammars in a persistent cache between application runs.
|
|
75
|
+
# The packaged templates live somewhere read-only once installed, so
|
|
76
|
+
# fall back to a working-directory cache when no repo checkout exists.
|
|
77
|
+
http_dir = paths.FIXTURES_HTTP if paths.FIXTURES.is_dir() else Path.cwd() / ".command-router" / "http"
|
|
78
|
+
# Stop serving requests after one grammar loads successfully.
|
|
79
|
+
state = {"success": False}
|
|
80
|
+
# Let the request handler update this router instance.
|
|
81
|
+
router = self
|
|
82
|
+
|
|
83
|
+
# noinspection bad-argument-type
|
|
84
|
+
# Define the small HTTP protocol used to receive a grammar.
|
|
85
|
+
class Lazy(LazyServer):
|
|
86
|
+
# noinspection pep8-naming
|
|
87
|
+
def do_POST(self) -> None:
|
|
88
|
+
self.post(self.__class__.__name__)
|
|
89
|
+
|
|
90
|
+
# Detect whether the body is a JSON5 or TOML object.
|
|
91
|
+
suffix: str | None = None
|
|
92
|
+
for candidate, parser in ((".json5", json5.loads), (".toml", tomllib.loads)):
|
|
93
|
+
try:
|
|
94
|
+
parsed = parser(self.text)
|
|
95
|
+
except ValueError, tomllib.TOMLDecodeError:
|
|
96
|
+
continue
|
|
97
|
+
if isinstance(parsed, dict):
|
|
98
|
+
suffix = candidate
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
if suffix is None:
|
|
102
|
+
self._invalid("HTTP body is not valid JSON5 or TOML.")
|
|
103
|
+
return
|
|
104
|
+
log.debug("lazy request recognized as %s", suffix)
|
|
105
|
+
|
|
106
|
+
# Reuse an identical grammar already saved by an earlier run.
|
|
107
|
+
existing: Path | None = None
|
|
108
|
+
try:
|
|
109
|
+
for candidate in http_dir.glob(f"grammar-*{suffix}"):
|
|
110
|
+
try:
|
|
111
|
+
if candidate.is_file() and candidate.read_bytes() == self.payload:
|
|
112
|
+
existing = candidate
|
|
113
|
+
break
|
|
114
|
+
except OSError:
|
|
115
|
+
continue
|
|
116
|
+
except OSError:
|
|
117
|
+
# Handle the request as new when the cache cannot be scanned.
|
|
118
|
+
log.warning("could not scan the persisted lazy grammar cache")
|
|
119
|
+
log.error("continuing with this request as a new grammar")
|
|
120
|
+
|
|
121
|
+
if existing is not None:
|
|
122
|
+
# Load the cached grammar into this router instance.
|
|
123
|
+
log.info("reusing persisted lazy grammar %s", existing.name)
|
|
124
|
+
result = load_grammars(existing)
|
|
125
|
+
if isinstance(result, int) or isinstance(result, Status):
|
|
126
|
+
self._invalid("HTTP grammar has an invalid schema.")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# noinspection not-iterable
|
|
130
|
+
router.normalize(*result)
|
|
131
|
+
log.stderr(0)
|
|
132
|
+
log.debug("reused grammar normalized (%d commands(s))", len(result[0]))
|
|
133
|
+
state["success"] = True
|
|
134
|
+
self._reply(204)
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
# Save unseen grammars under a unique filename.
|
|
138
|
+
target = http_dir / f"grammar-{uuid4().hex}{suffix}"
|
|
139
|
+
try:
|
|
140
|
+
http_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
target.write_bytes(self.payload)
|
|
142
|
+
result = load_grammars(target)
|
|
143
|
+
except OSError as exception:
|
|
144
|
+
log.error("could not persist lazy grammar %s: %s", target, exception)
|
|
145
|
+
self._invalid("Could not save the HTTP grammar.")
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
# Remove files that fail schema validation.
|
|
149
|
+
if isinstance(result, int) or isinstance(result, Status):
|
|
150
|
+
target.unlink(missing_ok=True)
|
|
151
|
+
self._invalid("HTTP grammar has an invalid schema.")
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
# Store the grammar and signal that initialization is complete.
|
|
155
|
+
# noinspection not-iterable
|
|
156
|
+
router.normalize(*result)
|
|
157
|
+
log.stderr(0)
|
|
158
|
+
log.info("saved and loaded lazy grammar %s", target.name)
|
|
159
|
+
log.debug("saved grammar normalized (%d commands(s))", len(result[0]))
|
|
160
|
+
state["success"] = True
|
|
161
|
+
self._reply(204)
|
|
162
|
+
|
|
163
|
+
# noinspection bad-argument-type
|
|
164
|
+
# Bind an ephemeral localhost port and announce it to the client.
|
|
165
|
+
addr = uctx.CMD_ROUTER_DEFAULT_ADDRESS
|
|
166
|
+
server = HTTPServer((addr, uctx.CMD_ROUTER_DEFAULT_PORT), Lazy)
|
|
167
|
+
log.info("lazy grammar server listening on %s", addr)
|
|
168
|
+
log.stderr(server.server_port)
|
|
169
|
+
try:
|
|
170
|
+
# Process requests until a valid grammar is accepted.
|
|
171
|
+
while not state["success"]:
|
|
172
|
+
server.handle_request()
|
|
173
|
+
finally:
|
|
174
|
+
# Always release the listening socket.
|
|
175
|
+
server.server_close()
|
|
176
|
+
log.info("lazy grammar server stopped")
|
|
177
|
+
log.raw("lazy grammar server: ", end="")
|
|
178
|
+
log.raw("OK" if state["success"] else "FAILURE")
|
|
179
|
+
|
|
180
|
+
def grammar_init(self, f: list[Path] | Path) -> tuple[_Dict, _Dict] | Status:
|
|
181
|
+
files = f if isinstance(f, list) else [f]
|
|
182
|
+
log.info("loading %d grammar file(s)", len(files))
|
|
183
|
+
|
|
184
|
+
# Keep bare filenames fast while also accepting full file or directory paths.
|
|
185
|
+
ignored_names: set[str] = set()
|
|
186
|
+
ignored_paths: set[Path] = set()
|
|
187
|
+
for value in flags.ignore:
|
|
188
|
+
candidate = Path(value).expanduser()
|
|
189
|
+
if not candidate.is_absolute() and len(candidate.parts) == 1 and not candidate.is_dir():
|
|
190
|
+
ignored_names.add(candidate.name)
|
|
191
|
+
continue
|
|
192
|
+
try:
|
|
193
|
+
ignored_paths.add(candidate.resolve())
|
|
194
|
+
except OSError, RuntimeError:
|
|
195
|
+
ignored_paths.add(candidate.absolute())
|
|
196
|
+
|
|
197
|
+
# Load grammars unless their name or path was explicitly ignored.
|
|
198
|
+
for file in files:
|
|
199
|
+
if file.name in ignored_names:
|
|
200
|
+
log.debug("ignoring grammar file by name: %s", file.name)
|
|
201
|
+
continue
|
|
202
|
+
if ignored_paths:
|
|
203
|
+
try:
|
|
204
|
+
file_path = file.resolve()
|
|
205
|
+
except OSError, RuntimeError:
|
|
206
|
+
file_path = file.absolute()
|
|
207
|
+
if any(file_path == ignored or ignored in file_path.parents for ignored in ignored_paths):
|
|
208
|
+
log.debug("ignoring grammar file by path: %s", file)
|
|
209
|
+
continue
|
|
210
|
+
log.debug("loading grammar file %s", file)
|
|
211
|
+
result = load_grammars(file)
|
|
212
|
+
if isinstance(result, Status):
|
|
213
|
+
if result.name == stat.UnsupportedGrammarFormatError().name:
|
|
214
|
+
log.debug("skipped unsupported grammar file %s", file)
|
|
215
|
+
continue
|
|
216
|
+
log.error("grammar file %s failed with code %s", file, result)
|
|
217
|
+
return result
|
|
218
|
+
# noinspection not-iterable
|
|
219
|
+
self.normalize(*result)
|
|
220
|
+
|
|
221
|
+
log.info("grammar loading completed (%d commands(s))", len(self.grammars))
|
|
222
|
+
return self.grammars, self.info
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def genesis_source(self) -> Path | None:
|
|
226
|
+
"""Return the validated genesis directory, or None when unset or invalid.
|
|
227
|
+
|
|
228
|
+
A genesis directory must exist and contain a ``__init__.py`` file so
|
|
229
|
+
the fixture loader can treat it as a package. String values are
|
|
230
|
+
coerced for callers that bypass ``init_flags``. Failures are logged
|
|
231
|
+
as critical diagnostics; callers decide whether that aborts startup,
|
|
232
|
+
so grammar discovery and control initialization share one check.
|
|
233
|
+
"""
|
|
234
|
+
if flags.genesis is None:
|
|
235
|
+
return None
|
|
236
|
+
genesis = flags.genesis if isinstance(flags.genesis, Path) else Path(flags.genesis)
|
|
237
|
+
if (message := _check_genesis_dir(genesis)) is not None:
|
|
238
|
+
log.critical("%s", message)
|
|
239
|
+
return None
|
|
240
|
+
return genesis
|
|
241
|
+
|
|
242
|
+
def control_init(self) -> bool:
|
|
243
|
+
"""Load fixture behavior and compile the active command surface."""
|
|
244
|
+
log.info("initializing control")
|
|
245
|
+
|
|
246
|
+
r: ControlInitialization
|
|
247
|
+
|
|
248
|
+
if flags.genesis is None:
|
|
249
|
+
fixture = default_fixtures_dir()
|
|
250
|
+
else:
|
|
251
|
+
genesis = self.genesis_source
|
|
252
|
+
if genesis is None:
|
|
253
|
+
return False
|
|
254
|
+
log.info("genesis given, fixtures will be loaded from %s", genesis)
|
|
255
|
+
fixture = genesis
|
|
256
|
+
r = self.control.initialize(
|
|
257
|
+
self.grammars,
|
|
258
|
+
fixture=fixture,
|
|
259
|
+
keep_help=not flags.no_help,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if not r.ok:
|
|
263
|
+
log.error("control initialization failed (%s): %s", r.code, r.message)
|
|
264
|
+
return False
|
|
265
|
+
log.info("control ready (%d grammar(s))", r.command_count)
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
_router = __Router()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class __CommandRouter:
|
|
273
|
+
_was_i_initialized = False
|
|
274
|
+
"""Literal variable to track whether the router was initialized using ``__init__``."""
|
|
275
|
+
|
|
276
|
+
def __init__(self) -> None:
|
|
277
|
+
self._grammars = _router.grammars
|
|
278
|
+
self._info = _router.info
|
|
279
|
+
self.control = _router.control
|
|
280
|
+
self._was_i_initialized = True
|
|
281
|
+
log.info("router instance was initialized. use `initialize` to actually start the router")
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def initialize(self) -> Status:
|
|
285
|
+
"""The main entry point for the router."""
|
|
286
|
+
if not self._was_i_initialized:
|
|
287
|
+
return stat.ImpossibleControlState()
|
|
288
|
+
log.debug(
|
|
289
|
+
"flags (lazy=%s, test_suite=%s, no_help=%s, ignore=%s, genesis=%s)",
|
|
290
|
+
flags.lazy,
|
|
291
|
+
flags.test_suite,
|
|
292
|
+
flags.no_help,
|
|
293
|
+
flags.ignore,
|
|
294
|
+
flags.genesis,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Informational and scaffolding flags short-circuit before anything
|
|
298
|
+
# boots: verbose help is read-only, and init only writes files.
|
|
299
|
+
if flags.verbose_help is not None:
|
|
300
|
+
return self._verbose_help(flags.verbose_help)
|
|
301
|
+
if flags.init is not None:
|
|
302
|
+
return self._scaffold(flags.init)
|
|
303
|
+
|
|
304
|
+
# Get every single file in fixtures/*
|
|
305
|
+
if flags.lazy:
|
|
306
|
+
log.info("lazy grammar loading enabled")
|
|
307
|
+
_router.lazy_init()
|
|
308
|
+
ctrl_init = _router.control_init()
|
|
309
|
+
if ctrl_init and flags.test_suite:
|
|
310
|
+
c = self._test_suite_loop()
|
|
311
|
+
return c
|
|
312
|
+
log.info("initialization completed")
|
|
313
|
+
|
|
314
|
+
genesis = flags.genesis
|
|
315
|
+
if genesis is None:
|
|
316
|
+
grammar_source = default_fixtures_dir()
|
|
317
|
+
else:
|
|
318
|
+
grammar_source = Path(genesis)
|
|
319
|
+
try:
|
|
320
|
+
files = [path for path in grammar_source.iterdir() if path.is_file()]
|
|
321
|
+
except OSError as exception:
|
|
322
|
+
log.critical("cannot list grammar source %s: %s", grammar_source, exception)
|
|
323
|
+
return stat.Abort()
|
|
324
|
+
log.debug("discovered %d fixture file(s) in %s", len(files), grammar_source)
|
|
325
|
+
result = _router.grammar_init(files)
|
|
326
|
+
if isinstance(result, Status):
|
|
327
|
+
log.error("grammar initialization failed (%s); continuing with loaded data", result)
|
|
328
|
+
|
|
329
|
+
# The test-suite loop doesn't need to be initialized for embedded use.
|
|
330
|
+
ctrl_init = _router.control_init()
|
|
331
|
+
|
|
332
|
+
log.debug("normalized grammars=%r; info=%r", self._grammars, self._info)
|
|
333
|
+
|
|
334
|
+
suggestions_server: HTTPServer | None = None
|
|
335
|
+
if flags.no_suggestions_server:
|
|
336
|
+
log.info("suggestions server completely disabled; not binding")
|
|
337
|
+
elif flags.serve:
|
|
338
|
+
log.info("serve mode owns the stderr transport; suggestions server not binding")
|
|
339
|
+
else:
|
|
340
|
+
# Bind even when `suggestions_server` is off, so disabled use stays
|
|
341
|
+
# visible instead of a dropped connection: the handler logs at
|
|
342
|
+
# error level, ignores POST bodies (204), and answers GET with an
|
|
343
|
+
# empty JSON list (200), while returning SuggestionServerDisabled.
|
|
344
|
+
try:
|
|
345
|
+
sgs_addr = lazy_suggest_srv_ctx.address
|
|
346
|
+
sgs_port = lazy_suggest_srv_ctx.port
|
|
347
|
+
suggestions_server = HTTPServer((sgs_addr, sgs_port), LazySuggestionsServer)
|
|
348
|
+
log.debug("initializing thread of lazy suggestions server")
|
|
349
|
+
_sgs_thread = threading.Thread(
|
|
350
|
+
target=suggestions_server.serve_forever,
|
|
351
|
+
kwargs={"poll_interval": 0.2},
|
|
352
|
+
name="lazy-suggestions-server",
|
|
353
|
+
daemon=True,
|
|
354
|
+
)
|
|
355
|
+
_sgs_thread.start()
|
|
356
|
+
log.info("lazy suggestions server listening on %s:%d", sgs_addr, suggestions_server.server_port)
|
|
357
|
+
except OSError as exception:
|
|
358
|
+
log.error("could not start lazy suggestions server: %s", exception)
|
|
359
|
+
suggestions_server = None
|
|
360
|
+
|
|
361
|
+
ultima: Status
|
|
362
|
+
|
|
363
|
+
# Technically, a lazy initialization is possible, but it's not worth the complexity.
|
|
364
|
+
if ctrl_init and flags.test_suite:
|
|
365
|
+
log.warning("test-suite enabled, giving up control to our suite")
|
|
366
|
+
try:
|
|
367
|
+
ultima = self._test_suite_loop()
|
|
368
|
+
log.info("test-suite loop exited with status %s", ultima)
|
|
369
|
+
return ultima
|
|
370
|
+
finally:
|
|
371
|
+
if suggestions_server is not None:
|
|
372
|
+
suggestions_server.shutdown()
|
|
373
|
+
suggestions_server.server_close()
|
|
374
|
+
|
|
375
|
+
log.info("ready (%d commands grammar(s))", len(self._grammars))
|
|
376
|
+
|
|
377
|
+
if ctrl_init and flags.serve:
|
|
378
|
+
if not flags.json_out:
|
|
379
|
+
log.debug("serve mode implies JSON responses")
|
|
380
|
+
flags.json_out = True
|
|
381
|
+
log.info("serve mode enabled; entering stdin loop")
|
|
382
|
+
try:
|
|
383
|
+
ultima = self._serve_loop()
|
|
384
|
+
log.info("serve loop exited with status %s", ultima)
|
|
385
|
+
return ultima
|
|
386
|
+
finally:
|
|
387
|
+
if suggestions_server is not None:
|
|
388
|
+
suggestions_server.shutdown()
|
|
389
|
+
suggestions_server.server_close()
|
|
390
|
+
|
|
391
|
+
if not ctrl_init and flags.genesis is not None:
|
|
392
|
+
log.critical("router cannot start without an initialized control surface")
|
|
393
|
+
return stat.Abort()
|
|
394
|
+
return stat.Success()
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def main(self) -> Status:
|
|
398
|
+
"""(Alias) The main entry point for the router."""
|
|
399
|
+
return self.initialize
|
|
400
|
+
|
|
401
|
+
def execute(self, command: Any) -> ControlResult:
|
|
402
|
+
"""Execute through the configured control surface."""
|
|
403
|
+
return self.control.execute(command)
|
|
404
|
+
|
|
405
|
+
async def execute_async(self, command: Any) -> ControlResult:
|
|
406
|
+
"""Async counterpart to :meth:`execute`."""
|
|
407
|
+
return await self.control.execute_async(command)
|
|
408
|
+
|
|
409
|
+
@property
|
|
410
|
+
def deeper_level(self) -> Any:
|
|
411
|
+
"""Expose the live Python control state for embedded callers."""
|
|
412
|
+
return self.control.deeper_context
|
|
413
|
+
|
|
414
|
+
def _test_suite_loop(self) -> Status:
|
|
415
|
+
"""Run the Textual REPL until it exits with a status.
|
|
416
|
+
|
|
417
|
+
The router owns this loop because the control API only knows how to
|
|
418
|
+
initialize and execute a command surface; it does not know whether
|
|
419
|
+
the surrounding application wants an interactive session. Command
|
|
420
|
+
failures are represented by ``ControlResult`` (unknown -> suggestions)
|
|
421
|
+
and therefore do not end the session. Failures in the loop itself are
|
|
422
|
+
converted to a status so a bad app crash cannot escape router startup.
|
|
423
|
+
"""
|
|
424
|
+
try:
|
|
425
|
+
initialized = self.control.deeper_context.initialized
|
|
426
|
+
except Exception as exception:
|
|
427
|
+
log.critical("cannot inspect test state before starting the loop: %s", exception)
|
|
428
|
+
return stat.Abort()
|
|
429
|
+
|
|
430
|
+
if not initialized:
|
|
431
|
+
log.error("cannot start test loop; control is not initialized")
|
|
432
|
+
return stat.ControlNotInitializedError()
|
|
433
|
+
|
|
434
|
+
log.info("test loop has started")
|
|
435
|
+
app = REPL(handle=self._handle_command)
|
|
436
|
+
try:
|
|
437
|
+
ultima: Status | None = app.run() # blocks this thread, like input() did
|
|
438
|
+
except KeyboardInterrupt:
|
|
439
|
+
log.warning("test loop interrupted")
|
|
440
|
+
return stat.Interrupted()
|
|
441
|
+
except Exception as exception: # app-level crash
|
|
442
|
+
log.error("repl crashed: %r", exception)
|
|
443
|
+
return stat.Abort()
|
|
444
|
+
if ultima is None:
|
|
445
|
+
log.error("repl exited without a status")
|
|
446
|
+
return stat.ImpossibleControlState()
|
|
447
|
+
log.info("repl exited with status %s", ultima)
|
|
448
|
+
return ultima
|
|
449
|
+
|
|
450
|
+
def _serve_loop(self) -> Status:
|
|
451
|
+
"""Feed dirty stdin lines to the control surface; answers flow to stderr.
|
|
452
|
+
|
|
453
|
+
There is no input schema: every line is fed whole to ``execute``,
|
|
454
|
+
the same path the test suite uses, so empty lines, plain text, and
|
|
455
|
+
broken commands are all accepted eagerly. The control layer prints
|
|
456
|
+
exactly one JSON response line per execution on ``stderr`` itself,
|
|
457
|
+
which keeps framing strictly one-to-one with no printing done here.
|
|
458
|
+
The loop only writes when that emission could not have happened: an
|
|
459
|
+
execution failure, or an action value that JSON cannot serialize
|
|
460
|
+
(re-serialized here as a watchdog, since the control layer swallows
|
|
461
|
+
that emission failure internally). EOF ends the loop with ``Success``.
|
|
462
|
+
"""
|
|
463
|
+
log.info("serve loop started; reading commands from stdin")
|
|
464
|
+
try:
|
|
465
|
+
for line in sys.stdin:
|
|
466
|
+
text = line.rstrip("\n")
|
|
467
|
+
try:
|
|
468
|
+
result = self.execute(text)
|
|
469
|
+
except Exception as exception:
|
|
470
|
+
log.error("serve loop failed to execute %r: %s", text, exception)
|
|
471
|
+
self._serve_fallback(text, str(exception))
|
|
472
|
+
continue
|
|
473
|
+
try:
|
|
474
|
+
result.to_json()
|
|
475
|
+
except (TypeError, ValueError) as exception:
|
|
476
|
+
log.error("serve loop could not serialize the answer to %r: %s", text, exception)
|
|
477
|
+
self._serve_fallback(text, f"unserializable result: {exception}")
|
|
478
|
+
except KeyboardInterrupt:
|
|
479
|
+
log.warning("serve loop interrupted")
|
|
480
|
+
return stat.Interrupted()
|
|
481
|
+
log.info("serve loop reached EOF")
|
|
482
|
+
return stat.Success()
|
|
483
|
+
|
|
484
|
+
@staticmethod
|
|
485
|
+
def _serve_fallback(text: str, message: str) -> None:
|
|
486
|
+
"""Print one JSON error line on ``stderr`` to preserve 1:1 framing.
|
|
487
|
+
|
|
488
|
+
Only used when the control layer could not emit its own response
|
|
489
|
+
line, so a pipe consumer waiting on the next line never hangs.
|
|
490
|
+
"""
|
|
491
|
+
print(
|
|
492
|
+
json.dumps(
|
|
493
|
+
{
|
|
494
|
+
"ok": False,
|
|
495
|
+
"input": text,
|
|
496
|
+
"value": None,
|
|
497
|
+
"suggestions": [],
|
|
498
|
+
"error": {"message": message},
|
|
499
|
+
"message": message,
|
|
500
|
+
}
|
|
501
|
+
),
|
|
502
|
+
file=sys.stderr,
|
|
503
|
+
flush=True,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
def _verbose_help(self, name: str) -> Status:
|
|
507
|
+
"""Print flag documentation; a full dump asks first on terminals.
|
|
508
|
+
|
|
509
|
+
Dumping every flag behind a bare `--verbose-help` prints the whole
|
|
510
|
+
environment, so interactive use confirms first. Piped input cannot
|
|
511
|
+
answer, so it dumps directly instead of hanging.
|
|
512
|
+
"""
|
|
513
|
+
if name == "all" and sys.stdin.isatty():
|
|
514
|
+
try:
|
|
515
|
+
answer = input(f"Print documentation for {len(flag_names())} flags? [y/N] ")
|
|
516
|
+
except EOFError:
|
|
517
|
+
answer = ""
|
|
518
|
+
if answer.strip().casefold() not in ("y", "yes"):
|
|
519
|
+
print("cancelled.", flush=True)
|
|
520
|
+
return stat.Success()
|
|
521
|
+
try:
|
|
522
|
+
text = describe_flags(name)
|
|
523
|
+
except ValueError as exception:
|
|
524
|
+
print(exception, flush=True)
|
|
525
|
+
return stat.Abort()
|
|
526
|
+
print(text, flush=True)
|
|
527
|
+
return stat.Success()
|
|
528
|
+
|
|
529
|
+
def _scaffold(self, dest: Path | None) -> Status:
|
|
530
|
+
"""Copy the bundled fixture templates to *dest* and explain `--genesis`.
|
|
531
|
+
|
|
532
|
+
A missing destination is treated as a name for a new directory under
|
|
533
|
+
the working directory (`None` means `./fixtures`); an existing
|
|
534
|
+
directory is used as-is, but a non-empty one is refused rather than
|
|
535
|
+
merged into. The copied tree is validated with the same rules the
|
|
536
|
+
fixture loader enforces, so the printed next step is guaranteed to
|
|
537
|
+
work.
|
|
538
|
+
"""
|
|
539
|
+
target = Path("fixtures") if dest is None else Path(dest)
|
|
540
|
+
try:
|
|
541
|
+
if target.exists():
|
|
542
|
+
if not target.is_dir():
|
|
543
|
+
log.critical("init destination %s exists and is not a directory", target)
|
|
544
|
+
return stat.Abort()
|
|
545
|
+
if any(target.iterdir()):
|
|
546
|
+
log.critical("init destination %s is not empty; refusing to overwrite", target)
|
|
547
|
+
return stat.Abort()
|
|
548
|
+
else:
|
|
549
|
+
target.mkdir(parents=True)
|
|
550
|
+
except OSError as exception:
|
|
551
|
+
log.critical("cannot prepare init destination %s: %s", target, exception)
|
|
552
|
+
return stat.Abort()
|
|
553
|
+
try:
|
|
554
|
+
with resources.as_file(resources.files("command_router") / "_example" / "fixtures") as source:
|
|
555
|
+
shutil.copytree(source, target, ignore=shutil.ignore_patterns("__pycache__"), dirs_exist_ok=True)
|
|
556
|
+
except OSError as exception:
|
|
557
|
+
log.critical("cannot copy fixture templates to %s: %s", target, exception)
|
|
558
|
+
return stat.Abort()
|
|
559
|
+
if (message := _check_genesis_dir(target)) is not None:
|
|
560
|
+
log.critical("%s", message)
|
|
561
|
+
return stat.Abort()
|
|
562
|
+
print(f"Done. Run the program with 'cmd-router --genesis {target}'.", flush=True)
|
|
563
|
+
return stat.Success()
|
|
564
|
+
|
|
565
|
+
def _handle_command(self, command: str) -> Outcome:
|
|
566
|
+
"""Execute one REPL line: exit-status, suggestions, or keep-going.
|
|
567
|
+
|
|
568
|
+
Returns ``Status`` to exit the REPL, ``list[str]`` of suggestions for
|
|
569
|
+
an unknown command, or ``None`` for a known command (keep listening).
|
|
570
|
+
Mirrors the old ``input()`` loop body minus the blocking read.
|
|
571
|
+
"""
|
|
572
|
+
if not isinstance(command, str):
|
|
573
|
+
log.error("test loop received non-string input (%s)", type(command).__name__)
|
|
574
|
+
return stat.TokenizeUnsupportedTypeError()
|
|
575
|
+
|
|
576
|
+
command_marker = command.strip().casefold()
|
|
577
|
+
quitters: tuple[str, ...] = ("q", "quit", "e", "exit", "!q", "!quit")
|
|
578
|
+
quitters += tuple(f"{Fixtures.cmd_prefix}{name}" for name in ("q", "quit", "e", "exit"))
|
|
579
|
+
if command_marker in quitters:
|
|
580
|
+
log.info("test loop requested to stop")
|
|
581
|
+
return stat.Success()
|
|
582
|
+
if not command_marker:
|
|
583
|
+
log.warning("test loop received empty input! is it a typo on an error?")
|
|
584
|
+
return None
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
result = self.execute(command)
|
|
588
|
+
except KeyboardInterrupt:
|
|
589
|
+
log.warning("test loop interrupted during commands execution")
|
|
590
|
+
return stat.Interrupted()
|
|
591
|
+
except Exception as exception:
|
|
592
|
+
log.critical("test loop failed while executing a commands: %s", exception)
|
|
593
|
+
return stat.Abort()
|
|
594
|
+
|
|
595
|
+
if not isinstance(result, ControlResult):
|
|
596
|
+
log.critical("test execution returned an invalid result")
|
|
597
|
+
return stat.Abort()
|
|
598
|
+
|
|
599
|
+
was_not_initialized = stat.ControlNotInitializedError()
|
|
600
|
+
if result.code.code == was_not_initialized.code:
|
|
601
|
+
log.critical("test became uninitialized while the loop was running")
|
|
602
|
+
return was_not_initialized
|
|
603
|
+
|
|
604
|
+
if result.ok:
|
|
605
|
+
if result.kind != "input" and result.value is not None:
|
|
606
|
+
log.info("test result: %r", result.value)
|
|
607
|
+
if result.command == "help" and isinstance(result.value, dict):
|
|
608
|
+
log.info(
|
|
609
|
+
"commands: '%s'\n prefix: '%s'\n target: '%s'\n suggestions: '%s'",
|
|
610
|
+
result.value.get("commands", None),
|
|
611
|
+
result.value.get("prefix", None),
|
|
612
|
+
result.value.get("target", None),
|
|
613
|
+
result.value.get("suggestions", None),
|
|
614
|
+
)
|
|
615
|
+
return None
|
|
616
|
+
|
|
617
|
+
final_result = result.suggestions
|
|
618
|
+
return [] if final_result is None else final_result
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def start(entry: tuple[str, ...] | None = None) -> Status:
|
|
622
|
+
"""Start the command router and return its process exit status name.
|
|
623
|
+
|
|
624
|
+
When *entry* is given, it is parsed as CLI arguments first, so importers
|
|
625
|
+
can initialize the flag environment without touching ``sys.argv``::
|
|
626
|
+
|
|
627
|
+
start(("--serve", "--quiet"))
|
|
628
|
+
|
|
629
|
+
An absent *entry* parses ``sys.argv`` instead. Bare `--init` and
|
|
630
|
+
`--verbose-help` occurrences are expanded to explicit values before
|
|
631
|
+
parsing, since stock click cannot express an option that is valid
|
|
632
|
+
both bare and valued. Invalid entries raise ``SystemExit`` exactly
|
|
633
|
+
like the command line does.
|
|
634
|
+
"""
|
|
635
|
+
|
|
636
|
+
if entry is not None:
|
|
637
|
+
init_flags(normalize_bare_options(list(entry)), standalone_mode=False)
|
|
638
|
+
else:
|
|
639
|
+
init_flags(normalize_bare_options(sys.argv[1:]), standalone_mode=False)
|
|
640
|
+
|
|
641
|
+
log.info("starting command router")
|
|
642
|
+
ultima: Status
|
|
643
|
+
try:
|
|
644
|
+
ultima = __CommandRouter().main
|
|
645
|
+
except KeyboardInterrupt:
|
|
646
|
+
log.warning("interrupted; shutting down")
|
|
647
|
+
return stat.Interrupted()
|
|
648
|
+
except Exception as exception:
|
|
649
|
+
log.debug("unrecoverable startup exception was raised")
|
|
650
|
+
log.critical(str(exception))
|
|
651
|
+
return stat.Abort()
|
|
652
|
+
log.info("command router exited with code %s (%s)", ultima.code, ultima.name)
|
|
653
|
+
log.info("contract message (if any): %s", ultima.message)
|
|
654
|
+
return ultima
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# The Clear BSD License
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2026 Ian Hylton
|
|
4
|
+
# All rights reserved.
|
|
5
|
+
|
|
6
|
+
"""Console entry point for the installed ``command-router`` script."""
|
|
7
|
+
|
|
8
|
+
from command_router import start
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def console_main() -> int:
|
|
12
|
+
"""Parse ``sys.argv`` and run; returns the process exit code."""
|
|
13
|
+
return start().code
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
raise SystemExit(console_main())
|