neorepl 0.1.0__tar.gz
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-0.1.0/PKG-INFO +45 -0
- neorepl-0.1.0/README.md +36 -0
- neorepl-0.1.0/pyproject.toml +17 -0
- neorepl-0.1.0/setup.cfg +4 -0
- neorepl-0.1.0/src/neorepl/__init__.py +5 -0
- neorepl-0.1.0/src/neorepl/builtin_commands.py +48 -0
- neorepl-0.1.0/src/neorepl/completer.py +59 -0
- neorepl-0.1.0/src/neorepl/shell.py +292 -0
- neorepl-0.1.0/src/neorepl.egg-info/PKG-INFO +45 -0
- neorepl-0.1.0/src/neorepl.egg-info/SOURCES.txt +12 -0
- neorepl-0.1.0/src/neorepl.egg-info/dependency_links.txt +1 -0
- neorepl-0.1.0/src/neorepl.egg-info/requires.txt +3 -0
- neorepl-0.1.0/src/neorepl.egg-info/top_level.txt +1 -0
- neorepl-0.1.0/tests/test_shell.py +67 -0
neorepl-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
neorepl-0.1.0/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# neorepl
|
|
2
|
+
|
|
3
|
+
A small toolkit for building custom Python shells. Zero dependencies, Python 3.9+.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from neorepl import Shell
|
|
7
|
+
|
|
8
|
+
shell = Shell({"answer": 42}, prompt="my> ", history_file="~/.my_history")
|
|
9
|
+
|
|
10
|
+
@shell.command("greet", help="Say hello")
|
|
11
|
+
def greet(sh, name="world"):
|
|
12
|
+
sh.print(f"Hello, {name}!")
|
|
13
|
+
|
|
14
|
+
@shell.on("pre_run") # rewrite input before it executes
|
|
15
|
+
def rewrite(sh, source):
|
|
16
|
+
if source.strip() == "ll":
|
|
17
|
+
return "print(sorted(globals()))"
|
|
18
|
+
|
|
19
|
+
shell.run()
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
- **Commands**: `%name args` lines handled by `@shell.command` (prefix configurable). Built-ins: `%help %exit %vars %reset %run %history`.
|
|
24
|
+
- **Hooks**: `start`, `exit`, `pre_run` (can rewrite input), `post_run`, `error`, `command`.
|
|
25
|
+
- **Prompts**: static strings or `callable(shell, more) -> str`.
|
|
26
|
+
- **Display**: swap `formatter=` to pretty-print results (e.g. `pprint.pformat`, rich).
|
|
27
|
+
- **Tab completion** for names, dotted attributes, and commands (via readline).
|
|
28
|
+
- **Persistent history** with `history_file=`.
|
|
29
|
+
- **Embedding**: `neorepl.embed()` opens a shell with the caller's variables.
|
|
30
|
+
|
|
31
|
+
## Develop
|
|
32
|
+
```
|
|
33
|
+
pip install -e ".[dev]"
|
|
34
|
+
pytest
|
|
35
|
+
python examples/demo.py
|
|
36
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "neorepl"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A small toolkit for building custom Python shells"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
dev = ["pytest"]
|
|
15
|
+
|
|
16
|
+
[tool.setuptools.packages.find]
|
|
17
|
+
where = ["src"]
|
neorepl-0.1.0/setup.cfg
ADDED
|
@@ -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)}")
|
|
@@ -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
|
+
)
|
|
@@ -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,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/neorepl/__init__.py
|
|
4
|
+
src/neorepl/builtin_commands.py
|
|
5
|
+
src/neorepl/completer.py
|
|
6
|
+
src/neorepl/shell.py
|
|
7
|
+
src/neorepl.egg-info/PKG-INFO
|
|
8
|
+
src/neorepl.egg-info/SOURCES.txt
|
|
9
|
+
src/neorepl.egg-info/dependency_links.txt
|
|
10
|
+
src/neorepl.egg-info/requires.txt
|
|
11
|
+
src/neorepl.egg-info/top_level.txt
|
|
12
|
+
tests/test_shell.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
neorepl
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from neorepl import Shell
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def run(shell, *lines):
|
|
5
|
+
it = iter(lines)
|
|
6
|
+
shell.run(input_fn=lambda prompt: next(it))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_expression_and_assignment(capsys):
|
|
10
|
+
sh = Shell(banner="")
|
|
11
|
+
run(sh, "x = 2", "x * 21", "%exit")
|
|
12
|
+
assert capsys.readouterr().out.strip() == "42"
|
|
13
|
+
assert sh.locals["_"] == 42
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_multiline(capsys):
|
|
17
|
+
sh = Shell(banner="")
|
|
18
|
+
run(sh, "def f():", " return 7", "", "f()", "%exit")
|
|
19
|
+
assert capsys.readouterr().out.strip() == "7"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_custom_command(capsys):
|
|
23
|
+
sh = Shell(banner="")
|
|
24
|
+
|
|
25
|
+
@sh.command("hi")
|
|
26
|
+
def hi(shell, name="you"):
|
|
27
|
+
shell.print(f"hi {name}")
|
|
28
|
+
|
|
29
|
+
run(sh, "%hi bob", "%exit")
|
|
30
|
+
assert "hi bob" in capsys.readouterr().out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_unknown_command(capsys):
|
|
34
|
+
sh = Shell(banner="")
|
|
35
|
+
run(sh, "%nope", "%exit")
|
|
36
|
+
assert "Unknown command" in capsys.readouterr().out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_pre_run_rewrites_input(capsys):
|
|
40
|
+
sh = Shell(banner="")
|
|
41
|
+
sh.on("pre_run", lambda s, src: "1 + 1" if src.strip() == "two" else None)
|
|
42
|
+
run(sh, "two", "%exit")
|
|
43
|
+
assert capsys.readouterr().out.strip() == "2"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_error_hook_and_post_run(capsys):
|
|
47
|
+
sh = Shell(banner="")
|
|
48
|
+
seen = {}
|
|
49
|
+
sh.on("error", lambda s, t, e, tb: seen.setdefault("err", t.__name__))
|
|
50
|
+
sh.on("post_run", lambda s, src, ok: seen.setdefault("ok", ok))
|
|
51
|
+
run(sh, "1/0", "%exit")
|
|
52
|
+
assert seen == {"err": "ZeroDivisionError", "ok": False}
|
|
53
|
+
assert "ZeroDivisionError" in capsys.readouterr().err
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_reset_restores_namespace():
|
|
57
|
+
sh = Shell({"a": 1}, banner="")
|
|
58
|
+
run(sh, "a = 99", "b = 2", "%reset", "%exit")
|
|
59
|
+
assert sh.locals["a"] == 1 and "b" not in sh.locals
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_dynamic_prompt():
|
|
63
|
+
prompts = []
|
|
64
|
+
sh = Shell(banner="", prompt=lambda s, more: "P> ")
|
|
65
|
+
it = iter(["%exit"])
|
|
66
|
+
sh.run(input_fn=lambda p: (prompts.append(p), next(it))[1])
|
|
67
|
+
assert prompts == ["P> "]
|