rigbox 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.
- rigbox/__init__.py +1 -0
- rigbox/cli.py +142 -0
- rigbox/constants.py +89 -0
- rigbox/core/__init__.py +1 -0
- rigbox/core/cli_parser.py +145 -0
- rigbox/core/context.py +129 -0
- rigbox/core/history.py +44 -0
- rigbox/core/plugins.py +76 -0
- rigbox/core/registry.py +191 -0
- rigbox/core/render.py +253 -0
- rigbox/core/transport.py +78 -0
- rigbox/core/workflow.py +94 -0
- rigbox/core/workspace.py +96 -0
- rigbox/hooks.py +30 -0
- rigbox/plugins/__init__.py +1 -0
- rigbox/plugins/builtin.py +130 -0
- rigbox/plugins/code/__init__.py +1 -0
- rigbox/plugins/code/commands.py +84 -0
- rigbox/plugins/code/generator.py +154 -0
- rigbox/plugins/code/macros/c/__init__.py +0 -0
- rigbox/plugins/code/macros/c/c_autogen_banner.j2 +27 -0
- rigbox/plugins/code/macros/c/c_autogen_banner_editable.j2 +27 -0
- rigbox/plugins/code/macros/c/c_autogen_banner_state_machine.j2 +29 -0
- rigbox/plugins/code/macros/c/c_enum.j2 +10 -0
- rigbox/plugins/code/macros/c/c_machine_actions.j2 +7 -0
- rigbox/plugins/code/macros/c/c_machine_constants.j2 +5 -0
- rigbox/plugins/code/macros/c/c_machine_errors.j2 +10 -0
- rigbox/plugins/code/macros/c/c_machine_events.j2 +10 -0
- rigbox/plugins/code/macros/c/c_machine_state_table_entry.j2 +20 -0
- rigbox/plugins/code/macros/c/c_machine_state_transition_table.j2 +20 -0
- rigbox/plugins/code/macros/c/c_machine_states.j2 +9 -0
- rigbox/plugins/code/macros/c/c_machine_struct.j2 +6 -0
- rigbox/plugins/code/macros/c/c_machine_version.j2 +3 -0
- rigbox/plugins/code/macros/c/c_struct.j2 +8 -0
- rigbox/plugins/code/macros/c/c_union.j2 +8 -0
- rigbox/plugins/code/render.py +106 -0
- rigbox/plugins/code/template.py +64 -0
- rigbox/plugins/code/templates/c/state_machine.h.j2 +42 -0
- rigbox/plugins/device/__init__.py +1 -0
- rigbox/plugins/device/commands.py +236 -0
- rigbox/plugins/device/connection.py +160 -0
- rigbox/plugins/device/replay_transport.py +77 -0
- rigbox/plugins/device/serial_transport.py +84 -0
- rigbox/plugins/manifests/__init__.py +1 -0
- rigbox/plugins/manifests/api.py +96 -0
- rigbox/plugins/manifests/build.py +98 -0
- rigbox/plugins/manifests/check.py +79 -0
- rigbox/plugins/manifests/commands.py +98 -0
- rigbox/plugins/manifests/schema.py +130 -0
- rigbox/plugins/plugin_template.py +43 -0
- rigbox/plugins/statemachine/__init__.py +1 -0
- rigbox/plugins/statemachine/check.py +169 -0
- rigbox/plugins/statemachine/commands.py +357 -0
- rigbox/plugins/statemachine/coverage.py +156 -0
- rigbox/plugins/statemachine/engine.py +186 -0
- rigbox/plugins/statemachine/fuzz.py +181 -0
- rigbox/plugins/statemachine/schema.py +241 -0
- rigbox/repl.py +135 -0
- rigbox-0.1.0.dist-info/METADATA +99 -0
- rigbox-0.1.0.dist-info/RECORD +64 -0
- rigbox-0.1.0.dist-info/WHEEL +5 -0
- rigbox-0.1.0.dist-info/entry_points.txt +2 -0
- rigbox-0.1.0.dist-info/licenses/LICENSE +21 -0
- rigbox-0.1.0.dist-info/top_level.txt +1 -0
rigbox/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Main package for Rigbox."""
|
rigbox/cli.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Main CLI entrypoint for Rigbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from rigbox.constants import RenderFormat, RenderLevel
|
|
11
|
+
from rigbox.core.context import Context
|
|
12
|
+
from rigbox.core.plugins import load_all
|
|
13
|
+
from rigbox.core.registry import CommandError, Registry, UsageError
|
|
14
|
+
from rigbox.core.render import RenderError, RigboxCmdResult
|
|
15
|
+
from rigbox.core.workspace import Workspace
|
|
16
|
+
|
|
17
|
+
cli = typer.Typer()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _build_context(workspace: str | None, fmt: RenderFormat) -> Context:
|
|
21
|
+
"""Find the workspace (from `workspace` or the cwd), load plugins and return a ready Context."""
|
|
22
|
+
ws = Workspace.find(Path(workspace) if workspace else Path.cwd())
|
|
23
|
+
registry = Registry()
|
|
24
|
+
load_all(registry, ws)
|
|
25
|
+
return Context(registry=registry, workspace=ws, fmt=fmt)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@cli.callback(invoke_without_command=True)
|
|
29
|
+
def main(ctx: typer.Context) -> None:
|
|
30
|
+
"""Main entrypoint for the Rigbox CLI. Overridden to allow for REPL launch when no arguments are provided.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
ctx (typer.Context): The Typer context object for the CLI invocation.
|
|
34
|
+
"""
|
|
35
|
+
# If no subcommand was invoked, we default to spawning the interactive REPL interface
|
|
36
|
+
if ctx.invoked_subcommand is None:
|
|
37
|
+
from rigbox.repl import run_repl
|
|
38
|
+
|
|
39
|
+
rigbox_ctx = _build_context(None, RenderFormat.default())
|
|
40
|
+
run_repl(rigbox_ctx)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@cli.command(help="Initialize a new Rigbox workspace.")
|
|
44
|
+
def init(
|
|
45
|
+
path: Path = typer.Argument(Path("."), help="The path to create the workspace in."),
|
|
46
|
+
name: str | None = typer.Option(None, "--name", help="The name of the workspace."),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Initialize a new Rigbox workspace.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
path (Path, optional): The path to create the workspace in. Defaults to the current directory.
|
|
52
|
+
name (str | None, optional): The name of the workspace. Defaults to None.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
typer.Exit(code=1): Raised if the workspace already exists.
|
|
56
|
+
typer.Exit(code=0): Raised after successful initialization.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
ws = Workspace.init(path, name)
|
|
60
|
+
except FileExistsError as e:
|
|
61
|
+
typer.echo(f"Error: {e}")
|
|
62
|
+
raise typer.Exit(code=1) from e
|
|
63
|
+
typer.echo(f"Workspace initialized at {ws.root}")
|
|
64
|
+
raise typer.Exit(code=0)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@cli.command(context_settings={"ignore_unknown_options": True, "allow_interspersed_args": False}, help="Run one command non-interactively.")
|
|
68
|
+
def run(
|
|
69
|
+
tokens: Annotated[list[str], typer.Argument(help="<command> [args...]")],
|
|
70
|
+
workspace: Annotated[str | None, typer.Option(help="Start the workspace search here (default: cwd)")] = None,
|
|
71
|
+
fmt: Annotated[RenderFormat, typer.Option("--format", "-f", help=f"Output format (default: {RenderFormat.default()})")] = RenderFormat.default(),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Run one command non-interactively.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
tokens (Annotated[list[str], typer.Argument): The command and its arguments to run.
|
|
77
|
+
workspace (str | None, optional): The workspace to start the search from. Defaults to cwd.
|
|
78
|
+
fmt: The output format, including plain text, tables, JSON, and NDJSON. Defaults to "auto".
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
typer.Exit(code=2): Raised if no command is specified.
|
|
82
|
+
typer.Exit(code=2): Raised if there is a usage error.
|
|
83
|
+
typer.Exit(code=1): Raised if there is a command error.
|
|
84
|
+
typer.Exit(code=0): Raised after successful execution.
|
|
85
|
+
"""
|
|
86
|
+
if not tokens:
|
|
87
|
+
typer.echo("Error: No command specified.")
|
|
88
|
+
raise typer.Exit(code=2)
|
|
89
|
+
ctx = _build_context(workspace, fmt)
|
|
90
|
+
try:
|
|
91
|
+
response = ctx.run_tokens(tokens)
|
|
92
|
+
out = response.render(fmt)
|
|
93
|
+
except UsageError as e:
|
|
94
|
+
raise typer.Exit(code=e.exit_code) from e
|
|
95
|
+
except (CommandError, RenderError) as e:
|
|
96
|
+
typer.echo(RigboxCmdResult(text=str(e), level=RenderLevel.ERROR).render(RenderFormat.PLAINTEXT), err=True)
|
|
97
|
+
raise typer.Exit(code=1) from e
|
|
98
|
+
finally:
|
|
99
|
+
ctx.close()
|
|
100
|
+
if out:
|
|
101
|
+
typer.echo(out)
|
|
102
|
+
raise typer.Exit(code=0)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@cli.command(help="Execute a script file within the Rigbox workspace.")
|
|
106
|
+
def script(
|
|
107
|
+
file: Annotated[Path, typer.Argument(help="The script file to execute.")],
|
|
108
|
+
workspace: Annotated[str | None, typer.Option(help="Start the workspace search here (default: cwd)")] = None,
|
|
109
|
+
fmt: Annotated[RenderFormat, typer.Option("--format", "-f", help=f"Output format (default: {RenderFormat.default()})")] = RenderFormat.default(),
|
|
110
|
+
var: Annotated[list[str] | None, typer.Option("--var", "-v", help="key=value override")] = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Execute a script file within the Rigbox workspace.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
file (Annotated[Path, typer.Argument): The script file to execute.
|
|
116
|
+
workspace (str | None, optional): The workspace to start the search from. Defaults to cwd.
|
|
117
|
+
fmt: The output format, including plain text, rich renderables, JSON, and NDJSON. Defaults to "auto".
|
|
118
|
+
var (Annotated[list[str] | None, typer.Option, optional): Key=value overrides for the script. Defaults to None.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
typer.Exit(code=2): Raised if there is a usage error.
|
|
122
|
+
typer.Exit(code=1): Raised if there is a command error.
|
|
123
|
+
typer.Exit(code=0): Raised after successful execution.
|
|
124
|
+
"""
|
|
125
|
+
ctx = _build_context(workspace, fmt)
|
|
126
|
+
try:
|
|
127
|
+
response = ctx.run("workflow.run", {"workflow": str(file), "var": var or []})
|
|
128
|
+
out = response.render(fmt)
|
|
129
|
+
except UsageError as e:
|
|
130
|
+
raise typer.Exit(code=e.exit_code) from e
|
|
131
|
+
except (CommandError, RenderError) as e:
|
|
132
|
+
typer.echo(RigboxCmdResult(text=str(e), level=RenderLevel.ERROR).render(RenderFormat.PLAINTEXT), err=True)
|
|
133
|
+
raise typer.Exit(code=1) from e
|
|
134
|
+
finally:
|
|
135
|
+
ctx.close()
|
|
136
|
+
if out:
|
|
137
|
+
typer.echo(out)
|
|
138
|
+
raise typer.Exit(code=0)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
cli()
|
rigbox/constants.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Common constants for Rigbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
from typing import Final, Self
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AppMetadata:
|
|
11
|
+
"""Namespace class for application metadata-related constants."""
|
|
12
|
+
|
|
13
|
+
NAME: Final[str] = "rigbox"
|
|
14
|
+
VERSION: Final[str] = version(NAME)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AppFiles:
|
|
18
|
+
"""Namespace class for file-related constants."""
|
|
19
|
+
|
|
20
|
+
SHELL_HISTORY: Final[str] = "shell_history.json"
|
|
21
|
+
WORKSPACE_CONFIG: Final[str] = "rigbox.yaml"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RenderFormat(StrEnum):
|
|
25
|
+
"""Enumeration of output rendering formats.
|
|
26
|
+
|
|
27
|
+
This dictates how the output should be formatted when rendered, including options for JSON, NDJSON, Rich text, plain text,
|
|
28
|
+
or automatic detection based on the terminal capabilities.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
JSON = "json"
|
|
32
|
+
NDJSON = "ndjson"
|
|
33
|
+
RICH = "rich"
|
|
34
|
+
PLAINTEXT = "plaintext"
|
|
35
|
+
AUTO = "auto"
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def default(cls) -> Self:
|
|
39
|
+
"""Get the default rendering format."""
|
|
40
|
+
return cls.AUTO
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RenderLevel(StrEnum):
|
|
44
|
+
"""Enumeration of output rendering severity levels.
|
|
45
|
+
|
|
46
|
+
This dictates how messages of different severity levels should be rendered, including their associated colors.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
ERROR = "error"
|
|
50
|
+
WARNING = "warning"
|
|
51
|
+
INFO = "info"
|
|
52
|
+
SUCCESS = "success"
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def default(cls) -> Self:
|
|
56
|
+
"""Get the default rendering level."""
|
|
57
|
+
return cls.INFO
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def color(self) -> str:
|
|
61
|
+
"""Get the associated color for this `RenderLevel`.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
KeyError: If the `RenderLevel` does not have an associated color mapping.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
str: The rich color associated with this `RenderLevel`.
|
|
68
|
+
"""
|
|
69
|
+
return {
|
|
70
|
+
"error": "bold red",
|
|
71
|
+
"warning": "yellow",
|
|
72
|
+
"info": "default", # No color
|
|
73
|
+
"success": "green",
|
|
74
|
+
}[self]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Plugins:
|
|
78
|
+
"""Namespace class for plugin-related constants."""
|
|
79
|
+
|
|
80
|
+
PROJECT: Final[str] = AppMetadata.NAME
|
|
81
|
+
ENTRYPOINT_GROUP: Final[str] = f"{PROJECT}.plugins".lower()
|
|
82
|
+
BUILTIN_MODULES: tuple[str, ...] = (
|
|
83
|
+
f"{ENTRYPOINT_GROUP}.builtin",
|
|
84
|
+
f"{ENTRYPOINT_GROUP}.manifests.commands",
|
|
85
|
+
f"{ENTRYPOINT_GROUP}.device.commands",
|
|
86
|
+
f"{ENTRYPOINT_GROUP}.statemachine.commands",
|
|
87
|
+
f"{ENTRYPOINT_GROUP}.code.commands",
|
|
88
|
+
f"{ENTRYPOINT_GROUP}.dsp.commands",
|
|
89
|
+
)
|
rigbox/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Framework core: registry, context, parsing, rendering, workspace, workflows and plugin loading."""
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Module for command-line parsing using pydantic models.
|
|
2
|
+
|
|
3
|
+
Rigbox defines all commands using pydantic models to represent their arguments.
|
|
4
|
+
|
|
5
|
+
This module provides functions to convert these pydantic models into argparse parsers,
|
|
6
|
+
so that commands can be run both from the command line and the interactive shell with consistent argument handling.
|
|
7
|
+
|
|
8
|
+
Required fields in the pydantic model become positional arguments, while optional fields become named options with `--`.
|
|
9
|
+
Boolean fields become bare flags, list fields can repeat, and Literal fields become validated choices.
|
|
10
|
+
|
|
11
|
+
Values stay as strings when parsed from the command line, and pydantic handles conversion and validation when the command runs.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import functools
|
|
18
|
+
import shlex
|
|
19
|
+
import typing
|
|
20
|
+
from types import UnionType
|
|
21
|
+
from typing import Any, NoReturn
|
|
22
|
+
|
|
23
|
+
import cmd2
|
|
24
|
+
from pydantic import BaseModel, Field
|
|
25
|
+
from pydantic.fields import FieldInfo
|
|
26
|
+
|
|
27
|
+
from rigbox.core.registry import Command, UsageError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def path_field(default: Any = ..., **kwargs: Any) -> Any: # noqa: ANN401 - mirrors pydantic's Field()
|
|
31
|
+
"""Declare a ``str`` argument that holds a file path, so the shell can tab-complete file names for it.
|
|
32
|
+
|
|
33
|
+
This exists because some fields may represent both a file path
|
|
34
|
+
and other types of string data, and we want the shell to provide
|
|
35
|
+
tab-completion for the file path case.
|
|
36
|
+
|
|
37
|
+
Takes the same arguments as pydantic's ``Field``; with no default the argument is required.
|
|
38
|
+
"""
|
|
39
|
+
return Field(default, json_schema_extra={"path": True}, **kwargs)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RigboxParser(cmd2.Cmd2ArgumentParser):
|
|
43
|
+
"""The parser for one command. It remembers the last error so non-interactive callers can report it."""
|
|
44
|
+
|
|
45
|
+
fields: tuple[str, ...] = ()
|
|
46
|
+
"""Names of the args model's fields, used to pick them out of the parsed namespace."""
|
|
47
|
+
|
|
48
|
+
error_message: str = ""
|
|
49
|
+
|
|
50
|
+
def error(self, message: str) -> NoReturn:
|
|
51
|
+
"""Record `message`, then let argparse print the usage and exit as usual."""
|
|
52
|
+
self.error_message = message
|
|
53
|
+
super().error(message)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _unwrap(annotation: object) -> object:
|
|
57
|
+
"""Strip Optional[...] so `bool | None` still counts as bool."""
|
|
58
|
+
if typing.get_origin(annotation) in (typing.Union, UnionType):
|
|
59
|
+
args = [a for a in typing.get_args(annotation) if a is not type(None)]
|
|
60
|
+
if len(args) == 1:
|
|
61
|
+
return args[0]
|
|
62
|
+
return annotation
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_bool(info: FieldInfo) -> bool:
|
|
66
|
+
return _unwrap(info.annotation) is bool
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_list(info: FieldInfo) -> bool:
|
|
70
|
+
return typing.get_origin(_unwrap(info.annotation)) in (list, tuple, set)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_path(info: FieldInfo) -> bool:
|
|
74
|
+
extra = info.json_schema_extra
|
|
75
|
+
return isinstance(extra, dict) and extra.get("path") is True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _help_text(info: FieldInfo) -> str:
|
|
79
|
+
"""The field's description, with its default appended; `%` is doubled because argparse formats help text."""
|
|
80
|
+
text = info.description or ""
|
|
81
|
+
if info.default_factory is None and info.default not in (None, "", False) and not info.is_required():
|
|
82
|
+
text = f"{text} (default: {info.default})".strip()
|
|
83
|
+
return text.replace("%", "%%")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_parser(name: str, model: type[BaseModel], summary: str) -> RigboxParser:
|
|
87
|
+
"""Build the parser for command `name` from its args `model`; `summary` is shown as the description."""
|
|
88
|
+
# First, create a parser instance
|
|
89
|
+
parser = RigboxParser(prog=name, description=summary, allow_abbrev=False, suggest_on_error=True)
|
|
90
|
+
# Then, create the fields using the pydantic model's field definitions
|
|
91
|
+
parser.fields = tuple(model.model_fields)
|
|
92
|
+
# Now, we add each field as an argument to the parser based on its properties
|
|
93
|
+
for field, info in model.model_fields.items():
|
|
94
|
+
annotation = _unwrap(info.annotation)
|
|
95
|
+
options: dict[str, Any] = {"help": _help_text(info)}
|
|
96
|
+
if typing.get_origin(annotation) is typing.Literal:
|
|
97
|
+
options["choices"] = list(typing.get_args(annotation))
|
|
98
|
+
# Automatically set up path completion for path fields
|
|
99
|
+
if _is_path(info):
|
|
100
|
+
options["completer"] = cmd2.Cmd.path_complete
|
|
101
|
+
if info.is_required():
|
|
102
|
+
parser.add_argument(field, **options)
|
|
103
|
+
continue
|
|
104
|
+
flag = "--" + field.replace("_", "-")
|
|
105
|
+
if _is_bool(info):
|
|
106
|
+
parser.add_argument(flag, dest=field, action="store_true", default=argparse.SUPPRESS, help=options["help"])
|
|
107
|
+
elif _is_list(info):
|
|
108
|
+
parser.add_argument(flag, dest=field, action="append", default=argparse.SUPPRESS, **options)
|
|
109
|
+
else:
|
|
110
|
+
parser.add_argument(flag, dest=field, default=argparse.SUPPRESS, **options)
|
|
111
|
+
return parser
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@functools.cache # Cache the parser for each command to avoid rebuilding it multiple times
|
|
115
|
+
def parser_for(command: Command) -> RigboxParser:
|
|
116
|
+
"""Return the (cached) parser for a registered command."""
|
|
117
|
+
return build_parser(command.name, command.args_model, command.help)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_args(parser: RigboxParser, tokens: list[str]) -> dict[str, Any]:
|
|
121
|
+
"""Parse command-line `tokens` into a raw args dict holding only the options actually given.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
UsageError: If the tokens are invalid, or ``--help`` was given. The parser has already printed the usage
|
|
125
|
+
message or help text; the error's ``exit_code`` is 2 for a bad command line and 0 for help.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
namespace = parser.parse_args(tokens)
|
|
129
|
+
except SystemExit as exit_:
|
|
130
|
+
code = exit_.code
|
|
131
|
+
raise UsageError(parser.error_message, code if isinstance(code, int) else 0) from None
|
|
132
|
+
return {key: value for key, value in vars(namespace).items() if key in parser.fields}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def split_line(line: str) -> list[str]:
|
|
136
|
+
"""Split a line into tokens. Quotes group words; backslashes are kept (Windows paths)."""
|
|
137
|
+
lexer = shlex.shlex(line, posix=False)
|
|
138
|
+
lexer.whitespace_split = True
|
|
139
|
+
lexer.commenters = ""
|
|
140
|
+
tokens: list[str] = []
|
|
141
|
+
for tok in lexer:
|
|
142
|
+
if len(tok) >= 2 and tok[0] == tok[-1] and tok[0] in "\"'":
|
|
143
|
+
tok = tok[1:-1]
|
|
144
|
+
tokens.append(tok)
|
|
145
|
+
return tokens
|
rigbox/core/context.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Execution context shared by REPL, CLI and workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Protocol, TextIO
|
|
8
|
+
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
from rich.console import Console, RenderableType
|
|
11
|
+
|
|
12
|
+
from rigbox.constants import RenderFormat
|
|
13
|
+
from rigbox.core.cli_parser import parse_args, parser_for, split_line
|
|
14
|
+
from rigbox.core.history import History
|
|
15
|
+
from rigbox.core.registry import CommandError, Registry
|
|
16
|
+
from rigbox.core.render import RigboxCmdResult, resolve_render_format
|
|
17
|
+
from rigbox.core.workspace import Workspace
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Closable(Protocol):
|
|
21
|
+
"""Anything with a ``close()`` method, such as a device connection."""
|
|
22
|
+
|
|
23
|
+
def close(self) -> None:
|
|
24
|
+
"""Release the resource."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Context:
|
|
29
|
+
"""Shared state for one session: registry, workspace, history, output format and open connections."""
|
|
30
|
+
|
|
31
|
+
registry: Registry
|
|
32
|
+
workspace: Workspace | None = None
|
|
33
|
+
fmt: RenderFormat = RenderFormat.default()
|
|
34
|
+
history: History = field(init=False) # created in __post_init__
|
|
35
|
+
# Open resources owned by plugins (e.g. device connections), closed in `close()`; any object with .close()
|
|
36
|
+
connections: dict[str, Closable] = field(default_factory=dict)
|
|
37
|
+
output_stream: TextIO | None = None
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
"""Create the session history, persisted in the workspace if there is one."""
|
|
41
|
+
path = self.workspace.data_dir / "history.jsonl" if self.workspace else None
|
|
42
|
+
self.history = History(path)
|
|
43
|
+
|
|
44
|
+
def close(self) -> None:
|
|
45
|
+
"""Attempt every resource's cleanup, log failures, and forget the closed session."""
|
|
46
|
+
connections = list(self.connections.items())
|
|
47
|
+
self.connections.clear()
|
|
48
|
+
for name, conn in connections:
|
|
49
|
+
try:
|
|
50
|
+
conn.close()
|
|
51
|
+
except Exception as error:
|
|
52
|
+
self.log(f"warning: cannot close connection '{name}': {error}")
|
|
53
|
+
|
|
54
|
+
def log(self, message: str) -> None:
|
|
55
|
+
"""Write a progress or diagnostic message to stderr, keeping stdout pipe-clean.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
message (str): The message to log.
|
|
59
|
+
"""
|
|
60
|
+
print(message, file=sys.stderr)
|
|
61
|
+
|
|
62
|
+
def print(self, renderable: RenderableType, *, style: str | None = None) -> None:
|
|
63
|
+
"""Print a Rich renderable to stdout in plaintext or rich mode.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
renderable: The text or Rich renderable to display.
|
|
67
|
+
style: Optional Rich style applied to the renderable.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
CommandError: If the selected output format is not plaintext or rich.
|
|
71
|
+
"""
|
|
72
|
+
stream = self.output_stream if self.output_stream is not None else sys.stdout
|
|
73
|
+
resolved_fmt = resolve_render_format(self.fmt, stream)
|
|
74
|
+
if resolved_fmt not in (RenderFormat.RICH, RenderFormat.PLAINTEXT):
|
|
75
|
+
raise CommandError("Rich output is only available in plaintext or rich format")
|
|
76
|
+
Console(file=stream).print(renderable, style=style, markup=False)
|
|
77
|
+
|
|
78
|
+
def run(self, name: str, raw_args: dict[str, Any] | None = None) -> RigboxCmdResult:
|
|
79
|
+
"""Validate arguments, invoke the handler, and normalize its response.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
name (str): The name of the command to run.
|
|
83
|
+
raw_args (dict[str, Any] | None, optional): The raw arguments to pass to the command. Defaults to None.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The response containing normalized data and any explicit plaintext message.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
CommandError: If the command is unknown, the args are invalid, or the handler fails.
|
|
90
|
+
"""
|
|
91
|
+
cmd = self.registry.get(name)
|
|
92
|
+
try:
|
|
93
|
+
args = cmd.args_model.model_validate(raw_args or {})
|
|
94
|
+
except ValidationError as e:
|
|
95
|
+
problems = "; ".join(f"{'.'.join(str(p) for p in err['loc']) or 'args'}: {err['msg']}" for err in e.errors())
|
|
96
|
+
raise CommandError(f"{name}: invalid arguments: {problems}") from None
|
|
97
|
+
result = cmd.handler(self, args)
|
|
98
|
+
if cmd.record:
|
|
99
|
+
self.history.append(name, args.model_dump(mode="json", exclude_unset=True))
|
|
100
|
+
return RigboxCmdResult.normalized(result)
|
|
101
|
+
|
|
102
|
+
def run_tokens(self, tokens: list[str]) -> RigboxCmdResult:
|
|
103
|
+
"""Run a command from tokens: the command name followed by CLI-style arguments.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
tokens (list[str]): The command name followed by CLI-style arguments.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The normalized command response.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
UsageError: If the arguments are invalid or ``--help`` was given; the parser has already printed
|
|
113
|
+
the usage message or help text.
|
|
114
|
+
"""
|
|
115
|
+
if not tokens:
|
|
116
|
+
return RigboxCmdResult.empty()
|
|
117
|
+
cmd = self.registry.get(tokens[0])
|
|
118
|
+
return self.run(cmd.name, parse_args(parser_for(cmd), tokens[1:]))
|
|
119
|
+
|
|
120
|
+
def run_line(self, line: str) -> RigboxCmdResult:
|
|
121
|
+
"""Split `line` into tokens and run it as a command.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
line (str): The command line to split and run.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The normalized command response.
|
|
128
|
+
"""
|
|
129
|
+
return self.run_tokens(split_line(line))
|
rigbox/core/history.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Structured session history (JSONL). Steps replay and convert to workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class History:
|
|
12
|
+
"""The commands run in a session, optionally persisted to a JSONL file."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
15
|
+
"""Load existing entries from `path` if it exists; with no path, keep history in memory only."""
|
|
16
|
+
self.path = path
|
|
17
|
+
self._entries: list[dict[str, Any]] = []
|
|
18
|
+
if path is not None and path.is_file():
|
|
19
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
20
|
+
if line.strip():
|
|
21
|
+
self._entries.append(json.loads(line))
|
|
22
|
+
|
|
23
|
+
def append(self, run: str, args: dict[str, Any], ok: bool = True) -> None:
|
|
24
|
+
"""Record a command run (name, args, success flag) and write it to the file, if any.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
run (str): The name of the command that was run.
|
|
28
|
+
args (dict[str, Any]): The arguments passed to the command.
|
|
29
|
+
ok (bool, optional): Whether the command ran successfully. Defaults to True.
|
|
30
|
+
"""
|
|
31
|
+
entry = {"ts": round(time.time(), 3), "run": run, "args": args, "ok": ok}
|
|
32
|
+
self._entries.append(entry)
|
|
33
|
+
if self.path is not None:
|
|
34
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
36
|
+
f.write(json.dumps(entry) + "\n")
|
|
37
|
+
|
|
38
|
+
def entries(self) -> list[dict[str, Any]]:
|
|
39
|
+
"""Return a copy of all entries, oldest first.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
list[dict[str, Any]]: A list of all history entries, each being a dictionary with keys "ts", "run", "args", and "ok".
|
|
43
|
+
"""
|
|
44
|
+
return list(self._entries)
|
rigbox/core/plugins.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Plugin discovery on top of pluggy: built-ins, installed entry points, workspace-local files.
|
|
2
|
+
|
|
3
|
+
pluggy owns hook definition and dispatch; this module only gathers plugin modules from our three
|
|
4
|
+
sources and records per-plugin failures (a broken plugin never aborts startup).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
import importlib.util
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from functools import partial
|
|
13
|
+
from importlib.metadata import entry_points
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import ModuleType
|
|
16
|
+
|
|
17
|
+
import pluggy
|
|
18
|
+
|
|
19
|
+
from rigbox import hooks
|
|
20
|
+
from rigbox.constants import Plugins as PluginConstants
|
|
21
|
+
from rigbox.core.registry import PluginInfo, Registry
|
|
22
|
+
from rigbox.core.workspace import Workspace
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_all(registry: Registry, workspace: Workspace | None = None) -> None:
|
|
26
|
+
"""Load every plugin and let each register its commands on `registry`.
|
|
27
|
+
|
|
28
|
+
Failures are recorded in ``registry.plugins`` rather than raised.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
registry (Registry): The registry where plugins should register their commands.
|
|
32
|
+
workspace (Workspace | None, optional): The workspace to look for workspace-local plugins. Defaults to None.
|
|
33
|
+
"""
|
|
34
|
+
pm = pluggy.PluginManager(PluginConstants.PROJECT)
|
|
35
|
+
pm.add_hookspecs(hooks)
|
|
36
|
+
sources: dict[str, str] = {}
|
|
37
|
+
|
|
38
|
+
def add(name: str, source: str, loader: Callable[[], object]) -> None:
|
|
39
|
+
try:
|
|
40
|
+
pm.register(loader(), name=name)
|
|
41
|
+
sources[name] = source
|
|
42
|
+
except Exception as e: # plugin code must never crash the host
|
|
43
|
+
registry.plugins.append(PluginInfo(name, source, error=f"{type(e).__name__}: {e}"))
|
|
44
|
+
|
|
45
|
+
for mod in PluginConstants.BUILTIN_MODULES:
|
|
46
|
+
add(mod, "builtin", partial(importlib.import_module, mod))
|
|
47
|
+
for ep in entry_points(group=PluginConstants.ENTRYPOINT_GROUP):
|
|
48
|
+
add(ep.name, "entry-point", ep.load)
|
|
49
|
+
if workspace is not None and workspace.plugins_dir.is_dir():
|
|
50
|
+
for path in sorted(workspace.plugins_dir.glob("*.py")):
|
|
51
|
+
if not path.name.startswith("_"):
|
|
52
|
+
add(path.stem, "workspace", partial(_import_file, path))
|
|
53
|
+
|
|
54
|
+
called: set[str] = set()
|
|
55
|
+
for impl in pm.hook.rigbox_register.get_hookimpls():
|
|
56
|
+
called.add(impl.plugin_name)
|
|
57
|
+
before = set(registry.names())
|
|
58
|
+
info = PluginInfo(impl.plugin_name, sources[impl.plugin_name])
|
|
59
|
+
try:
|
|
60
|
+
impl.function(registry=registry)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
info.error = f"{type(e).__name__}: {e}"
|
|
63
|
+
info.commands = sorted(set(registry.names()) - before)
|
|
64
|
+
registry.plugins.append(info)
|
|
65
|
+
for name, source in sources.items():
|
|
66
|
+
if name not in called:
|
|
67
|
+
registry.plugins.append(PluginInfo(name, source, error="no rigbox_register hook"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _import_file(path: Path) -> ModuleType:
|
|
71
|
+
spec = importlib.util.spec_from_file_location(f"rigbox_workspace_plugin_{path.stem}", path)
|
|
72
|
+
if spec is None or spec.loader is None:
|
|
73
|
+
raise ImportError(f"cannot load plugin file: {path}")
|
|
74
|
+
module = importlib.util.module_from_spec(spec)
|
|
75
|
+
spec.loader.exec_module(module)
|
|
76
|
+
return module
|