neorepl 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.
neorepl/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """neorepl: build custom Python shells."""
2
+ from .shell import Shell, Command, embed, start
3
+
4
+ __all__ = ["Shell", "Command", "embed", "start"]
5
+ __version__ = "0.1.0"
@@ -0,0 +1,48 @@
1
+ """Commands every Shell gets by default."""
2
+ from __future__ import annotations
3
+
4
+
5
+ def register(shell) -> None:
6
+ @shell.command("help", help="List commands (or: help <command>)")
7
+ def _help(sh, name=None):
8
+ if name:
9
+ cmd = sh.commands.get(name)
10
+ sh.print(f"{sh.command_prefix}{name}: {cmd.help}" if cmd else f"No such command: {name}")
11
+ return
12
+ width = max(len(n) for n in sh.commands)
13
+ for n in sorted(sh.commands):
14
+ sh.print(f" {sh.command_prefix}{n:<{width}} {sh.commands[n].help}")
15
+
16
+ @shell.command("exit", help="Leave the shell")
17
+ def _exit(sh):
18
+ raise SystemExit
19
+
20
+ shell.commands["quit"] = shell.commands["exit"]
21
+
22
+ @shell.command("vars", help="Show user-defined variables")
23
+ def _vars(sh):
24
+ for k, v in sorted(sh.locals.items()):
25
+ if not k.startswith("_"):
26
+ sh.print(f" {k} = {sh.formatter(v)[:70]}")
27
+
28
+ @shell.command("reset", help="Restore the initial namespace")
29
+ def _reset(sh):
30
+ sh.reset()
31
+ sh.print("Namespace reset.")
32
+
33
+ @shell.command("run", help="Execute a Python file in the shell namespace")
34
+ def _run(sh, path=None):
35
+ if not path:
36
+ sh.print(f"usage: {sh.command_prefix}run <file.py>")
37
+ return
38
+ sh.exec_file(path)
39
+
40
+ @shell.command("history", help="Show input history")
41
+ def _history(sh):
42
+ try:
43
+ import readline
44
+ except ImportError:
45
+ sh.print("history unavailable (no readline)")
46
+ return
47
+ for i in range(1, readline.get_current_history_length() + 1):
48
+ sh.print(f" {i:>3} {readline.get_history_item(i)}")
neorepl/completer.py ADDED
@@ -0,0 +1,59 @@
1
+ """Tab completion for Shell (names, dotted attributes, %commands)."""
2
+ from __future__ import annotations
3
+
4
+ import builtins
5
+ import keyword
6
+ import re
7
+
8
+ _DOTTED = re.compile(r"^[A-Za-z_][\w]*(\.[A-Za-z_][\w]*)*\.[\w]*$")
9
+
10
+
11
+ class Completer:
12
+ def __init__(self, shell):
13
+ self.shell = shell
14
+ self._matches: list[str] = []
15
+
16
+ def complete(self, text: str, state: int):
17
+ if state == 0:
18
+ self._matches = self._compute(text)
19
+ try:
20
+ return self._matches[state]
21
+ except IndexError:
22
+ return None
23
+
24
+ def _line_buffer(self) -> str:
25
+ try:
26
+ import readline
27
+ return readline.get_line_buffer()
28
+ except ImportError: # pragma: no cover
29
+ return ""
30
+
31
+ def _compute(self, text: str) -> list[str]:
32
+ buf = self._line_buffer().lstrip()
33
+ prefix = self.shell.command_prefix
34
+ if buf.startswith(prefix) and " " not in buf:
35
+ return sorted(n for n in self.shell.commands if n.startswith(buf[len(prefix):]))
36
+ if "." in text and _DOTTED.match(text):
37
+ return self._attr_matches(text)
38
+ return self._name_matches(text)
39
+
40
+ def _name_matches(self, text: str) -> list[str]:
41
+ names = set(keyword.kwlist) | set(dir(builtins)) | set(self.shell.locals)
42
+ return sorted(n for n in names if n.startswith(text))
43
+
44
+ def _attr_matches(self, text: str) -> list[str]:
45
+ *chain, partial = text.split(".")
46
+ obj = self.shell.locals.get(chain[0], getattr(builtins, chain[0], None))
47
+ if obj is None:
48
+ return []
49
+ try:
50
+ for part in chain[1:]:
51
+ obj = getattr(obj, part)
52
+ except Exception:
53
+ return []
54
+ base = ".".join(chain)
55
+ show_private = partial.startswith("_")
56
+ return sorted(
57
+ f"{base}.{a}" for a in dir(obj)
58
+ if a.startswith(partial) and (show_private or not a.startswith("_"))
59
+ )
neorepl/shell.py ADDED
@@ -0,0 +1,292 @@
1
+ """Core Shell class."""
2
+ from __future__ import annotations
3
+
4
+ import atexit
5
+ import code
6
+ import inspect
7
+ import os
8
+ import shlex
9
+ import sys
10
+ import time
11
+ from dataclasses import dataclass
12
+ from typing import Any, Callable, Optional, Union
13
+
14
+ from .completer import Completer
15
+
16
+ Prompt = Union[str, Callable[["Shell", bool], str]]
17
+
18
+ EVENTS = ("start", "exit", "pre_run", "post_run", "error", "command")
19
+
20
+
21
+ @dataclass
22
+ class Command:
23
+ name: str
24
+ func: Callable[..., Any]
25
+ help: str = ""
26
+
27
+
28
+ class Shell(code.InteractiveConsole):
29
+ """An extensible interactive Python shell.
30
+
31
+ Parameters
32
+ ----------
33
+ namespace: initial variables available in the shell.
34
+ banner: text shown at startup. Pass "" or False to disable.
35
+ prompt / continuation_prompt: a string, or a callable ``(shell, more) -> str``.
36
+ history_file: path to persist readline history (optional).
37
+ command_prefix: lines starting with this run a shell command instead of Python.
38
+ formatter: ``value -> str`` used to display expression results.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ namespace: Optional[dict] = None,
44
+ *,
45
+ banner: Union[str, bool, None] = None,
46
+ prompt: Prompt = ">>> ",
47
+ continuation_prompt: Prompt = "... ",
48
+ history_file: Optional[str] = None,
49
+ history_length: int = 1000,
50
+ command_prefix: str = "%",
51
+ exit_message: str = "",
52
+ formatter: Callable[[Any], str] = repr,
53
+ builtin_commands: bool = True,
54
+ ):
55
+ ns = dict(namespace or {})
56
+ ns.setdefault("__name__", "__shell__")
57
+ ns.setdefault("__doc__", None)
58
+ super().__init__(locals=ns, filename="<shell>")
59
+ self.banner = banner
60
+ self.prompt = prompt
61
+ self.continuation_prompt = continuation_prompt
62
+ self.history_file = os.path.expanduser(history_file) if history_file else None
63
+ self.history_length = history_length
64
+ self.command_prefix = command_prefix
65
+ self.exit_message = exit_message
66
+ self.formatter = formatter
67
+ self.commands: dict[str, Command] = {}
68
+ self._hooks: dict[str, list[Callable]] = {e: [] for e in EVENTS}
69
+ self._initial_ns = dict(ns)
70
+ self._had_error = False
71
+ self.last_duration = 0.0
72
+ if builtin_commands:
73
+ from . import builtin_commands as bc
74
+ bc.register(self)
75
+
76
+ # ------------------------------------------------------------------ output
77
+ def print(self, *args, **kwargs) -> None:
78
+ """Print to stdout (use for normal shell output)."""
79
+ print(*args, **kwargs)
80
+
81
+ # ---------------------------------------------------------------- commands
82
+ def command(self, name: Union[str, Callable, None] = None, *, help: str = ""):
83
+ """Register a command. Handler signature: ``func(shell, *args: str)``.
84
+
85
+ @shell.command("greet", help="Say hi")
86
+ def greet(shell, who="world"): shell.print(f"hi {who}")
87
+ """
88
+ def decorator(func):
89
+ cmd_name = name if isinstance(name, str) else func.__name__
90
+ doc = help or (inspect.getdoc(func) or "").split("\n")[0]
91
+ self.commands[cmd_name] = Command(cmd_name, func, doc)
92
+ return func
93
+
94
+ if callable(name): # used as bare @shell.command
95
+ func, name = name, None
96
+ return decorator(func)
97
+ return decorator
98
+
99
+ def is_command(self, line: str) -> bool:
100
+ return bool(self.command_prefix) and line.lstrip().startswith(self.command_prefix)
101
+
102
+ def run_command(self, line: str) -> None:
103
+ body = line.strip()[len(self.command_prefix):]
104
+ try:
105
+ parts = shlex.split(body)
106
+ except ValueError as e:
107
+ self.print(f"error: {e}")
108
+ return
109
+ if not parts:
110
+ return
111
+ name, *args = parts
112
+ cmd = self.commands.get(name)
113
+ if cmd is None:
114
+ self.print(f"Unknown command {name!r}. Try {self.command_prefix}help")
115
+ return
116
+ self.emit("command", name, args)
117
+ try:
118
+ cmd.func(self, *args)
119
+ except SystemExit:
120
+ raise
121
+ except Exception:
122
+ self.showtraceback()
123
+
124
+ # ------------------------------------------------------------------- hooks
125
+ def on(self, event: str, func: Optional[Callable] = None):
126
+ """Register a hook (decorator or direct call).
127
+
128
+ Events and signatures:
129
+ start(shell) / exit(shell)
130
+ pre_run(shell, source) -> str | None (return new source to rewrite input)
131
+ post_run(shell, source, ok)
132
+ error(shell, exc_type, exc, tb)
133
+ command(shell, name, args)
134
+ """
135
+ if event not in self._hooks:
136
+ raise ValueError(f"unknown event {event!r}; choose from {EVENTS}")
137
+
138
+ def register(f):
139
+ self._hooks[event].append(f)
140
+ return f
141
+
142
+ return register(func) if func else register
143
+
144
+ def emit(self, event: str, *args) -> None:
145
+ for hook in self._hooks[event]:
146
+ hook(self, *args)
147
+
148
+ def _transform(self, source: str) -> str:
149
+ for hook in self._hooks["pre_run"]:
150
+ result = hook(self, source)
151
+ if isinstance(result, str):
152
+ source = result
153
+ return source
154
+
155
+ # --------------------------------------------------------------- execution
156
+ def runsource(self, source, filename="<shell>", symbol="single"):
157
+ try:
158
+ compiled = self.compile(source, filename, symbol)
159
+ except (OverflowError, SyntaxError, ValueError):
160
+ self.showsyntaxerror(filename)
161
+ return False
162
+ if compiled is None:
163
+ return True # incomplete input, ask for more
164
+
165
+ # Input is complete: give pre_run hooks a chance to rewrite it.
166
+ new_source = self._transform(source)
167
+ if new_source != source:
168
+ try:
169
+ compiled = self.compile(new_source, filename, symbol)
170
+ except (OverflowError, SyntaxError, ValueError):
171
+ self.showsyntaxerror(filename)
172
+ return False
173
+ if compiled is None:
174
+ return False
175
+
176
+ self._had_error = False
177
+ start = time.perf_counter()
178
+ self.runcode(compiled)
179
+ self.last_duration = time.perf_counter() - start
180
+ self.emit("post_run", new_source, not self._had_error)
181
+ return False
182
+
183
+ def runcode(self, code_obj):
184
+ old = sys.displayhook
185
+ sys.displayhook = self._display
186
+ try:
187
+ super().runcode(code_obj)
188
+ finally:
189
+ sys.displayhook = old
190
+
191
+ def _display(self, value) -> None:
192
+ if value is None:
193
+ return
194
+ self.locals["_"] = value
195
+ self.print(self.formatter(value))
196
+
197
+ def showtraceback(self):
198
+ self._had_error = True
199
+ exc_info = sys.exc_info()
200
+ super().showtraceback()
201
+ if exc_info[0] is not None:
202
+ self.emit("error", *exc_info)
203
+
204
+ def exec_file(self, path: str) -> None:
205
+ with open(path, encoding="utf-8") as f:
206
+ self.runsource(f.read(), path, "exec")
207
+
208
+ def reset(self) -> None:
209
+ self.locals.clear()
210
+ self.locals.update(self._initial_ns)
211
+
212
+ # ---------------------------------------------------------------- REPL loop
213
+ def render_prompt(self, more: bool) -> str:
214
+ p = self.continuation_prompt if more else self.prompt
215
+ return p(self, more) if callable(p) else p
216
+
217
+ def _default_banner(self) -> str:
218
+ return (f"Python {sys.version.split()[0]} custom shell. "
219
+ f"Type {self.command_prefix}help for commands.")
220
+
221
+ def _setup_readline(self) -> None:
222
+ try:
223
+ import readline
224
+ except ImportError: # e.g. Windows without pyreadline
225
+ return
226
+ readline.set_completer(Completer(self).complete)
227
+ readline.parse_and_bind("tab: complete")
228
+ if self.history_file:
229
+ try:
230
+ readline.read_history_file(self.history_file)
231
+ except (FileNotFoundError, OSError):
232
+ pass
233
+ readline.set_history_length(self.history_length)
234
+ atexit.register(self._save_history)
235
+
236
+ def _save_history(self) -> None:
237
+ if not self.history_file:
238
+ return
239
+ try:
240
+ import readline
241
+ readline.write_history_file(self.history_file)
242
+ except (ImportError, OSError):
243
+ pass
244
+
245
+ def run(self, input_fn: Optional[Callable[[str], str]] = None) -> None:
246
+ """Start the shell loop. ``input_fn`` can be swapped out for testing."""
247
+ input_fn = input_fn or input
248
+ if input_fn is input:
249
+ self._setup_readline()
250
+ banner = self._default_banner() if self.banner is None else self.banner
251
+ if banner:
252
+ self.print(banner)
253
+ self.emit("start")
254
+ more = False
255
+ try:
256
+ while True:
257
+ try:
258
+ line = input_fn(self.render_prompt(more))
259
+ except EOFError:
260
+ self.print()
261
+ break
262
+ except KeyboardInterrupt:
263
+ self.print("\nKeyboardInterrupt")
264
+ self.resetbuffer()
265
+ more = False
266
+ continue
267
+ try:
268
+ if not more and self.is_command(line):
269
+ self.run_command(line)
270
+ else:
271
+ more = self.push(line)
272
+ except SystemExit:
273
+ break
274
+ finally:
275
+ self.emit("exit")
276
+ self._save_history()
277
+ if self.exit_message:
278
+ self.print(self.exit_message)
279
+
280
+
281
+ def start(namespace: Optional[dict] = None, **kwargs) -> Shell:
282
+ """Create a Shell and run it immediately."""
283
+ shell = Shell(namespace, **kwargs)
284
+ shell.run()
285
+ return shell
286
+
287
+
288
+ def embed(**kwargs) -> Shell:
289
+ """Drop into a shell with the caller's globals and locals (like IPython.embed)."""
290
+ frame = inspect.currentframe().f_back
291
+ ns = {**frame.f_globals, **frame.f_locals}
292
+ return start(ns, **kwargs)
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: neorepl
3
+ Version: 0.1.0
4
+ Summary: A small toolkit for building custom Python shells
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Provides-Extra: dev
8
+ Requires-Dist: pytest; extra == "dev"
9
+
10
+ # neorepl
11
+
12
+ A small toolkit for building custom Python shells. Zero dependencies, Python 3.9+.
13
+
14
+ ```python
15
+ from neorepl import Shell
16
+
17
+ shell = Shell({"answer": 42}, prompt="my> ", history_file="~/.my_history")
18
+
19
+ @shell.command("greet", help="Say hello")
20
+ def greet(sh, name="world"):
21
+ sh.print(f"Hello, {name}!")
22
+
23
+ @shell.on("pre_run") # rewrite input before it executes
24
+ def rewrite(sh, source):
25
+ if source.strip() == "ll":
26
+ return "print(sorted(globals()))"
27
+
28
+ shell.run()
29
+ ```
30
+
31
+ ## Features
32
+ - **Commands**: `%name args` lines handled by `@shell.command` (prefix configurable). Built-ins: `%help %exit %vars %reset %run %history`.
33
+ - **Hooks**: `start`, `exit`, `pre_run` (can rewrite input), `post_run`, `error`, `command`.
34
+ - **Prompts**: static strings or `callable(shell, more) -> str`.
35
+ - **Display**: swap `formatter=` to pretty-print results (e.g. `pprint.pformat`, rich).
36
+ - **Tab completion** for names, dotted attributes, and commands (via readline).
37
+ - **Persistent history** with `history_file=`.
38
+ - **Embedding**: `neorepl.embed()` opens a shell with the caller's variables.
39
+
40
+ ## Develop
41
+ ```
42
+ pip install -e ".[dev]"
43
+ pytest
44
+ python examples/demo.py
45
+ ```
@@ -0,0 +1,8 @@
1
+ neorepl/__init__.py,sha256=Y-GFJW6kPeXH7yqiklkknV2xWTHHZa84xK7JHgtJgrY,163
2
+ neorepl/builtin_commands.py,sha256=kRNdGubCx3khpQHcZsEHDjbRSjqiFzTij5mAZi-7dmw,1680
3
+ neorepl/completer.py,sha256=Dnf_Qcn36fW2Et0M06c8uaITCw2vaE5dLhcPozP2XEU,1955
4
+ neorepl/shell.py,sha256=WrFjrRsHXBAb1ekP0ringyax3fHNQUCED4OCKvz6m7o,10093
5
+ neorepl-0.1.0.dist-info/METADATA,sha256=0RR2EoAF0yP0XUziWnitLOvbDhHPUgrLg0MCZCYJBR4,1454
6
+ neorepl-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ neorepl-0.1.0.dist-info/top_level.txt,sha256=ls2BQgRzWmXGJDJx1lt726PmmGylIN50jzzRLsH3SBY,8
8
+ neorepl-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ neorepl