repl-session 0.1.0__tar.gz → 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repl-session
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Runs a session with a Read-Eval-Print-Loop (REPL). Takes YAML as input gives JSON as output.
5
5
  Classifier: Development Status :: 3 - Alpha
6
6
  Classifier: Environment :: Console
@@ -11,7 +11,7 @@ Classifier: Topic :: Utilities
11
11
  Requires-Python: >=3.11
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: argh>=0.30.5
14
+ Requires-Dist: click>=8.1
15
15
  Requires-Dist: msgspec>=0.19.0
16
16
  Requires-Dist: pexpect>=4.9.0
17
17
  Dynamic: license-file
@@ -60,22 +60,22 @@ I like to work with [Chez Scheme](https://cisco.github.io/ChezScheme/). Suppose
60
60
  ```yaml
61
61
  #| file: test/scheme.yml
62
62
  config:
63
- command: "scheme --eedisable"
64
- first_prompt: "> "
65
- change_prompt: "(waiter-prompt-string \"{key}>\")"
66
- next_prompt: "{key}> "
67
- strip_command: true
63
+ command: "scheme --eedisable"
64
+ first_prompt: "> "
65
+ change_prompt: '(waiter-prompt-string "{key}>")'
66
+ prompt: "{key}> "
67
+ # continuation_prompt: ""
68
68
  commands:
69
- - command: (* 6 7)
70
- - command: |
69
+ - command: (* 6 7)
70
+ - command: |
71
71
  (define (fac n init)
72
72
  (if (zero? n)
73
73
  init
74
74
  (fac (- n 1) (* init n)))))
75
- - command: (fac 10 1)
75
+ - command: (fac 10 1)
76
76
  ```
77
77
 
78
- 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:
78
+ 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!). Running this should give:
79
79
 
80
80
  ```bash
81
81
  repl-session < test/scheme.yml | jq '.commands.[].output'
@@ -83,7 +83,7 @@ repl-session < test/scheme.yml | jq '.commands.[].output'
83
83
 
84
84
  ```
85
85
  "42"
86
- "(define (fac n init)\n (if (zero? n)\n init\n (fac (- n 1) (* init n)))))"
86
+ null
87
87
  "3628800"
88
88
  ```
89
89
 
@@ -94,15 +94,27 @@ This looks very similar to the previous example:
94
94
  ```yaml
95
95
  #| file: test/lua.yml
96
96
  config:
97
- command: "lua"
98
- first_prompt: "> "
99
- change_prompt: "_PROMPT = \"{key}> \""
100
- next_prompt: "{key}> "
101
- strip_command: true
102
- strip_ansi: true
97
+ command: "lua"
98
+ first_prompt: "> "
99
+ change_prompt: '_PROMPT = "{key}> "; _PROMPT2 = "{key}+ "'
100
+ prompt: "{key}> "
101
+ continuation_prompt: "{key}\\+ "
102
+ strip_ansi: true
103
103
  commands:
104
- - command: 6 * 7
105
- - command: "\"Hello\" .. \", \" .. \"World!\""
104
+ - command: 6 * 7
105
+ - command: '"Hello" .. ", " .. "World!"'
106
+ - command: |
107
+ function fac(n, m)
108
+ if m == nil then
109
+ return fac(n, 1)
110
+ end
111
+ if n == 0 then
112
+ return m
113
+ else
114
+ return fac(n-1, m*n)
115
+ end
116
+ end
117
+ - command: fac(10)
106
118
  ```
107
119
 
108
120
  The Lua REPL is not so nice. It sends ANSI escape codes and those need to be filtered out.
@@ -114,6 +126,45 @@ repl-session < test/lua.yml | jq '.commands.[].output'
114
126
  ```
115
127
  "42"
116
128
  "Hello, World!"
129
+ null
130
+ "3628800"
131
+ ```
132
+
133
+ ### Python
134
+
135
+ The Python REPL got a revision in version 3.13, with lots of colour and ANSI codes.
136
+
137
+ ```yaml
138
+ #| file: test/python.yml
139
+ config:
140
+ command: python -q
141
+ first_prompt: ">>>"
142
+ change_prompt: 'import sys; sys.ps1 = "{key}>>> "; sys.ps2 = "{key}+++ "'
143
+ prompt: "{key}>>> "
144
+ continuation_prompt: "{key}\\+\\+\\+ "
145
+ environment:
146
+ NO_COLOR: "1"
147
+ PYTHON_BASIC_REPL: "1"
148
+ commands:
149
+ - command: print("Hello, World!")
150
+ - command: 6 * 7
151
+ - command: |
152
+ def fac(n):
153
+ for i in range(1, n):
154
+ n *= i
155
+ return n
156
+ - command: fac(10)
157
+ ```
158
+
159
+ ```bash
160
+ repl-session < test/python.yml | jq '.commands.[].output'
161
+ ```
162
+
163
+ ```
164
+ "Hello, World!"
165
+ "42"
166
+ null
167
+ "3628800"
117
168
  ```
118
169
 
119
170
  ## Input/Output structure
@@ -137,13 +188,14 @@ class ReplConfig(msgspec.Struct):
137
188
  output; useful if the REPL echoes your input before answering.
138
189
  timeout (float): Command timeout for this session in seconds.
139
190
  """
191
+
140
192
  command: str
141
193
  first_prompt: str
142
194
  change_prompt: str
143
- next_prompt: str
144
- append_newline: bool = True
145
- strip_command: bool = False
195
+ prompt: str
196
+ continuation_prompt: str | None = None
146
197
  strip_ansi: bool = False
198
+ environment: dict[str, str] = msgspec.field(default_factory=dict)
147
199
  timeout: float = 5.0
148
200
  ```
149
201
 
@@ -160,6 +212,7 @@ class ReplCommand(msgspec.Struct):
160
212
  output (str | None): evaluated output.
161
213
  expected (str | None): expected output.
162
214
  """
215
+
163
216
  command: str
164
217
  output_type: str = "text/plain"
165
218
  output: str | None = None
@@ -173,6 +226,7 @@ class ReplSession(msgspec.Struct):
173
226
  config (ReplConfig): Config for setting up a REPL session.
174
227
  commands (list[ReplCommand]): List of commands in the session.
175
228
  """
229
+
176
230
  config: ReplConfig
177
231
  commands: list[ReplCommand]
178
232
  ```
@@ -42,22 +42,22 @@ I like to work with [Chez Scheme](https://cisco.github.io/ChezScheme/). Suppose
42
42
  ```yaml
43
43
  #| file: test/scheme.yml
44
44
  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: ""
50
50
  commands:
51
- - command: (* 6 7)
52
- - command: |
51
+ - command: (* 6 7)
52
+ - command: |
53
53
  (define (fac n init)
54
54
  (if (zero? n)
55
55
  init
56
56
  (fac (- n 1) (* init n)))))
57
- - command: (fac 10 1)
57
+ - command: (fac 10 1)
58
58
  ```
59
59
 
60
- 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:
60
+ 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!). Running this should give:
61
61
 
62
62
  ```bash
63
63
  repl-session < test/scheme.yml | jq '.commands.[].output'
@@ -65,7 +65,7 @@ repl-session < test/scheme.yml | jq '.commands.[].output'
65
65
 
66
66
  ```
67
67
  "42"
68
- "(define (fac n init)\n (if (zero? n)\n init\n (fac (- n 1) (* init n)))))"
68
+ null
69
69
  "3628800"
70
70
  ```
71
71
 
@@ -76,15 +76,27 @@ This looks very similar to the previous example:
76
76
  ```yaml
77
77
  #| file: test/lua.yml
78
78
  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
85
85
  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)
88
100
  ```
89
101
 
90
102
  The Lua REPL is not so nice. It sends ANSI escape codes and those need to be filtered out.
@@ -96,6 +108,45 @@ repl-session < test/lua.yml | jq '.commands.[].output'
96
108
  ```
97
109
  "42"
98
110
  "Hello, World!"
111
+ null
112
+ "3628800"
113
+ ```
114
+
115
+ ### Python
116
+
117
+ The Python REPL got a revision in version 3.13, with lots of colour and ANSI codes.
118
+
119
+ ```yaml
120
+ #| file: test/python.yml
121
+ config:
122
+ command: python -q
123
+ first_prompt: ">>>"
124
+ change_prompt: 'import sys; sys.ps1 = "{key}>>> "; sys.ps2 = "{key}+++ "'
125
+ prompt: "{key}>>> "
126
+ continuation_prompt: "{key}\\+\\+\\+ "
127
+ environment:
128
+ NO_COLOR: "1"
129
+ PYTHON_BASIC_REPL: "1"
130
+ commands:
131
+ - command: print("Hello, World!")
132
+ - command: 6 * 7
133
+ - command: |
134
+ def fac(n):
135
+ for i in range(1, n):
136
+ n *= i
137
+ return n
138
+ - command: fac(10)
139
+ ```
140
+
141
+ ```bash
142
+ repl-session < test/python.yml | jq '.commands.[].output'
143
+ ```
144
+
145
+ ```
146
+ "Hello, World!"
147
+ "42"
148
+ null
149
+ "3628800"
99
150
  ```
100
151
 
101
152
  ## Input/Output structure
@@ -119,13 +170,14 @@ class ReplConfig(msgspec.Struct):
119
170
  output; useful if the REPL echoes your input before answering.
120
171
  timeout (float): Command timeout for this session in seconds.
121
172
  """
173
+
122
174
  command: str
123
175
  first_prompt: str
124
176
  change_prompt: str
125
- next_prompt: str
126
- append_newline: bool = True
127
- strip_command: bool = False
177
+ prompt: str
178
+ continuation_prompt: str | None = None
128
179
  strip_ansi: bool = False
180
+ environment: dict[str, str] = msgspec.field(default_factory=dict)
129
181
  timeout: float = 5.0
130
182
  ```
131
183
 
@@ -142,6 +194,7 @@ class ReplCommand(msgspec.Struct):
142
194
  output (str | None): evaluated output.
143
195
  expected (str | None): expected output.
144
196
  """
197
+
145
198
  command: str
146
199
  output_type: str = "text/plain"
147
200
  output: str | None = None
@@ -155,6 +208,7 @@ class ReplSession(msgspec.Struct):
155
208
  config (ReplConfig): Config for setting up a REPL session.
156
209
  commands (list[ReplCommand]): List of commands in the session.
157
210
  """
211
+
158
212
  config: ReplConfig
159
213
  commands: list[ReplCommand]
160
214
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repl-session"
3
- version = "0.1.0"
3
+ version = "0.2.1"
4
4
  description = "Runs a session with a Read-Eval-Print-Loop (REPL). Takes YAML as input gives JSON as output."
5
5
  readme = "README.md"
6
6
  classifiers = [
@@ -13,7 +13,7 @@ classifiers = [
13
13
  ]
14
14
  requires-python = ">=3.11"
15
15
  dependencies = [
16
- "argh>=0.30.5",
16
+ "click>=8.1",
17
17
  "msgspec>=0.19.0",
18
18
  "pexpect>=4.9.0",
19
19
  ]
@@ -24,7 +24,11 @@ dev = [
24
24
  "entangled-cli>=2.1.13",
25
25
  "mkdocs-macros-plugin>=1.3.9",
26
26
  "mkdocs-material>=9.6.20",
27
+ "pytest>=9.2.0",
28
+ "python-lsp-server>=1.13.1",
29
+ "ruff>=0.13.2",
27
30
  "ty>=0.0.1a21",
31
+ "types-pexpect>=4.9.0.20250916",
28
32
  ]
29
33
 
30
34
  [project.scripts]
@@ -0,0 +1,223 @@
1
+ # ~/~ begin <<docs/index.md#src/repl_session/__init__.py>>[init]
2
+ """
3
+ `repl-session` is a command-line tool to evaluate a given session
4
+ in any REPL, and store the results.
5
+ """
6
+
7
+ # ~/~ begin <<docs/index.md#imports>>[init]
8
+ # from datetime import datetime, tzinfo
9
+ from typing import IO, cast
10
+ from collections.abc import Generator, Callable
11
+
12
+ # import re
13
+ from contextlib import contextmanager
14
+ import uuid
15
+ import sys
16
+ import re
17
+ import logging
18
+ import os
19
+
20
+ import pexpect
21
+ import msgspec
22
+ import click
23
+ import importlib.metadata
24
+
25
+
26
+ __version__ = importlib.metadata.version("repl-session")
27
+ # ~/~ end
28
+ # ~/~ begin <<README.md#input-data>>[init]
29
+ class ReplConfig(msgspec.Struct):
30
+ """Configuration
31
+
32
+ Attributes:
33
+ command (str): Command to start the REPL
34
+ first_prompt (str): Regex to match the first prompt
35
+ change_prompt (str): Command to change prompt; should contain '{key}' as an
36
+ argument.
37
+ next_prompt (str): Regex to match the changed prompts; should contain '{key}'
38
+ as an argument.
39
+ append_newline (bool): Whether to append a newline to given commands.
40
+ strip_command (bool): Whether to strip the original command from the gotten
41
+ output; useful if the REPL echoes your input before answering.
42
+ timeout (float): Command timeout for this session in seconds.
43
+ """
44
+
45
+ command: str
46
+ first_prompt: str
47
+ change_prompt: str
48
+ prompt: str
49
+ continuation_prompt: str | None = None
50
+ strip_ansi: bool = False
51
+ environment: dict[str, str] = msgspec.field(default_factory=dict)
52
+ timeout: float = 5.0
53
+ # ~/~ end
54
+ # ~/~ begin <<README.md#input-data>>[1]
55
+ class ReplCommand(msgspec.Struct):
56
+ """A command to be sent to the REPL.
57
+
58
+ Attributes:
59
+ command (str): the command.
60
+ output_type (str): MIME type of expected output.
61
+ output (str | None): evaluated output.
62
+ expected (str | None): expected output.
63
+ """
64
+
65
+ command: str
66
+ output_type: str = "text/plain"
67
+ output: str | None = None
68
+ expected: str | None = None
69
+
70
+
71
+ class ReplSession(msgspec.Struct):
72
+ """A REPL session.
73
+
74
+ Attributes:
75
+ config (ReplConfig): Config for setting up a REPL session.
76
+ commands (list[ReplCommand]): List of commands in the session.
77
+ """
78
+
79
+ config: ReplConfig
80
+ commands: list[ReplCommand]
81
+ # ~/~ end
82
+ # ~/~ begin <<docs/index.md#repl-contextmanager>>[init]
83
+ def spawn(config: ReplConfig):
84
+ env = dict(os.environ) | config.environment
85
+ child: pexpect.spawn[str] = pexpect.spawn(
86
+ config.command,
87
+ timeout=config.timeout,
88
+ echo=False,
89
+ encoding="utf-8",
90
+ env=dict(os.environ) | config.environment,
91
+ )
92
+ return child
93
+
94
+
95
+ ANSI_ESCAPE = re.compile(r"(\u001b|\x1B)(\[[0-?]*[ -\/]*[@-~]|[\>\=])")
96
+
97
+
98
+ def clean_output(config: ReplConfig, output: str) -> str | None:
99
+ """Remove ANSI escapes (if requested), normalize the CRLF line endings
100
+ that the pseudo-terminal produces, and trim surrounding whitespace.
101
+ Returns `None` if nothing is left."""
102
+ if config.strip_ansi:
103
+ output = ANSI_ESCAPE.sub("", output)
104
+ output = output.replace("\r\n", "\n").replace("\r", "").strip()
105
+ return output or None
106
+
107
+
108
+ @contextmanager
109
+ def repl(config: ReplConfig) -> Generator[Callable[[str], str | None]]:
110
+ key = str(uuid.uuid4())
111
+ change_prompt_cmd = config.change_prompt.format(key=key)
112
+ prompt = config.prompt.format(key=key)
113
+ continuation_prompt = (
114
+ config.continuation_prompt.format(key=key)
115
+ if config.continuation_prompt is not None
116
+ else None
117
+ )
118
+
119
+ child: pexpect.spawn[str]
120
+ with spawn(config) as child:
121
+ _ = child.expect(config.first_prompt)
122
+ _ = child.sendline(change_prompt_cmd)
123
+ # if config.strip_command:
124
+ # child.expect(key)
125
+ _ = child.expect(prompt)
126
+
127
+ if continuation_prompt is not None:
128
+ def send(msg: str) -> str | None:
129
+ lines = msg.splitlines()
130
+ answer: list[str] = []
131
+
132
+ if not lines:
133
+ return None
134
+
135
+ still_waiting: bool = True
136
+ for line in lines:
137
+ logging.debug("sending: '%s'", line)
138
+ _ = child.sendline(line)
139
+ logging.debug("waiting for prompt or continuation")
140
+ _ = child.expect(
141
+ f"(?P<norm>{prompt})|(?P<cont>{continuation_prompt})"
142
+ )
143
+ if not isinstance(child.match, re.Match):
144
+ continue
145
+ if child.match.group("cont") is not None:
146
+ logging.debug("continuation: %s -- %s", child.before, child.after)
147
+ still_waiting = True
148
+ else:
149
+ logging.debug("done: %s", child.before)
150
+ if child.before is not None:
151
+ answer.append(child.before)
152
+ still_waiting = False
153
+
154
+ if still_waiting:
155
+ logging.debug(f"waiting for last prompt")
156
+ _ = child.sendline("")
157
+ _ = child.expect(prompt)
158
+ logging.debug(f"got: %s", child.before)
159
+ if child.before:
160
+ answer.append(child.before)
161
+
162
+ if not answer:
163
+ return None
164
+
165
+ return clean_output(config, answer[-1])
166
+
167
+ else:
168
+ def send(msg: str) -> str | None:
169
+ logging.debug("sending: %s", msg)
170
+
171
+ _ = child.sendline(msg)
172
+ _ = child.expect(prompt)
173
+
174
+ if not child.before:
175
+ return None
176
+
177
+ return clean_output(config, child.before)
178
+
179
+ yield send
180
+ # ~/~ end
181
+ # ~/~ begin <<docs/index.md#run-session>>[init]
182
+ def run_session(session: ReplSession):
183
+ with repl(session.config) as run:
184
+ for cmd in session.commands:
185
+ expected = cmd.expected or cmd.output
186
+ output = run(cmd.command)
187
+ cmd.output = output
188
+ cmd.expected = expected
189
+
190
+ return session
191
+ # ~/~ end
192
+ # ~/~ begin <<docs/index.md#io>>[init]
193
+ def read_session(port: IO[str] = sys.stdin) -> ReplSession:
194
+ data: str = port.read()
195
+ return msgspec.yaml.decode(data, type=ReplSession)
196
+
197
+
198
+ def write_session(session: ReplSession, port: IO[str] = sys.stdout):
199
+ data = msgspec.json.encode(session)
200
+ _ = port.write(data.decode())
201
+ # ~/~ end
202
+
203
+ @click.command()
204
+ @click.version_option(
205
+ __version__, "-v", "--version",
206
+ prog_name="repl-session", message="%(prog)s %(version)s",
207
+ help="show version and exit")
208
+ @click.option("-l", "--log-enable", is_flag=True, help="show debugging output")
209
+ def repl_session(log_enable: bool = False):
210
+ """
211
+ repl-session runs a REPL session, reading JSON from standard input and
212
+ writing to standard output. Both the input and output follow the same
213
+ schema.
214
+ """
215
+ if log_enable:
216
+ logging.basicConfig(level=logging.DEBUG)
217
+
218
+ write_session(run_session(read_session()))
219
+
220
+
221
+ def main():
222
+ repl_session()
223
+ # ~/~ end
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repl-session
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Runs a session with a Read-Eval-Print-Loop (REPL). Takes YAML as input gives JSON as output.
5
5
  Classifier: Development Status :: 3 - Alpha
6
6
  Classifier: Environment :: Console
@@ -11,7 +11,7 @@ Classifier: Topic :: Utilities
11
11
  Requires-Python: >=3.11
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: argh>=0.30.5
14
+ Requires-Dist: click>=8.1
15
15
  Requires-Dist: msgspec>=0.19.0
16
16
  Requires-Dist: pexpect>=4.9.0
17
17
  Dynamic: license-file
@@ -60,22 +60,22 @@ I like to work with [Chez Scheme](https://cisco.github.io/ChezScheme/). Suppose
60
60
  ```yaml
61
61
  #| file: test/scheme.yml
62
62
  config:
63
- command: "scheme --eedisable"
64
- first_prompt: "> "
65
- change_prompt: "(waiter-prompt-string \"{key}>\")"
66
- next_prompt: "{key}> "
67
- strip_command: true
63
+ command: "scheme --eedisable"
64
+ first_prompt: "> "
65
+ change_prompt: '(waiter-prompt-string "{key}>")'
66
+ prompt: "{key}> "
67
+ # continuation_prompt: ""
68
68
  commands:
69
- - command: (* 6 7)
70
- - command: |
69
+ - command: (* 6 7)
70
+ - command: |
71
71
  (define (fac n init)
72
72
  (if (zero? n)
73
73
  init
74
74
  (fac (- n 1) (* init n)))))
75
- - command: (fac 10 1)
75
+ - command: (fac 10 1)
76
76
  ```
77
77
 
78
- 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:
78
+ 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!). Running this should give:
79
79
 
80
80
  ```bash
81
81
  repl-session < test/scheme.yml | jq '.commands.[].output'
@@ -83,7 +83,7 @@ repl-session < test/scheme.yml | jq '.commands.[].output'
83
83
 
84
84
  ```
85
85
  "42"
86
- "(define (fac n init)\n (if (zero? n)\n init\n (fac (- n 1) (* init n)))))"
86
+ null
87
87
  "3628800"
88
88
  ```
89
89
 
@@ -94,15 +94,27 @@ This looks very similar to the previous example:
94
94
  ```yaml
95
95
  #| file: test/lua.yml
96
96
  config:
97
- command: "lua"
98
- first_prompt: "> "
99
- change_prompt: "_PROMPT = \"{key}> \""
100
- next_prompt: "{key}> "
101
- strip_command: true
102
- strip_ansi: true
97
+ command: "lua"
98
+ first_prompt: "> "
99
+ change_prompt: '_PROMPT = "{key}> "; _PROMPT2 = "{key}+ "'
100
+ prompt: "{key}> "
101
+ continuation_prompt: "{key}\\+ "
102
+ strip_ansi: true
103
103
  commands:
104
- - command: 6 * 7
105
- - command: "\"Hello\" .. \", \" .. \"World!\""
104
+ - command: 6 * 7
105
+ - command: '"Hello" .. ", " .. "World!"'
106
+ - command: |
107
+ function fac(n, m)
108
+ if m == nil then
109
+ return fac(n, 1)
110
+ end
111
+ if n == 0 then
112
+ return m
113
+ else
114
+ return fac(n-1, m*n)
115
+ end
116
+ end
117
+ - command: fac(10)
106
118
  ```
107
119
 
108
120
  The Lua REPL is not so nice. It sends ANSI escape codes and those need to be filtered out.
@@ -114,6 +126,45 @@ repl-session < test/lua.yml | jq '.commands.[].output'
114
126
  ```
115
127
  "42"
116
128
  "Hello, World!"
129
+ null
130
+ "3628800"
131
+ ```
132
+
133
+ ### Python
134
+
135
+ The Python REPL got a revision in version 3.13, with lots of colour and ANSI codes.
136
+
137
+ ```yaml
138
+ #| file: test/python.yml
139
+ config:
140
+ command: python -q
141
+ first_prompt: ">>>"
142
+ change_prompt: 'import sys; sys.ps1 = "{key}>>> "; sys.ps2 = "{key}+++ "'
143
+ prompt: "{key}>>> "
144
+ continuation_prompt: "{key}\\+\\+\\+ "
145
+ environment:
146
+ NO_COLOR: "1"
147
+ PYTHON_BASIC_REPL: "1"
148
+ commands:
149
+ - command: print("Hello, World!")
150
+ - command: 6 * 7
151
+ - command: |
152
+ def fac(n):
153
+ for i in range(1, n):
154
+ n *= i
155
+ return n
156
+ - command: fac(10)
157
+ ```
158
+
159
+ ```bash
160
+ repl-session < test/python.yml | jq '.commands.[].output'
161
+ ```
162
+
163
+ ```
164
+ "Hello, World!"
165
+ "42"
166
+ null
167
+ "3628800"
117
168
  ```
118
169
 
119
170
  ## Input/Output structure
@@ -137,13 +188,14 @@ class ReplConfig(msgspec.Struct):
137
188
  output; useful if the REPL echoes your input before answering.
138
189
  timeout (float): Command timeout for this session in seconds.
139
190
  """
191
+
140
192
  command: str
141
193
  first_prompt: str
142
194
  change_prompt: str
143
- next_prompt: str
144
- append_newline: bool = True
145
- strip_command: bool = False
195
+ prompt: str
196
+ continuation_prompt: str | None = None
146
197
  strip_ansi: bool = False
198
+ environment: dict[str, str] = msgspec.field(default_factory=dict)
147
199
  timeout: float = 5.0
148
200
  ```
149
201
 
@@ -160,6 +212,7 @@ class ReplCommand(msgspec.Struct):
160
212
  output (str | None): evaluated output.
161
213
  expected (str | None): expected output.
162
214
  """
215
+
163
216
  command: str
164
217
  output_type: str = "text/plain"
165
218
  output: str | None = None
@@ -173,6 +226,7 @@ class ReplSession(msgspec.Struct):
173
226
  config (ReplConfig): Config for setting up a REPL session.
174
227
  commands (list[ReplCommand]): List of commands in the session.
175
228
  """
229
+
176
230
  config: ReplConfig
177
231
  commands: list[ReplCommand]
178
232
  ```
@@ -8,4 +8,5 @@ src/repl_session.egg-info/SOURCES.txt
8
8
  src/repl_session.egg-info/dependency_links.txt
9
9
  src/repl_session.egg-info/entry_points.txt
10
10
  src/repl_session.egg-info/requires.txt
11
- src/repl_session.egg-info/top_level.txt
11
+ src/repl_session.egg-info/top_level.txt
12
+ test/test_examples.py
@@ -1,3 +1,3 @@
1
- argh>=0.30.5
1
+ click>=8.1
2
2
  msgspec>=0.19.0
3
3
  pexpect>=4.9.0
@@ -0,0 +1,40 @@
1
+ """Run the example sessions from the README and check their outputs."""
2
+
3
+ import os
4
+ import shlex
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ import msgspec
9
+ import pytest
10
+
11
+ from repl_session import ReplSession, run_session
12
+
13
+
14
+ TEST_DIR = Path(__file__).parent
15
+
16
+
17
+ EXPECTED = {
18
+ "scheme": ["42", None, "3628800"],
19
+ "lua": ["42", "Hello, World!", None, "3628800"],
20
+ "python": ["Hello, World!", "42", None, "3628800"],
21
+ }
22
+
23
+
24
+ def load_session(name: str) -> ReplSession:
25
+ data = (TEST_DIR / f"{name}.yml").read_bytes()
26
+ return msgspec.yaml.decode(data, type=ReplSession)
27
+
28
+
29
+ @pytest.mark.parametrize("name", EXPECTED.keys())
30
+ def test_example(name: str):
31
+ session = load_session(name)
32
+ executable = shlex.split(session.config.command)[0]
33
+ if shutil.which(executable) is None:
34
+ # In CI every interpreter must be available; locally we allow skipping.
35
+ if os.environ.get("CI"):
36
+ pytest.fail(f"`{executable}` not found")
37
+ pytest.skip(f"`{executable}` not found")
38
+
39
+ result = run_session(session)
40
+ assert [cmd.output for cmd in result.commands] == EXPECTED[name]
@@ -1,159 +0,0 @@
1
- # ~/~ begin <<docs/index.md#src/repl_session/__init__.py>>[init]
2
- #| file: src/repl_session/__init__.py
3
- """
4
- `repl-session` is a command-line tool to evaluate a given session
5
- in any REPL, and store the results.
6
- """
7
- # ~/~ begin <<docs/index.md#imports>>[init]
8
- #| id: imports
9
- # from datetime import datetime, tzinfo
10
- from typing import IO
11
- from collections.abc import Generator, Callable
12
- # import re
13
- from contextlib import contextmanager
14
- import uuid
15
- import sys
16
- import re
17
-
18
- import pexpect
19
- import msgspec
20
- import argh
21
- import importlib.metadata
22
-
23
-
24
- __version__ = importlib.metadata.version("repl-session")
25
- # ~/~ end
26
-
27
- # ~/~ begin <<README.md#input-data>>[init]
28
- #| id: input-data
29
- class ReplConfig(msgspec.Struct):
30
- """Configuration
31
-
32
- Attributes:
33
- command (str): Command to start the REPL
34
- first_prompt (str): Regex to match the first prompt
35
- change_prompt (str): Command to change prompt; should contain '{key}' as an
36
- argument.
37
- next_prompt (str): Regex to match the changed prompts; should contain '{key}'
38
- as an argument.
39
- append_newline (bool): Whether to append a newline to given commands.
40
- strip_command (bool): Whether to strip the original command from the gotten
41
- output; useful if the REPL echoes your input before answering.
42
- timeout (float): Command timeout for this session in seconds.
43
- """
44
- command: str
45
- first_prompt: str
46
- change_prompt: str
47
- next_prompt: str
48
- append_newline: bool = True
49
- strip_command: bool = False
50
- strip_ansi: bool = False
51
- timeout: float = 5.0
52
- # ~/~ end
53
- # ~/~ begin <<README.md#input-data>>[1]
54
- #| id: input-data
55
- class ReplCommand(msgspec.Struct):
56
- """A command to be sent to the REPL.
57
-
58
- Attributes:
59
- command (str): the command.
60
- output_type (str): MIME type of expected output.
61
- output (str | None): evaluated output.
62
- expected (str | None): expected output.
63
- """
64
- command: str
65
- output_type: str = "text/plain"
66
- output: str | None = None
67
- expected: str | None = None
68
-
69
-
70
- class ReplSession(msgspec.Struct):
71
- """A REPL session.
72
-
73
- Attributes:
74
- config (ReplConfig): Config for setting up a REPL session.
75
- commands (list[ReplCommand]): List of commands in the session.
76
- """
77
- config: ReplConfig
78
- commands: list[ReplCommand]
79
- # ~/~ end
80
-
81
- # ~/~ begin <<docs/index.md#repl-contextmanager>>[init]
82
- #| id: repl-contextmanager
83
-
84
- @contextmanager
85
- def repl(config: ReplConfig) -> Generator[Callable[[str], str]]:
86
- key = str(uuid.uuid4())
87
-
88
- with pexpect.spawn(config.command, timeout=config.timeout) as child:
89
- child.expect(config.first_prompt)
90
- change_prompt_cmd = config.change_prompt.format(key=key)
91
- if config.append_newline:
92
- change_prompt_cmd = change_prompt_cmd + "\n"
93
- child.send(change_prompt_cmd)
94
- if config.strip_command:
95
- child.expect(key)
96
- prompt = config.next_prompt.format(key=key)
97
- child.expect(prompt)
98
-
99
- def send(msg: str) -> str:
100
- if config.append_newline:
101
- msg = msg + "\n"
102
- child.send(msg)
103
- child.expect("(.*)" + prompt)
104
-
105
- answer = child.match[1].decode()
106
- if config.strip_ansi:
107
- ansi_escape = re.compile(r'(\u001b\[|\x1B\[)[0-?]*[ -\/]*[@-~]')
108
- answer = ansi_escape.sub("", answer)
109
- if config.strip_command:
110
- answer = answer.strip().replace("\r", "")
111
- return answer.removeprefix(msg)
112
- else:
113
- return child.match[1].decode()
114
-
115
- yield send
116
- # ~/~ end
117
- # ~/~ begin <<docs/index.md#run-session>>[init]
118
- #| id: run-session
119
- def run_session(session: ReplSession):
120
- with repl(session.config) as run:
121
- for cmd in session.commands:
122
- expected = cmd.expected or cmd.output
123
- output = run(cmd.command)
124
- cmd.output = output
125
- cmd.expected = expected
126
-
127
- return session
128
- # ~/~ end
129
- # ~/~ begin <<docs/index.md#io>>[init]
130
- #| id: io
131
-
132
- def read_session(port: IO[str] = sys.stdin) -> ReplSession:
133
- data: str = port.read()
134
- return msgspec.yaml.decode(data, type=ReplSession)
135
-
136
-
137
- def write_session(session: ReplSession, port: IO[str] = sys.stdout):
138
- data = msgspec.json.encode(session)
139
- port.write(data.decode())
140
- # ~/~ end
141
-
142
-
143
- @argh.arg("-v", "--version", help="show version and exit")
144
- def repl_session(version: bool = False):
145
- """
146
- repl-session runs a REPL session, reading JSON from standard input and
147
- writing to standard output. Both the input and output follow the same
148
- schema.
149
- """
150
- if version:
151
- print(f"repl-session {__version__}")
152
- sys.exit(0)
153
-
154
- write_session(run_session(read_session()))
155
-
156
-
157
- def main():
158
- argh.dispatch_command(repl_session)
159
- # ~/~ end
File without changes
File without changes