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.
@@ -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
@@ -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("__")}
@@ -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)