pykernel-cli 1.0.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.
- pykernel/__init__.py +0 -0
- pykernel/__main__.py +2 -0
- pykernel/commands/__init__.py +0 -0
- pykernel/commands/builtins.py +144 -0
- pykernel/commands/inspector.py +164 -0
- pykernel/commands/shell.py +101 -0
- pykernel/commands/snippets.py +257 -0
- pykernel/core/__init__.py +0 -0
- pykernel/core/config.py +118 -0
- pykernel/core/context.py +64 -0
- pykernel/core/painter.py +119 -0
- pykernel/core/registry.py +159 -0
- pykernel/core/repl.py +215 -0
- pykernel/kernel.py +38 -0
- pykernel_cli-1.0.0.dist-info/METADATA +74 -0
- pykernel_cli-1.0.0.dist-info/RECORD +20 -0
- pykernel_cli-1.0.0.dist-info/WHEEL +5 -0
- pykernel_cli-1.0.0.dist-info/entry_points.txt +2 -0
- pykernel_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- pykernel_cli-1.0.0.dist-info/top_level.txt +1 -0
pykernel/core/config.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Config — loads ~/.pykernel/config.toml (or defaults).
|
|
3
|
+
|
|
4
|
+
Supported keys
|
|
5
|
+
--------------
|
|
6
|
+
[kernel]
|
|
7
|
+
prompt = ">>> "
|
|
8
|
+
prompt2 = "... "
|
|
9
|
+
history_file = "~/.pykernel/history"
|
|
10
|
+
history_size = 2000
|
|
11
|
+
banner = true
|
|
12
|
+
theme = "dark" # dark | light | none
|
|
13
|
+
|
|
14
|
+
[kernel.colors]
|
|
15
|
+
prompt = "cyan"
|
|
16
|
+
error = "red"
|
|
17
|
+
info = "green"
|
|
18
|
+
warning = "yellow"
|
|
19
|
+
result = "white"
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import os, pathlib
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import tomllib # Python 3.11+
|
|
26
|
+
except ImportError:
|
|
27
|
+
try:
|
|
28
|
+
import tomli as tomllib # pip install tomli
|
|
29
|
+
except ImportError:
|
|
30
|
+
tomllib = None # fall back to defaults
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
DEFAULTS: dict = {
|
|
34
|
+
"kernel": {
|
|
35
|
+
"prompt": ">>> ",
|
|
36
|
+
"prompt2": "... ",
|
|
37
|
+
"history_file": "~/.pykernel/history",
|
|
38
|
+
"history_size": 2000,
|
|
39
|
+
"banner": True,
|
|
40
|
+
"theme": "dark",
|
|
41
|
+
"colors": {
|
|
42
|
+
"prompt": "cyan",
|
|
43
|
+
"error": "red",
|
|
44
|
+
"info": "green",
|
|
45
|
+
"warning": "yellow",
|
|
46
|
+
"result": "white",
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
53
|
+
out = dict(base)
|
|
54
|
+
for k, v in override.items():
|
|
55
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
56
|
+
out[k] = _deep_merge(out[k], v)
|
|
57
|
+
else:
|
|
58
|
+
out[k] = v
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Config:
|
|
63
|
+
"""Thin wrapper around the merged config dict."""
|
|
64
|
+
|
|
65
|
+
CONFIG_DIR = pathlib.Path.home() / ".pykernel"
|
|
66
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
67
|
+
|
|
68
|
+
def __init__(self):
|
|
69
|
+
raw = {}
|
|
70
|
+
if tomllib and self.CONFIG_FILE.exists():
|
|
71
|
+
with open(self.CONFIG_FILE, "rb") as f:
|
|
72
|
+
raw = tomllib.load(f)
|
|
73
|
+
self._data = _deep_merge(DEFAULTS, raw)
|
|
74
|
+
self._ensure_dirs()
|
|
75
|
+
|
|
76
|
+
# ── helpers ─────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def _ensure_dirs(self):
|
|
79
|
+
self.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
self.plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
|
|
82
|
+
def _k(self, *keys):
|
|
83
|
+
node = self._data
|
|
84
|
+
for k in keys:
|
|
85
|
+
node = node.get(k, {})
|
|
86
|
+
return node
|
|
87
|
+
|
|
88
|
+
# ── public properties ────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def prompt(self) -> str: return self._k("kernel", "prompt")
|
|
92
|
+
@property
|
|
93
|
+
def prompt2(self) -> str: return self._k("kernel", "prompt2")
|
|
94
|
+
@property
|
|
95
|
+
def history_file(self) -> pathlib.Path:
|
|
96
|
+
return pathlib.Path(os.path.expanduser(self._k("kernel", "history_file")))
|
|
97
|
+
@property
|
|
98
|
+
def history_size(self) -> int: return self._k("kernel", "history_size")
|
|
99
|
+
@property
|
|
100
|
+
def banner(self) -> bool: return bool(self._k("kernel", "banner"))
|
|
101
|
+
@property
|
|
102
|
+
def theme(self) -> str: return self._k("kernel", "theme")
|
|
103
|
+
@property
|
|
104
|
+
def colors(self) -> dict: return self._k("kernel", "colors")
|
|
105
|
+
@property
|
|
106
|
+
def plugin_dir(self) -> pathlib.Path:
|
|
107
|
+
return self.CONFIG_DIR / "plugins"
|
|
108
|
+
|
|
109
|
+
def color(self, name: str) -> str:
|
|
110
|
+
return self.colors.get(name, "white")
|
|
111
|
+
|
|
112
|
+
def get(self, *keys, default=None):
|
|
113
|
+
node = self._data
|
|
114
|
+
for k in keys:
|
|
115
|
+
if not isinstance(node, dict):
|
|
116
|
+
return default
|
|
117
|
+
node = node.get(k, {})
|
|
118
|
+
return node if node != {} else default
|
pykernel/core/context.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context — the object every command receives as its first argument.
|
|
3
|
+
|
|
4
|
+
It provides:
|
|
5
|
+
ctx.env — the shared Python namespace (locals/globals for exec)
|
|
6
|
+
ctx.print() — themed print
|
|
7
|
+
ctx.paint — Painter instance
|
|
8
|
+
ctx.registry — CommandRegistry
|
|
9
|
+
ctx.cfg — Config
|
|
10
|
+
ctx.history — list of past inputs (most recent last)
|
|
11
|
+
ctx.set_var() — inject a name into the exec namespace
|
|
12
|
+
ctx.get_var() — retrieve from the exec namespace
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from pykernel.core.config import Config
|
|
20
|
+
from pykernel.core.painter import Painter
|
|
21
|
+
from pykernel.core.registry import CommandRegistry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Context:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
env: dict,
|
|
28
|
+
paint: "Painter",
|
|
29
|
+
registry: "CommandRegistry",
|
|
30
|
+
cfg: "Config",
|
|
31
|
+
history: list[str],
|
|
32
|
+
):
|
|
33
|
+
self.env = env
|
|
34
|
+
self.paint = paint
|
|
35
|
+
self.registry = registry
|
|
36
|
+
self.cfg = cfg
|
|
37
|
+
self.history = history
|
|
38
|
+
|
|
39
|
+
# ── convenience ──────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
def print(self, *args, **kwargs):
|
|
42
|
+
"""Print to stdout (wrapper so commands don't need to import print)."""
|
|
43
|
+
print(*args, **kwargs)
|
|
44
|
+
|
|
45
|
+
def set_var(self, name: str, value: Any):
|
|
46
|
+
"""Inject a variable into the kernel's exec namespace."""
|
|
47
|
+
self.env[name] = value
|
|
48
|
+
|
|
49
|
+
def get_var(self, name: str, default: Any = None) -> Any:
|
|
50
|
+
"""Retrieve a variable from the kernel's exec namespace."""
|
|
51
|
+
return self.env.get(name, default)
|
|
52
|
+
|
|
53
|
+
def has_var(self, name: str) -> bool:
|
|
54
|
+
return name in self.env
|
|
55
|
+
|
|
56
|
+
def delete_var(self, name: str) -> bool:
|
|
57
|
+
if name in self.env:
|
|
58
|
+
del self.env[name]
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def namespace_vars(self) -> dict:
|
|
63
|
+
"""Return user-visible variables (excludes dunders)."""
|
|
64
|
+
return {k: v for k, v in self.env.items() if not k.startswith("__")}
|
pykernel/core/painter.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Painter — ANSI colour helpers, theme-aware.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from pykernel.core.painter import Painter
|
|
6
|
+
p = Painter(theme="dark")
|
|
7
|
+
print(p.info("hello"))
|
|
8
|
+
print(p.error("oops"))
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
ANSI = {
|
|
15
|
+
"reset": "\033[0m",
|
|
16
|
+
"bold": "\033[1m",
|
|
17
|
+
"dim": "\033[2m",
|
|
18
|
+
"italic": "\033[3m",
|
|
19
|
+
"under": "\033[4m",
|
|
20
|
+
|
|
21
|
+
"black": "\033[30m",
|
|
22
|
+
"red": "\033[91m",
|
|
23
|
+
"green": "\033[92m",
|
|
24
|
+
"yellow": "\033[93m",
|
|
25
|
+
"blue": "\033[94m",
|
|
26
|
+
"magenta": "\033[95m",
|
|
27
|
+
"cyan": "\033[96m",
|
|
28
|
+
"white": "\033[97m",
|
|
29
|
+
"gray": "\033[90m",
|
|
30
|
+
|
|
31
|
+
"bg_black": "\033[40m",
|
|
32
|
+
"bg_red": "\033[41m",
|
|
33
|
+
"bg_green": "\033[42m",
|
|
34
|
+
"bg_blue": "\033[44m",
|
|
35
|
+
"bg_cyan": "\033[46m",
|
|
36
|
+
"bg_magenta": "\033[45m",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_SUPPORTS_COLOR = sys.stdout.isatty()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _c(*codes) -> str:
|
|
43
|
+
if not _SUPPORTS_COLOR:
|
|
44
|
+
return ""
|
|
45
|
+
return "".join(ANSI.get(c, "") for c in codes)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def colorize(text: str, *codes) -> str:
|
|
49
|
+
if not _SUPPORTS_COLOR:
|
|
50
|
+
return text
|
|
51
|
+
return f"{_c(*codes)}{text}{ANSI['reset']}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Painter:
|
|
55
|
+
"""Theme-aware text painter."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, theme: str = "dark", color_map: Optional[dict] = None):
|
|
58
|
+
self.theme = theme
|
|
59
|
+
self._map = color_map or {}
|
|
60
|
+
self.enabled = _SUPPORTS_COLOR and theme != "none"
|
|
61
|
+
|
|
62
|
+
def _paint(self, text: str, *codes) -> str:
|
|
63
|
+
if not self.enabled:
|
|
64
|
+
return text
|
|
65
|
+
return colorize(text, *codes)
|
|
66
|
+
|
|
67
|
+
# ── semantic helpers ─────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def prompt(self, text: str) -> str: return self._paint(text, "cyan", "bold")
|
|
70
|
+
def error(self, text: str) -> str: return self._paint(text, "red", "bold")
|
|
71
|
+
def info(self, text: str) -> str: return self._paint(text, "green")
|
|
72
|
+
def warning(self, text: str) -> str: return self._paint(text, "yellow")
|
|
73
|
+
def result(self, text: str) -> str: return self._paint(text, "white")
|
|
74
|
+
def dim(self, text: str) -> str: return self._paint(text, "dim")
|
|
75
|
+
def bold(self, text: str) -> str: return self._paint(text, "bold")
|
|
76
|
+
def code(self, text: str) -> str: return self._paint(text, "magenta")
|
|
77
|
+
def header(self, text: str) -> str: return self._paint(text, "cyan", "bold", "under")
|
|
78
|
+
def success(self, text: str) -> str: return self._paint(text, "green", "bold")
|
|
79
|
+
def accent(self, text: str) -> str: return self._paint(text, "blue", "bold")
|
|
80
|
+
|
|
81
|
+
# ── table helper ─────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def table(self, rows: list[tuple], headers: Optional[list[str]] = None) -> str:
|
|
84
|
+
if not rows:
|
|
85
|
+
return self.dim("(empty)")
|
|
86
|
+
|
|
87
|
+
all_rows = [tuple(str(c) for c in r) for r in rows]
|
|
88
|
+
if headers:
|
|
89
|
+
all_rows = [tuple(headers)] + all_rows
|
|
90
|
+
|
|
91
|
+
col_w = [max(len(r[i]) for r in all_rows) for i in range(len(all_rows[0]))]
|
|
92
|
+
sep = " ".join("─" * w for w in col_w)
|
|
93
|
+
|
|
94
|
+
lines: list[str] = []
|
|
95
|
+
for i, row in enumerate(all_rows):
|
|
96
|
+
cells = " ".join(cell.ljust(col_w[j]) for j, cell in enumerate(row))
|
|
97
|
+
if headers and i == 0:
|
|
98
|
+
lines.append(self.header(cells))
|
|
99
|
+
lines.append(self.dim(sep))
|
|
100
|
+
else:
|
|
101
|
+
lines.append(cells)
|
|
102
|
+
return "\n".join(lines)
|
|
103
|
+
|
|
104
|
+
# ── banner ───────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
def banner(self, version: str = "1.0.0") -> str:
|
|
107
|
+
logo = r"""
|
|
108
|
+
____ _ __ _
|
|
109
|
+
| _ \ _ _| |/ /___ _ __ _ __ ___| |
|
|
110
|
+
| |_) | | | | ' // _ \ '__| '_ \ / _ \ |
|
|
111
|
+
| __/| |_| | . \ __/ | | | | | __/ |
|
|
112
|
+
|_| \__, |_|\_\___|_| |_| |_|\___|_|
|
|
113
|
+
|___/ """
|
|
114
|
+
lines = [
|
|
115
|
+
self._paint(logo, "cyan"),
|
|
116
|
+
self.dim(f" v{version} · type /help to explore commands"),
|
|
117
|
+
"",
|
|
118
|
+
]
|
|
119
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CommandRegistry — the heart of PyKernel's customisation system.
|
|
3
|
+
|
|
4
|
+
Commands are callables registered with a name, aliases, help text, and
|
|
5
|
+
optional argument spec. The registry also loads user plugins from a directory.
|
|
6
|
+
|
|
7
|
+
Quick API
|
|
8
|
+
---------
|
|
9
|
+
from pykernel.core.registry import CommandRegistry
|
|
10
|
+
|
|
11
|
+
reg = CommandRegistry()
|
|
12
|
+
|
|
13
|
+
@reg.command("greet", aliases=["hi"], help="Say hello", usage="/greet [name]")
|
|
14
|
+
def cmd_greet(ctx, *args):
|
|
15
|
+
name = args[0] if args else "world"
|
|
16
|
+
ctx.print(ctx.paint.success(f"Hello, {name}!"))
|
|
17
|
+
|
|
18
|
+
# or imperatively:
|
|
19
|
+
reg.register("greet", cmd_greet, aliases=["hi"], help="Say hello")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import importlib.util, pathlib, sys, traceback
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Callable, Optional
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── data model ───────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Command:
|
|
31
|
+
name: str
|
|
32
|
+
func: Callable
|
|
33
|
+
aliases: list[str] = field(default_factory=list)
|
|
34
|
+
help: str = "(no description)"
|
|
35
|
+
usage: str = ""
|
|
36
|
+
category: str = "General"
|
|
37
|
+
hidden: bool = False
|
|
38
|
+
|
|
39
|
+
def __post_init__(self):
|
|
40
|
+
if not self.usage:
|
|
41
|
+
self.usage = f"/{self.name}"
|
|
42
|
+
|
|
43
|
+
def invoke(self, ctx, *args):
|
|
44
|
+
return self.func(ctx, *args)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── registry ─────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
class CommandRegistry:
|
|
50
|
+
"""Holds all commands and resolves them by name or alias."""
|
|
51
|
+
|
|
52
|
+
def __init__(self):
|
|
53
|
+
self._commands: dict[str, Command] = {} # canonical name → Command
|
|
54
|
+
self._alias: dict[str, str] = {} # alias → canonical name
|
|
55
|
+
|
|
56
|
+
# ── registration ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def register(
|
|
59
|
+
self,
|
|
60
|
+
name: str,
|
|
61
|
+
func: Callable,
|
|
62
|
+
*,
|
|
63
|
+
aliases: list[str] = (),
|
|
64
|
+
help: str = "(no description)",
|
|
65
|
+
usage: str = "",
|
|
66
|
+
category: str = "General",
|
|
67
|
+
hidden: bool = False,
|
|
68
|
+
) -> "Command":
|
|
69
|
+
cmd = Command(
|
|
70
|
+
name=name, func=func, aliases=list(aliases),
|
|
71
|
+
help=help, usage=usage, category=category, hidden=hidden,
|
|
72
|
+
)
|
|
73
|
+
self._commands[name] = cmd
|
|
74
|
+
for alias in aliases:
|
|
75
|
+
self._alias[alias] = name
|
|
76
|
+
return cmd
|
|
77
|
+
|
|
78
|
+
def command(
|
|
79
|
+
self,
|
|
80
|
+
name: str,
|
|
81
|
+
*,
|
|
82
|
+
aliases: list[str] = (),
|
|
83
|
+
help: str = "(no description)",
|
|
84
|
+
usage: str = "",
|
|
85
|
+
category: str = "General",
|
|
86
|
+
hidden: bool = False,
|
|
87
|
+
):
|
|
88
|
+
"""Decorator factory — use on any function to register it as a command."""
|
|
89
|
+
def decorator(func: Callable) -> Callable:
|
|
90
|
+
self.register(
|
|
91
|
+
name, func,
|
|
92
|
+
aliases=aliases, help=help,
|
|
93
|
+
usage=usage, category=category, hidden=hidden,
|
|
94
|
+
)
|
|
95
|
+
return func
|
|
96
|
+
return decorator
|
|
97
|
+
|
|
98
|
+
# ── lookup ───────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
def resolve(self, name: str) -> Optional[Command]:
|
|
101
|
+
"""Return Command for canonical name or alias, or None."""
|
|
102
|
+
if name in self._commands:
|
|
103
|
+
return self._commands[name]
|
|
104
|
+
canonical = self._alias.get(name)
|
|
105
|
+
if canonical:
|
|
106
|
+
return self._commands.get(canonical)
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def all_commands(self, include_hidden: bool = False) -> list[Command]:
|
|
110
|
+
cmds = list(self._commands.values())
|
|
111
|
+
if not include_hidden:
|
|
112
|
+
cmds = [c for c in cmds if not c.hidden]
|
|
113
|
+
return sorted(cmds, key=lambda c: (c.category, c.name))
|
|
114
|
+
|
|
115
|
+
def by_category(self, include_hidden: bool = False) -> dict[str, list[Command]]:
|
|
116
|
+
out: dict[str, list[Command]] = {}
|
|
117
|
+
for cmd in self.all_commands(include_hidden):
|
|
118
|
+
out.setdefault(cmd.category, []).append(cmd)
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
def names(self) -> list[str]:
|
|
122
|
+
names = list(self._commands.keys()) + list(self._alias.keys())
|
|
123
|
+
return sorted(names)
|
|
124
|
+
|
|
125
|
+
# ── plugin loader ─────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
def load_plugins(self, plugin_dir: pathlib.Path) -> list[str]:
|
|
128
|
+
"""
|
|
129
|
+
Import every *.py file in plugin_dir.
|
|
130
|
+
Each plugin must expose a top-level register(registry) function.
|
|
131
|
+
Returns list of loaded plugin names.
|
|
132
|
+
"""
|
|
133
|
+
loaded: list[str] = []
|
|
134
|
+
if not plugin_dir.exists():
|
|
135
|
+
return loaded
|
|
136
|
+
|
|
137
|
+
for path in sorted(plugin_dir.glob("*.py")):
|
|
138
|
+
mod_name = f"_pykernel_plugin_{path.stem}"
|
|
139
|
+
try:
|
|
140
|
+
spec = importlib.util.spec_from_file_location(mod_name, path)
|
|
141
|
+
module = importlib.util.module_from_spec(spec) # type: ignore
|
|
142
|
+
sys.modules[mod_name] = module
|
|
143
|
+
spec.loader.exec_module(module) # type: ignore
|
|
144
|
+
if hasattr(module, "register") and callable(module.register):
|
|
145
|
+
module.register(self)
|
|
146
|
+
loaded.append(path.stem)
|
|
147
|
+
else:
|
|
148
|
+
print(
|
|
149
|
+
f"[plugin] {path.name} has no register(registry) — skipped",
|
|
150
|
+
file=sys.stderr,
|
|
151
|
+
)
|
|
152
|
+
except Exception:
|
|
153
|
+
print(f"[plugin] Failed to load {path.name}:", file=sys.stderr)
|
|
154
|
+
traceback.print_exc(file=sys.stderr)
|
|
155
|
+
|
|
156
|
+
return loaded
|
|
157
|
+
|
|
158
|
+
def __len__(self): return len(self._commands)
|
|
159
|
+
def __contains__(self, name): return name in self._commands or name in self._alias
|
pykernel/core/repl.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
REPL — the read-eval-print loop engine.
|
|
3
|
+
|
|
4
|
+
Input lines that start with '/' are dispatched to the command registry.
|
|
5
|
+
Everything else is compiled and exec'd in the shared namespace.
|
|
6
|
+
|
|
7
|
+
Multi-line input is accumulated until the block is syntactically complete
|
|
8
|
+
(same heuristic as CPython's interactive shell).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast, code, sys, traceback, atexit, pathlib
|
|
14
|
+
try:
|
|
15
|
+
import readline
|
|
16
|
+
except ImportError:
|
|
17
|
+
try:
|
|
18
|
+
import pyreadline3 as readline
|
|
19
|
+
except ImportError:
|
|
20
|
+
readline = None
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from pykernel.core.config import Config
|
|
24
|
+
from pykernel.core.context import Context
|
|
25
|
+
from pykernel.core.painter import Painter
|
|
26
|
+
from pykernel.core.registry import CommandRegistry
|
|
27
|
+
|
|
28
|
+
VERSION = "1.0.0"
|
|
29
|
+
|
|
30
|
+
# ── readline completion ───────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
class _Completer:
|
|
33
|
+
def __init__(self, env: dict, registry: CommandRegistry):
|
|
34
|
+
self._env = env
|
|
35
|
+
self._registry = registry
|
|
36
|
+
self._matches: list[str] = []
|
|
37
|
+
|
|
38
|
+
def complete(self, text: str, state: int) -> Optional[str]:
|
|
39
|
+
if state == 0:
|
|
40
|
+
if text.startswith("/"):
|
|
41
|
+
prefix = text[1:]
|
|
42
|
+
self._matches = [
|
|
43
|
+
f"/{n}" for n in self._registry.names()
|
|
44
|
+
if n.startswith(prefix)
|
|
45
|
+
]
|
|
46
|
+
else:
|
|
47
|
+
import rlcompleter
|
|
48
|
+
self._matches = [
|
|
49
|
+
m for m in rlcompleter.Completer(self._env).global_matches(text) or []
|
|
50
|
+
]
|
|
51
|
+
return self._matches[state] if state < len(self._matches) else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── REPL ─────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
class REPL:
|
|
57
|
+
COMMAND_PREFIX = "/"
|
|
58
|
+
|
|
59
|
+
def __init__(self, registry: CommandRegistry, cfg: Config):
|
|
60
|
+
self.registry = registry
|
|
61
|
+
self.cfg = cfg
|
|
62
|
+
self.paint = Painter(theme=cfg.theme, color_map=cfg.colors)
|
|
63
|
+
|
|
64
|
+
# shared execution namespace
|
|
65
|
+
self.env: dict = {
|
|
66
|
+
"__name__": "__kernel__",
|
|
67
|
+
"__builtins__": __builtins__,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
self.history: list[str] = []
|
|
71
|
+
self._buffer: list[str] = [] # multi-line accumulator
|
|
72
|
+
|
|
73
|
+
self._setup_readline()
|
|
74
|
+
|
|
75
|
+
# ── readline / history ────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def _setup_readline(self):
|
|
78
|
+
if readline is None:
|
|
79
|
+
return
|
|
80
|
+
hfile = self.cfg.history_file
|
|
81
|
+
hfile.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
readline.read_history_file(str(hfile))
|
|
85
|
+
except FileNotFoundError:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
readline.set_history_length(self.cfg.history_size)
|
|
89
|
+
atexit.register(readline.write_history_file, str(hfile))
|
|
90
|
+
|
|
91
|
+
completer = _Completer(self.env, self.registry)
|
|
92
|
+
readline.set_completer(completer.complete)
|
|
93
|
+
readline.set_completer_delims(" \t\n`!@#$%^&*()-=+[{]}\\|;:'\",<>?")
|
|
94
|
+
readline.parse_and_bind("tab: complete")
|
|
95
|
+
|
|
96
|
+
# ── prompt display ────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def _prompt(self) -> str:
|
|
99
|
+
p = self.cfg.prompt2 if self._buffer else self.cfg.prompt
|
|
100
|
+
return self.paint.prompt(p)
|
|
101
|
+
|
|
102
|
+
# ── command dispatch ──────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
def _dispatch_command(self, line: str):
|
|
105
|
+
parts = line[len(self.COMMAND_PREFIX):].split()
|
|
106
|
+
if not parts:
|
|
107
|
+
return
|
|
108
|
+
name, args = parts[0], parts[1:]
|
|
109
|
+
|
|
110
|
+
cmd = self.registry.resolve(name)
|
|
111
|
+
if cmd is None:
|
|
112
|
+
self.paint_error(f"Unknown command: /{name} (try /help)")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
ctx = Context(
|
|
116
|
+
env=self.env,
|
|
117
|
+
paint=self.paint,
|
|
118
|
+
registry=self.registry,
|
|
119
|
+
cfg=self.cfg,
|
|
120
|
+
history=self.history,
|
|
121
|
+
)
|
|
122
|
+
try:
|
|
123
|
+
cmd.invoke(ctx, *args)
|
|
124
|
+
except SystemExit:
|
|
125
|
+
raise
|
|
126
|
+
except Exception:
|
|
127
|
+
self.paint_error(f"Error in /{name}:\n" + traceback.format_exc())
|
|
128
|
+
|
|
129
|
+
# ── code execution ────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def _is_complete(self, source: str) -> tuple[bool, bool]:
|
|
132
|
+
"""
|
|
133
|
+
Returns (complete, invalid).
|
|
134
|
+
Mirrors CPython codeop behaviour.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
tree = compile(source, "<input>", "exec", ast.PyCF_ONLY_AST)
|
|
138
|
+
return True, False
|
|
139
|
+
except SyntaxError as e:
|
|
140
|
+
if e.msg in ("unexpected EOF while parsing",
|
|
141
|
+
"EOF while scanning triple-quoted string literal",
|
|
142
|
+
"expected an indented block"):
|
|
143
|
+
return False, False # incomplete — keep reading
|
|
144
|
+
return True, True # real error
|
|
145
|
+
|
|
146
|
+
def _exec(self, source: str):
|
|
147
|
+
try:
|
|
148
|
+
# try eval first (so expressions print their value)
|
|
149
|
+
try:
|
|
150
|
+
code_obj = compile(source, "<input>", "eval")
|
|
151
|
+
result = eval(code_obj, self.env)
|
|
152
|
+
if result is not None:
|
|
153
|
+
print(self.paint.result(repr(result)))
|
|
154
|
+
return
|
|
155
|
+
except SyntaxError:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
# fall back to exec
|
|
159
|
+
code_obj = compile(source, "<input>", "exec")
|
|
160
|
+
exec(code_obj, self.env)
|
|
161
|
+
|
|
162
|
+
except SystemExit:
|
|
163
|
+
raise
|
|
164
|
+
except Exception:
|
|
165
|
+
lines = traceback.format_exc().splitlines()
|
|
166
|
+
# trim noisy internal frames
|
|
167
|
+
cleaned = [l for l in lines if "<string>" in l or not l.strip().startswith('File "')]
|
|
168
|
+
print(self.paint.error("\n".join(cleaned or lines)))
|
|
169
|
+
|
|
170
|
+
# ── helpers ───────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def paint_error(self, msg: str):
|
|
173
|
+
print(self.paint.error(f"✗ {msg}"))
|
|
174
|
+
|
|
175
|
+
def paint_info(self, msg: str):
|
|
176
|
+
print(self.paint.info(f" {msg}"))
|
|
177
|
+
|
|
178
|
+
# ── main loop ─────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
def run(self):
|
|
181
|
+
if self.cfg.banner:
|
|
182
|
+
print(self.paint.banner(VERSION))
|
|
183
|
+
|
|
184
|
+
while True:
|
|
185
|
+
try:
|
|
186
|
+
line = input(self._prompt())
|
|
187
|
+
except EOFError:
|
|
188
|
+
print()
|
|
189
|
+
break
|
|
190
|
+
except KeyboardInterrupt:
|
|
191
|
+
print()
|
|
192
|
+
self._buffer.clear()
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
# ── command? ──────────────────────────────────────────────────────
|
|
196
|
+
stripped = line.strip()
|
|
197
|
+
if stripped.startswith(self.COMMAND_PREFIX) and not self._buffer:
|
|
198
|
+
self.history.append(stripped)
|
|
199
|
+
self._dispatch_command(stripped)
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
# ── accumulate multi-line ─────────────────────────────────────────
|
|
203
|
+
self._buffer.append(line)
|
|
204
|
+
source = "\n".join(self._buffer)
|
|
205
|
+
|
|
206
|
+
complete, invalid = self._is_complete(source)
|
|
207
|
+
|
|
208
|
+
if not complete:
|
|
209
|
+
continue # keep reading
|
|
210
|
+
|
|
211
|
+
# ── flush & execute ───────────────────────────────────────────────
|
|
212
|
+
self.history.append(source)
|
|
213
|
+
self._buffer.clear()
|
|
214
|
+
if stripped:
|
|
215
|
+
self._exec(source)
|