Skip to content

Commit db651ef

Browse files
committed
lots of improvements; handling of continuation prompts; test with python repl
1 parent 3bf66a8 commit db651ef

8 files changed

Lines changed: 544 additions & 100 deletions

File tree

README.md

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,19 @@ I like to work with [Chez Scheme](https://cisco.github.io/ChezScheme/). Suppose
4242
```yaml
4343
#| file: test/scheme.yml
4444
config:
45-
command: "scheme --eedisable"
46-
first_prompt: "> "
47-
change_prompt: "(waiter-prompt-string \"{key}>\")"
48-
next_prompt: "{key}> "
49-
strip_command: true
45+
command: "scheme --eedisable"
46+
first_prompt: "> "
47+
change_prompt: '(waiter-prompt-string "{key}>")'
48+
prompt: "{key}> "
49+
continuation_prompt: ""
5050
commands:
51-
- command: (* 6 7)
52-
- command: |
51+
- command: (* 6 7)
52+
- command: |
5353
(define (fac n init)
5454
(if (zero? n)
5555
init
5656
(fac (- n 1) (* init n)))))
57-
- command: (fac 10 1)
57+
- command: (fac 10 1)
5858
```
5959
6060
Passing this to `repl-session`, it will start the Chez Scheme interpreter, waiting for the `>` prompt to appear. It then changes the prompt to a generated `uuid4` code, for instance `27e87a8a-742c-4501-b05d-b05814f5a010> `. This will make sure that we can't accidentally match something else for an interactive prompt (imagine we're generating some XML!). Since commands are also echoed to standard out, we need to strip them from the resulting output. Running this should give:
@@ -76,15 +76,27 @@ This looks very similar to the previous example:
7676
```yaml
7777
#| file: test/lua.yml
7878
config:
79-
command: "lua"
80-
first_prompt: "> "
81-
change_prompt: "_PROMPT = \"{key}> \""
82-
next_prompt: "{key}> "
83-
strip_command: true
84-
strip_ansi: true
79+
command: "lua"
80+
first_prompt: "> "
81+
change_prompt: '_PROMPT = "{key}> "; _PROMPT2 = "{key}+ "'
82+
prompt: "{key}> "
83+
continuation_prompt: "{key}\\+ "
84+
strip_ansi: true
8585
commands:
86-
- command: 6 * 7
87-
- command: "\"Hello\" .. \", \" .. \"World!\""
86+
- command: 6 * 7
87+
- command: '"Hello" .. ", " .. "World!"'
88+
- command: |
89+
function fac(n, m)
90+
if m == nil then
91+
return fac(n, 1)
92+
end
93+
if n == 0 then
94+
return m
95+
else
96+
return fac(n-1, m*n)
97+
end
98+
end
99+
- command: fac(10)
88100
```
89101

90102
The Lua REPL is not so nice. It sends ANSI escape codes and those need to be filtered out.
@@ -98,6 +110,31 @@ repl-session < test/lua.yml | jq '.commands.[].output'
98110
"Hello, World!"
99111
```
100112

113+
### Python
114+
115+
The Python REPL got a revision in version 3.13, with lots of colour and ANSI codes.
116+
117+
```yaml
118+
#| file: test/python.yml
119+
config:
120+
command: python -q
121+
first_prompt: ">>>"
122+
change_prompt: 'import sys; sys.ps1 = "{key}>>> "; sys.ps2 = "{key}+++ "'
123+
prompt: "{key}>>> "
124+
continuation_prompt: "{key}\\+\\+\\+ "
125+
environment:
126+
NO_COLOR: "1"
127+
commands:
128+
- command: print("Hello, World!")
129+
- command: 6 * 7
130+
- command: |
131+
def fac(n):
132+
for i in range(1, n):
133+
n *= i
134+
return n
135+
- command: fac(10)
136+
```
137+
101138
## Input/Output structure
102139
103140
The user can configure how the REPL is called and interpreted.
@@ -119,14 +156,17 @@ class ReplConfig(msgspec.Struct):
119156
output; useful if the REPL echoes your input before answering.
120157
timeout (float): Command timeout for this session in seconds.
121158
"""
159+
122160
command: str
123161
first_prompt: str
124162
change_prompt: str
125-
next_prompt: str
126-
append_newline: bool = True
127-
strip_command: bool = False
163+
prompt: str
164+
continuation_prompt: str | None = None
128165
strip_ansi: bool = False
166+
environment: dict[str, str] = msgspec.field(default_factory=dict)
129167
timeout: float = 5.0
168+
169+
130170
```
131171

132172
Then, a session is a list of commands. Each command should be a UTF-8 string, and we allow to attach some meta-data like expected MIME type for the output. We can also pass an expected output in the case of a documentation test. If `output` was already given on the input, it is moved to `expected`. This way it becomes really easy to setup regression tests on your documentation. Just rerun on the generated output file.
@@ -142,6 +182,7 @@ class ReplCommand(msgspec.Struct):
142182
output (str | None): evaluated output.
143183
expected (str | None): expected output.
144184
"""
185+
145186
command: str
146187
output_type: str = "text/plain"
147188
output: str | None = None
@@ -155,8 +196,11 @@ class ReplSession(msgspec.Struct):
155196
config (ReplConfig): Config for setting up a REPL session.
156197
commands (list[ReplCommand]): List of commands in the session.
157198
"""
199+
158200
config: ReplConfig
159201
commands: list[ReplCommand]
202+
203+
160204
```
161205

162206
## License and contribution

docs/index.md

Lines changed: 90 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,39 +6,85 @@ The core of the implementation is handled by the `pexpect` library. We have a sm
66

77
```python
88
#| id: repl-contextmanager
9+
def spawn(config: ReplConfig):
10+
child: pexpect.spawn[str] = pexpect.spawn(
11+
config.command,
12+
timeout=config.timeout,
13+
echo=False,
14+
encoding="utf-8",
15+
env=config.environment,
16+
)
17+
return child
18+
919

1020
@contextmanager
11-
def repl(config: ReplConfig) -> Generator[Callable[[str], str]]:
21+
def repl(config: ReplConfig) -> Generator[Callable[[str], str | None]]:
1222
key = str(uuid.uuid4())
23+
change_prompt_cmd = config.change_prompt.format(key=key)
24+
prompt = config.prompt.format(key=key)
25+
continuation_prompt = (
26+
config.continuation_prompt.format(key=key)
27+
if config.continuation_prompt is not None
28+
else None
29+
)
30+
31+
child: pexpect.spawn[str]
32+
with spawn(config) as child:
33+
_ = child.expect(config.first_prompt)
34+
_ = child.sendline(change_prompt_cmd)
35+
# if config.strip_command:
36+
# child.expect(key)
37+
_ = child.expect(prompt)
38+
39+
def send(msg: str) -> str | None:
40+
nonlocal prompt, continuation_prompt, change_prompt_cmd
41+
lines = msg.splitlines()
42+
answer: list[str] = []
43+
44+
still_waiting: bool = True
45+
for line in lines:
46+
logging.debug("sending: %s", line)
47+
_ = child.sendline(line)
48+
if continuation_prompt is not None:
49+
logging.debug("waiting for prompt or continuation")
50+
_ = child.expect(
51+
f"(?P<cont>{continuation_prompt})|(?P<norm>{prompt})"
52+
)
53+
if not isinstance(child.match, re.Match):
54+
continue
55+
if child.match.group("cont") is not None:
56+
logging.debug("continuation")
57+
still_waiting = True
58+
else:
59+
logging.debug("done: %s", child.before)
60+
if child.before is not None:
61+
answer.append(child.before)
62+
still_waiting = False
63+
else:
64+
logging.debug("waiting for prompt")
65+
_ = child.expect(prompt)
66+
if child.before:
67+
answer.append(child.before)
68+
still_waiting = False
69+
70+
if still_waiting:
71+
_ = child.sendline("")
72+
_ = child.expect(prompt)
73+
if child.before:
74+
answer.append(child.before)
75+
76+
if not answer:
77+
return None
1378

14-
with pexpect.spawn(config.command, timeout=config.timeout) as child:
15-
child.expect(config.first_prompt)
16-
change_prompt_cmd = config.change_prompt.format(key=key)
17-
if config.append_newline:
18-
change_prompt_cmd = change_prompt_cmd + "\n"
19-
child.send(change_prompt_cmd)
20-
if config.strip_command:
21-
child.expect(key)
22-
prompt = config.next_prompt.format(key=key)
23-
child.expect(prompt)
24-
25-
def send(msg: str) -> str:
26-
if config.append_newline:
27-
msg = msg + "\n"
28-
child.send(msg)
29-
child.expect("(.*)" + prompt)
30-
31-
answer = child.match[1].decode()
3279
if config.strip_ansi:
33-
ansi_escape = re.compile(r'(\u001b\[|\x1B\[)[0-?]*[ -\/]*[@-~]')
34-
answer = ansi_escape.sub("", answer)
35-
if config.strip_command:
36-
answer = answer.strip().replace("\r", "")
37-
return answer.removeprefix(msg)
38-
else:
39-
return child.match[1].decode()
80+
ansi_escape = re.compile(r"(\u001b\[|\x1B\[)[0-?]*[ -\/]*[@-~]")
81+
return ansi_escape.sub("", answer[-1].strip())
82+
83+
return answer[-1].strip()
4084

4185
yield send
86+
87+
4288
```
4389

4490
We use this to run a session. The session is modified in place.
@@ -54,6 +100,8 @@ def run_session(session: ReplSession):
54100
cmd.expected = expected
55101

56102
return session
103+
104+
57105
```
58106

59107
### I/O
@@ -62,29 +110,32 @@ I/O is handled by `msgspec`.
62110

63111
```python
64112
#| id: io
65-
66113
def read_session(port: IO[str] = sys.stdin) -> ReplSession:
67114
data: str = port.read()
68115
return msgspec.yaml.decode(data, type=ReplSession)
69116

70117

71118
def write_session(session: ReplSession, port: IO[str] = sys.stdout):
72119
data = msgspec.json.encode(session)
73-
port.write(data.decode())
120+
_ = port.write(data.decode())
121+
122+
74123
```
75124

76125
## Imports
77126

78127
```python
79128
#| id: imports
80129
# from datetime import datetime, tzinfo
81-
from typing import IO
130+
from typing import IO, cast
82131
from collections.abc import Generator, Callable
132+
83133
# import re
84134
from contextlib import contextmanager
85135
import uuid
86136
import sys
87137
import re
138+
import logging
88139

89140
import pexpect
90141
import msgspec
@@ -103,17 +154,21 @@ __version__ = importlib.metadata.version("repl-session")
103154
`repl-session` is a command-line tool to evaluate a given session
104155
in any REPL, and store the results.
105156
"""
157+
106158
<<imports>>
107159

160+
108161
<<input-data>>
109162

163+
110164
<<repl-contextmanager>>
111165
<<run-session>>
112166
<<io>>
113167

114168

115169
@argh.arg("-v", "--version", help="show version and exit")
116-
def repl_session(version: bool = False):
170+
@argh.arg("-l", "--log-enable", help="show debugging output")
171+
def repl_session(version: bool = False, log_enable: bool = False):
117172
"""
118173
repl-session runs a REPL session, reading JSON from standard input and
119174
writing to standard output. Both the input and output follow the same
@@ -123,9 +178,14 @@ def repl_session(version: bool = False):
123178
print(f"repl-session {__version__}")
124179
sys.exit(0)
125180

181+
if log_enable:
182+
logging.basicConfig(level=logging.DEBUG)
183+
126184
write_session(run_session(read_session()))
127185

128186

129187
def main():
130188
argh.dispatch_command(repl_session)
189+
190+
131191
```

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "repl-session"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
description = "Runs a session with a Read-Eval-Print-Loop (REPL). Takes YAML as input gives JSON as output."
55
readme = "README.md"
66
classifiers = [
@@ -24,6 +24,8 @@ dev = [
2424
"entangled-cli>=2.1.13",
2525
"mkdocs-macros-plugin>=1.3.9",
2626
"mkdocs-material>=9.6.20",
27+
"python-lsp-server>=1.13.1",
28+
"ruff>=0.13.2",
2729
"ty>=0.0.1a21",
2830
]
2931

0 commit comments

Comments
 (0)