repl-session 0.2.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repl-session
3
- Version: 0.2.0
3
+ Version: 0.3.0
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,9 +11,11 @@ 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
+ Provides-Extra: jupyter
18
+ Requires-Dist: jupyter-client>=8.6; extra == "jupyter"
17
19
  Dynamic: license-file
18
20
 
19
21
  # REPL Session
@@ -33,7 +35,7 @@ This is only really useful if you're hacking together a literate programming env
33
35
 
34
36
  The REPL of your choice is started and interacted with through the `pexpect` library. All I/O is dealt with through `msgspec`.
35
37
 
36
- The preferred way to solve this is by using `jupyter-client` in combination with an existing Jupyter kernel. However, not all languages have a Jupyter kernel available, and developing one takes a bit more than configuring `pexpect` for an existing REPL. I may still include Jupyter support in this application at a later stage.
38
+ The preferred way to solve this is by using `jupyter-client` in combination with an existing Jupyter kernel. However, not all languages have a Jupyter kernel available, and developing one takes a bit more than configuring `pexpect` for an existing REPL. If a kernel is available for your language, you can use it through the optional Jupyter backend: `pip install repl-session[jupyter]`, then see [the Jupyter backend docs](https://entangled.github.io/repl-session/jupyter).
37
39
 
38
40
  ## Install
39
41
 
@@ -64,7 +66,7 @@ config:
64
66
  first_prompt: "> "
65
67
  change_prompt: '(waiter-prompt-string "{key}>")'
66
68
  prompt: "{key}> "
67
- continuation_prompt: ""
69
+ # continuation_prompt: ""
68
70
  commands:
69
71
  - command: (* 6 7)
70
72
  - command: |
@@ -75,7 +77,7 @@ commands:
75
77
  - command: (fac 10 1)
76
78
  ```
77
79
 
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:
80
+ 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
81
 
80
82
  ```bash
81
83
  repl-session < test/scheme.yml | jq '.commands.[].output'
@@ -83,7 +85,7 @@ repl-session < test/scheme.yml | jq '.commands.[].output'
83
85
 
84
86
  ```
85
87
  "42"
86
- "(define (fac n init)\n (if (zero? n)\n init\n (fac (- n 1) (* init n)))))"
88
+ null
87
89
  "3628800"
88
90
  ```
89
91
 
@@ -126,6 +128,8 @@ repl-session < test/lua.yml | jq '.commands.[].output'
126
128
  ```
127
129
  "42"
128
130
  "Hello, World!"
131
+ null
132
+ "3628800"
129
133
  ```
130
134
 
131
135
  ### Python
@@ -142,6 +146,7 @@ config:
142
146
  continuation_prompt: "{key}\\+\\+\\+ "
143
147
  environment:
144
148
  NO_COLOR: "1"
149
+ PYTHON_BASIC_REPL: "1"
145
150
  commands:
146
151
  - command: print("Hello, World!")
147
152
  - command: 6 * 7
@@ -153,14 +158,26 @@ commands:
153
158
  - command: fac(10)
154
159
  ```
155
160
 
161
+ ```bash
162
+ repl-session < test/python.yml | jq '.commands.[].output'
163
+ ```
164
+
165
+ ```
166
+ "Hello, World!"
167
+ "42"
168
+ null
169
+ "3628800"
170
+ ```
171
+
156
172
  ## Input/Output structure
157
173
 
158
- The user can configure how the REPL is called and interpreted.
174
+ The user can configure how the REPL is called and interpreted. The `backend` field picks between this `pexpect` based REPL backend (`backend: repl`, the default) and the Jupyter backend (`backend: jupyter`).
159
175
 
160
176
  ```python
161
177
  #| id: input-data
162
- class ReplConfig(msgspec.Struct):
163
- """Configuration
178
+ class ReplConfig(msgspec.Struct, tag_field="backend", tag="repl"):
179
+ """Configuration for the `pexpect` backend; selected with `backend: repl`,
180
+ which is also the default when `backend` is absent.
164
181
 
165
182
  Attributes:
166
183
  command (str): Command to start the REPL
@@ -183,42 +200,66 @@ class ReplConfig(msgspec.Struct):
183
200
  strip_ansi: bool = False
184
201
  environment: dict[str, str] = msgspec.field(default_factory=dict)
185
202
  timeout: float = 5.0
186
-
187
-
188
203
  ```
189
204
 
190
205
  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.
191
206
 
207
+ The `output` field only holds the representation matching `output_type`. The `outputs` field records everything a command produced, in order. Each item has a `kind` (`result`, `display`, `stdout`, `stderr` or `error`) and a MIME bundle `data` with one or more representations of the same output. A Jupyter kernel may return `text/plain`, `text/html` and `image/png` together, for example. The REPL backend only sees terminal text, so it always gives a single `result`.
208
+
192
209
  ```python
193
210
  #| id: input-data
211
+ OutputKind = Literal["result", "display", "stdout", "stderr", "error"]
212
+
213
+
214
+ class ReplOutput(msgspec.Struct, omit_defaults=True):
215
+ """A single piece of output produced by a command.
216
+
217
+ Attributes:
218
+ kind (str): One of `result` (the value of the command), `display`
219
+ (rich output produced along the way, e.g. a plot), `stdout`,
220
+ `stderr` or `error` (a traceback). The `repl` backend cannot tell
221
+ these apart and reports everything as a single `result`.
222
+ data (dict[str, str]): MIME bundle: the same output in one or more
223
+ representations, keyed by MIME type. Binary data is base64 encoded;
224
+ JSON data is serialized to a string.
225
+ metadata (dict[str, Any]): Per MIME type metadata, e.g. image sizes.
226
+ """
227
+
228
+ kind: OutputKind
229
+ data: dict[str, str]
230
+ metadata: dict[str, Any] = msgspec.field(default_factory=dict)
231
+
232
+
194
233
  class ReplCommand(msgspec.Struct):
195
234
  """A command to be sent to the REPL.
196
235
 
197
236
  Attributes:
198
237
  command (str): the command.
199
238
  output_type (str): MIME type of expected output.
200
- output (str | None): evaluated output.
239
+ output (str | None): evaluated output for `output_type`.
201
240
  expected (str | None): expected output.
241
+ outputs (list[ReplOutput] | None): everything the command produced, in
242
+ order, with all available MIME types.
202
243
  """
203
244
 
204
245
  command: str
205
246
  output_type: str = "text/plain"
206
247
  output: str | None = None
207
248
  expected: str | None = None
249
+ outputs: list[ReplOutput] | None = None
208
250
 
209
251
 
210
252
  class ReplSession(msgspec.Struct):
211
253
  """A REPL session.
212
254
 
213
255
  Attributes:
214
- config (ReplConfig): Config for setting up a REPL session.
256
+ config (ReplConfig | JupyterConfig): Config for setting up a REPL
257
+ session, the `backend` field selects which.
215
258
  commands (list[ReplCommand]): List of commands in the session.
216
259
  """
217
260
 
218
- config: ReplConfig
261
+ config: ReplConfig | JupyterConfig
219
262
  commands: list[ReplCommand]
220
-
221
-
222
263
  ```
223
264
 
224
265
  ## License and contribution
@@ -15,7 +15,7 @@ This is only really useful if you're hacking together a literate programming env
15
15
 
16
16
  The REPL of your choice is started and interacted with through the `pexpect` library. All I/O is dealt with through `msgspec`.
17
17
 
18
- The preferred way to solve this is by using `jupyter-client` in combination with an existing Jupyter kernel. However, not all languages have a Jupyter kernel available, and developing one takes a bit more than configuring `pexpect` for an existing REPL. I may still include Jupyter support in this application at a later stage.
18
+ The preferred way to solve this is by using `jupyter-client` in combination with an existing Jupyter kernel. However, not all languages have a Jupyter kernel available, and developing one takes a bit more than configuring `pexpect` for an existing REPL. If a kernel is available for your language, you can use it through the optional Jupyter backend: `pip install repl-session[jupyter]`, then see [the Jupyter backend docs](https://entangled.github.io/repl-session/jupyter).
19
19
 
20
20
  ## Install
21
21
 
@@ -46,7 +46,7 @@ config:
46
46
  first_prompt: "> "
47
47
  change_prompt: '(waiter-prompt-string "{key}>")'
48
48
  prompt: "{key}> "
49
- continuation_prompt: ""
49
+ # continuation_prompt: ""
50
50
  commands:
51
51
  - command: (* 6 7)
52
52
  - command: |
@@ -57,7 +57,7 @@ commands:
57
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
 
@@ -108,6 +108,8 @@ repl-session < test/lua.yml | jq '.commands.[].output'
108
108
  ```
109
109
  "42"
110
110
  "Hello, World!"
111
+ null
112
+ "3628800"
111
113
  ```
112
114
 
113
115
  ### Python
@@ -124,6 +126,7 @@ config:
124
126
  continuation_prompt: "{key}\\+\\+\\+ "
125
127
  environment:
126
128
  NO_COLOR: "1"
129
+ PYTHON_BASIC_REPL: "1"
127
130
  commands:
128
131
  - command: print("Hello, World!")
129
132
  - command: 6 * 7
@@ -135,14 +138,26 @@ commands:
135
138
  - command: fac(10)
136
139
  ```
137
140
 
141
+ ```bash
142
+ repl-session < test/python.yml | jq '.commands.[].output'
143
+ ```
144
+
145
+ ```
146
+ "Hello, World!"
147
+ "42"
148
+ null
149
+ "3628800"
150
+ ```
151
+
138
152
  ## Input/Output structure
139
153
 
140
- The user can configure how the REPL is called and interpreted.
154
+ The user can configure how the REPL is called and interpreted. The `backend` field picks between this `pexpect` based REPL backend (`backend: repl`, the default) and the Jupyter backend (`backend: jupyter`).
141
155
 
142
156
  ```python
143
157
  #| id: input-data
144
- class ReplConfig(msgspec.Struct):
145
- """Configuration
158
+ class ReplConfig(msgspec.Struct, tag_field="backend", tag="repl"):
159
+ """Configuration for the `pexpect` backend; selected with `backend: repl`,
160
+ which is also the default when `backend` is absent.
146
161
 
147
162
  Attributes:
148
163
  command (str): Command to start the REPL
@@ -165,42 +180,66 @@ class ReplConfig(msgspec.Struct):
165
180
  strip_ansi: bool = False
166
181
  environment: dict[str, str] = msgspec.field(default_factory=dict)
167
182
  timeout: float = 5.0
168
-
169
-
170
183
  ```
171
184
 
172
185
  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.
173
186
 
187
+ The `output` field only holds the representation matching `output_type`. The `outputs` field records everything a command produced, in order. Each item has a `kind` (`result`, `display`, `stdout`, `stderr` or `error`) and a MIME bundle `data` with one or more representations of the same output. A Jupyter kernel may return `text/plain`, `text/html` and `image/png` together, for example. The REPL backend only sees terminal text, so it always gives a single `result`.
188
+
174
189
  ```python
175
190
  #| id: input-data
191
+ OutputKind = Literal["result", "display", "stdout", "stderr", "error"]
192
+
193
+
194
+ class ReplOutput(msgspec.Struct, omit_defaults=True):
195
+ """A single piece of output produced by a command.
196
+
197
+ Attributes:
198
+ kind (str): One of `result` (the value of the command), `display`
199
+ (rich output produced along the way, e.g. a plot), `stdout`,
200
+ `stderr` or `error` (a traceback). The `repl` backend cannot tell
201
+ these apart and reports everything as a single `result`.
202
+ data (dict[str, str]): MIME bundle: the same output in one or more
203
+ representations, keyed by MIME type. Binary data is base64 encoded;
204
+ JSON data is serialized to a string.
205
+ metadata (dict[str, Any]): Per MIME type metadata, e.g. image sizes.
206
+ """
207
+
208
+ kind: OutputKind
209
+ data: dict[str, str]
210
+ metadata: dict[str, Any] = msgspec.field(default_factory=dict)
211
+
212
+
176
213
  class ReplCommand(msgspec.Struct):
177
214
  """A command to be sent to the REPL.
178
215
 
179
216
  Attributes:
180
217
  command (str): the command.
181
218
  output_type (str): MIME type of expected output.
182
- output (str | None): evaluated output.
219
+ output (str | None): evaluated output for `output_type`.
183
220
  expected (str | None): expected output.
221
+ outputs (list[ReplOutput] | None): everything the command produced, in
222
+ order, with all available MIME types.
184
223
  """
185
224
 
186
225
  command: str
187
226
  output_type: str = "text/plain"
188
227
  output: str | None = None
189
228
  expected: str | None = None
229
+ outputs: list[ReplOutput] | None = None
190
230
 
191
231
 
192
232
  class ReplSession(msgspec.Struct):
193
233
  """A REPL session.
194
234
 
195
235
  Attributes:
196
- config (ReplConfig): Config for setting up a REPL session.
236
+ config (ReplConfig | JupyterConfig): Config for setting up a REPL
237
+ session, the `backend` field selects which.
197
238
  commands (list[ReplCommand]): List of commands in the session.
198
239
  """
199
240
 
200
- config: ReplConfig
241
+ config: ReplConfig | JupyterConfig
201
242
  commands: list[ReplCommand]
202
-
203
-
204
243
  ```
205
244
 
206
245
  ## License and contribution
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repl-session"
3
- version = "0.2.0"
3
+ version = "0.3.0"
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
  ]
@@ -22,15 +22,27 @@ dependencies = [
22
22
  dev = [
23
23
  "basedpyright>=1.31.4",
24
24
  "entangled-cli>=2.1.13",
25
+ "ipykernel>=7.4.0",
25
26
  "mkdocs-macros-plugin>=1.3.9",
26
27
  "mkdocs-material>=9.6.20",
28
+ "pytest>=9.2.0",
27
29
  "python-lsp-server>=1.13.1",
28
30
  "ruff>=0.13.2",
29
31
  "ty>=0.0.1a21",
32
+ "types-pexpect>=4.9.0.20250916",
30
33
  ]
31
34
 
32
35
  [project.scripts]
33
36
  repl-session = "repl_session:main"
34
37
 
38
+ [project.optional-dependencies]
39
+ jupyter = [
40
+ "jupyter-client>=8.6",
41
+ ]
42
+
35
43
  [tool.uv]
36
44
  package = true
45
+
46
+ [tool.ruff.lint]
47
+ # Imports are grouped by theme (stdlib, dependencies, internal), not sorted.
48
+ ignore = ["I001"]
@@ -0,0 +1,115 @@
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, Any
10
+
11
+ # import re
12
+ from contextlib import AbstractContextManager
13
+ import sys
14
+ import logging
15
+
16
+ import msgspec
17
+ import click
18
+ import importlib.metadata
19
+
20
+ from .schema import (
21
+ Evaluator,
22
+ JupyterConfig,
23
+ OutputKind,
24
+ ReplCommand,
25
+ ReplConfig,
26
+ ReplOutput,
27
+ ReplSession,
28
+ )
29
+ from .repl import repl
30
+
31
+ __all__ = [
32
+ "Evaluator",
33
+ "JupyterConfig",
34
+ "OutputKind",
35
+ "ReplCommand",
36
+ "ReplConfig",
37
+ "ReplOutput",
38
+ "ReplSession",
39
+ "evaluator",
40
+ "read_session",
41
+ "repl",
42
+ "run_session",
43
+ "select_output",
44
+ "write_session",
45
+ ]
46
+
47
+ __version__ = importlib.metadata.version("repl-session")
48
+ # ~/~ end
49
+ # ~/~ begin <<docs/index.md#run-session>>[init]
50
+ def evaluator(config: ReplConfig | JupyterConfig) -> AbstractContextManager[Evaluator]:
51
+ """Start the backend selected by `config`. The Jupyter backend is only
52
+ imported when needed, so its dependencies remain optional."""
53
+ if isinstance(config, JupyterConfig):
54
+ from .jupyter import kernel
55
+
56
+ return kernel(config)
57
+ return repl(config)
58
+
59
+
60
+ def select_output(outputs: list[ReplOutput], output_type: str) -> str | None:
61
+ """Pick the representation of type `output_type` from the outputs. Text
62
+ is concatenated in order; for other types the last one wins."""
63
+ values = [out.data[output_type] for out in outputs if output_type in out.data]
64
+ if not values:
65
+ return None
66
+ if output_type.startswith("text/"):
67
+ return "\n".join(v.strip("\n") for v in values).strip() or None
68
+ return values[-1]
69
+
70
+
71
+ def run_session(session: ReplSession):
72
+ with evaluator(session.config) as run:
73
+ for cmd in session.commands:
74
+ expected = cmd.expected or cmd.output
75
+ cmd.outputs = run(cmd)
76
+ cmd.output = select_output(cmd.outputs, cmd.output_type)
77
+ cmd.expected = expected
78
+
79
+ return session
80
+ # ~/~ end
81
+ # ~/~ begin <<docs/index.md#io>>[init]
82
+ def read_session(port: IO[str] = sys.stdin) -> ReplSession:
83
+ data: Any = msgspec.yaml.decode(port.read())
84
+ # Sessions written before there were multiple backends have no `backend` tag.
85
+ if isinstance(data, dict) and isinstance(config := data.get("config"), dict):
86
+ _ = config.setdefault("backend", "repl")
87
+ return msgspec.convert(data, type=ReplSession)
88
+
89
+
90
+ def write_session(session: ReplSession, port: IO[str] = sys.stdout):
91
+ data = msgspec.json.encode(session)
92
+ _ = port.write(data.decode())
93
+ # ~/~ end
94
+
95
+ @click.command()
96
+ @click.version_option(
97
+ __version__, "-v", "--version",
98
+ prog_name="repl-session", message="%(prog)s %(version)s",
99
+ help="show version and exit")
100
+ @click.option("-l", "--log-enable", is_flag=True, help="show debugging output")
101
+ def repl_session(log_enable: bool = False):
102
+ """
103
+ repl-session runs a REPL session, reading JSON from standard input and
104
+ writing to standard output. Both the input and output follow the same
105
+ schema.
106
+ """
107
+ if log_enable:
108
+ logging.basicConfig(level=logging.DEBUG)
109
+
110
+ write_session(run_session(read_session()))
111
+
112
+
113
+ def main():
114
+ repl_session()
115
+ # ~/~ end
@@ -0,0 +1,105 @@
1
+ # ~/~ begin <<docs/jupyter.md#src/repl_session/jupyter.py>>[init]
2
+ """
3
+ Jupyter backend for `repl-session`. Requires the `jupyter` extra.
4
+ """
5
+
6
+ from collections.abc import Generator
7
+ from contextlib import contextmanager
8
+ from typing import Any
9
+ import logging
10
+ import os
11
+
12
+ import msgspec
13
+
14
+ try:
15
+ from jupyter_client.manager import start_new_kernel
16
+ except ImportError as e:
17
+ raise ImportError(
18
+ "The Jupyter backend needs the `jupyter` extra: "
19
+ "`pip install repl-session[jupyter]`"
20
+ ) from e
21
+
22
+ from .schema import ANSI_ESCAPE, Evaluator, JupyterConfig, ReplCommand, ReplOutput
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # ~/~ begin <<docs/jupyter.md#jupyter-collect>>[init]
27
+ def mime_bundle(data: dict[str, Any]) -> dict[str, str]:
28
+ return {
29
+ mime: value if isinstance(value, str) else msgspec.json.encode(value).decode()
30
+ for mime, value in data.items()
31
+ }
32
+
33
+
34
+ class OutputCollector:
35
+ """Collects IOPub messages into a list of `ReplOutput`."""
36
+
37
+ def __init__(self):
38
+ self.outputs: list[ReplOutput] = []
39
+ self.clear_pending: bool = False
40
+
41
+ def append(self, output: ReplOutput):
42
+ if self.clear_pending:
43
+ self.outputs.clear()
44
+ self.clear_pending = False
45
+
46
+ last = self.outputs[-1] if self.outputs else None
47
+ if (
48
+ last is not None
49
+ and output.kind in ("stdout", "stderr")
50
+ and last.kind == output.kind
51
+ ):
52
+ last.data["text/plain"] += output.data["text/plain"]
53
+ else:
54
+ self.outputs.append(output)
55
+
56
+ def __call__(self, msg: dict[str, Any]):
57
+ msg_type = msg["header"]["msg_type"]
58
+ content = msg["content"]
59
+ match msg_type:
60
+ case "stream":
61
+ self.append(ReplOutput(content["name"], {"text/plain": content["text"]}))
62
+ case "execute_result" | "display_data":
63
+ kind = "result" if msg_type == "execute_result" else "display"
64
+ self.append(
65
+ ReplOutput(kind, mime_bundle(content["data"]), content.get("metadata", {}))
66
+ )
67
+ case "error":
68
+ traceback = ANSI_ESCAPE.sub("", "\n".join(content["traceback"]))
69
+ self.append(ReplOutput("error", {"text/plain": traceback}))
70
+ case "clear_output":
71
+ if content.get("wait"):
72
+ self.clear_pending = True
73
+ else:
74
+ self.outputs.clear()
75
+ case _:
76
+ logger.debug("ignoring %s message", msg_type)
77
+ # ~/~ end
78
+ # ~/~ begin <<docs/jupyter.md#jupyter-kernel>>[init]
79
+ @contextmanager
80
+ def kernel(config: JupyterConfig) -> Generator[Evaluator]:
81
+ km, kc = start_new_kernel(
82
+ startup_timeout=config.startup_timeout,
83
+ kernel_name=config.kernel,
84
+ cwd=config.cwd,
85
+ env=dict(os.environ) | config.environment,
86
+ )
87
+
88
+ def send(cmd: ReplCommand) -> list[ReplOutput]:
89
+ logger.debug("executing: %s", cmd.command)
90
+ collect = OutputCollector()
91
+ _ = kc.execute_interactive(
92
+ cmd.command,
93
+ timeout=config.timeout,
94
+ output_hook=collect,
95
+ allow_stdin=False,
96
+ )
97
+ return collect.outputs
98
+
99
+ try:
100
+ yield send
101
+ finally:
102
+ kc.stop_channels()
103
+ km.shutdown_kernel(now=True)
104
+ # ~/~ end
105
+ # ~/~ end
@@ -0,0 +1,121 @@
1
+ # ~/~ begin <<docs/index.md#src/repl_session/repl.py>>[init]
2
+ """
3
+ REPL backend for `repl-session`: drives any interactive REPL through `pexpect`.
4
+ """
5
+
6
+ from collections.abc import Generator
7
+ from contextlib import contextmanager
8
+ import uuid
9
+ import re
10
+ import logging
11
+ import os
12
+
13
+ import pexpect
14
+
15
+ from .schema import ANSI_ESCAPE, Evaluator, ReplCommand, ReplConfig, ReplOutput
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # ~/~ begin <<docs/index.md#repl-contextmanager>>[init]
20
+ def spawn(config: ReplConfig):
21
+ env = dict(os.environ) | config.environment
22
+ child: pexpect.spawn[str] = pexpect.spawn(
23
+ config.command,
24
+ timeout=config.timeout,
25
+ echo=False,
26
+ encoding="utf-8",
27
+ env=env,
28
+ )
29
+ return child
30
+
31
+
32
+ def clean_output(config: ReplConfig, output: str) -> str | None:
33
+ """Remove ANSI escapes (if requested), normalize the CRLF line endings
34
+ that the pseudo-terminal produces, and trim surrounding whitespace.
35
+ Returns `None` if nothing is left."""
36
+ if config.strip_ansi:
37
+ output = ANSI_ESCAPE.sub("", output)
38
+ output = output.replace("\r\n", "\n").replace("\r", "").strip()
39
+ return output or None
40
+
41
+
42
+ @contextmanager
43
+ def repl(config: ReplConfig) -> Generator[Evaluator]:
44
+ key = str(uuid.uuid4())
45
+ change_prompt_cmd = config.change_prompt.format(key=key)
46
+ prompt = config.prompt.format(key=key)
47
+ continuation_prompt = (
48
+ config.continuation_prompt.format(key=key)
49
+ if config.continuation_prompt is not None
50
+ else None
51
+ )
52
+
53
+ child: pexpect.spawn[str]
54
+ with spawn(config) as child:
55
+ _ = child.expect(config.first_prompt)
56
+ _ = child.sendline(change_prompt_cmd)
57
+ # if config.strip_command:
58
+ # child.expect(key)
59
+ _ = child.expect(prompt)
60
+
61
+ if continuation_prompt is not None:
62
+ def send_text(msg: str) -> str | None:
63
+ lines = msg.splitlines()
64
+ answer: list[str] = []
65
+
66
+ if not lines:
67
+ return None
68
+
69
+ still_waiting: bool = True
70
+ for line in lines:
71
+ logger.debug("sending: '%s'", line)
72
+ _ = child.sendline(line)
73
+ logger.debug("waiting for prompt or continuation")
74
+ _ = child.expect(
75
+ f"(?P<norm>{prompt})|(?P<cont>{continuation_prompt})"
76
+ )
77
+ if not isinstance(child.match, re.Match):
78
+ continue
79
+ if child.match.group("cont") is not None:
80
+ logger.debug("continuation: %s -- %s", child.before, child.after)
81
+ still_waiting = True
82
+ else:
83
+ logger.debug("done: %s", child.before)
84
+ if child.before is not None:
85
+ answer.append(child.before)
86
+ still_waiting = False
87
+
88
+ if still_waiting:
89
+ logger.debug("waiting for last prompt")
90
+ _ = child.sendline("")
91
+ _ = child.expect(prompt)
92
+ logger.debug("got: %s", child.before)
93
+ if child.before:
94
+ answer.append(child.before)
95
+
96
+ if not answer:
97
+ return None
98
+
99
+ return clean_output(config, answer[-1])
100
+
101
+ else:
102
+ def send_text(msg: str) -> str | None:
103
+ logger.debug("sending: %s", msg)
104
+
105
+ _ = child.sendline(msg)
106
+ _ = child.expect(prompt)
107
+
108
+ if not child.before:
109
+ return None
110
+
111
+ return clean_output(config, child.before)
112
+
113
+ def send(cmd: ReplCommand) -> list[ReplOutput]:
114
+ text = send_text(cmd.command)
115
+ if text is None:
116
+ return []
117
+ return [ReplOutput("result", {cmd.output_type: text})]
118
+
119
+ yield send
120
+ # ~/~ end
121
+ # ~/~ end
@@ -0,0 +1,117 @@
1
+ # ~/~ begin <<docs/index.md#src/repl_session/schema.py>>[init]
2
+ """
3
+ Data types for `repl-session`: the session format and the interface shared
4
+ by the backends.
5
+ """
6
+
7
+ from typing import Any, Literal
8
+ from collections.abc import Callable
9
+ import re
10
+
11
+ import msgspec
12
+
13
+ # ~/~ begin <<docs/jupyter.md#jupyter-config>>[init]
14
+ class JupyterConfig(msgspec.Struct, tag_field="backend", tag="jupyter"):
15
+ """Configuration for the Jupyter backend; selected with `backend: jupyter`.
16
+
17
+ Attributes:
18
+ kernel (str): Name of the Jupyter kernel, see `jupyter kernelspec list`.
19
+ timeout (float): Command timeout in seconds.
20
+ startup_timeout (float): Timeout for starting the kernel in seconds.
21
+ cwd (str | None): Working directory of the kernel.
22
+ environment (dict[str, str]): Extra environment variables for the kernel.
23
+ """
24
+
25
+ kernel: str
26
+ timeout: float = 30.0
27
+ startup_timeout: float = 60.0
28
+ cwd: str | None = None
29
+ environment: dict[str, str] = msgspec.field(default_factory=dict)
30
+ # ~/~ end
31
+ # ~/~ begin <<README.md#input-data>>[init]
32
+ class ReplConfig(msgspec.Struct, tag_field="backend", tag="repl"):
33
+ """Configuration for the `pexpect` backend; selected with `backend: repl`,
34
+ which is also the default when `backend` is absent.
35
+
36
+ Attributes:
37
+ command (str): Command to start the REPL
38
+ first_prompt (str): Regex to match the first prompt
39
+ change_prompt (str): Command to change prompt; should contain '{key}' as an
40
+ argument.
41
+ next_prompt (str): Regex to match the changed prompts; should contain '{key}'
42
+ as an argument.
43
+ append_newline (bool): Whether to append a newline to given commands.
44
+ strip_command (bool): Whether to strip the original command from the gotten
45
+ output; useful if the REPL echoes your input before answering.
46
+ timeout (float): Command timeout for this session in seconds.
47
+ """
48
+
49
+ command: str
50
+ first_prompt: str
51
+ change_prompt: str
52
+ prompt: str
53
+ continuation_prompt: str | None = None
54
+ strip_ansi: bool = False
55
+ environment: dict[str, str] = msgspec.field(default_factory=dict)
56
+ timeout: float = 5.0
57
+ # ~/~ end
58
+ # ~/~ begin <<README.md#input-data>>[1]
59
+ OutputKind = Literal["result", "display", "stdout", "stderr", "error"]
60
+
61
+
62
+ class ReplOutput(msgspec.Struct, omit_defaults=True):
63
+ """A single piece of output produced by a command.
64
+
65
+ Attributes:
66
+ kind (str): One of `result` (the value of the command), `display`
67
+ (rich output produced along the way, e.g. a plot), `stdout`,
68
+ `stderr` or `error` (a traceback). The `repl` backend cannot tell
69
+ these apart and reports everything as a single `result`.
70
+ data (dict[str, str]): MIME bundle: the same output in one or more
71
+ representations, keyed by MIME type. Binary data is base64 encoded;
72
+ JSON data is serialized to a string.
73
+ metadata (dict[str, Any]): Per MIME type metadata, e.g. image sizes.
74
+ """
75
+
76
+ kind: OutputKind
77
+ data: dict[str, str]
78
+ metadata: dict[str, Any] = msgspec.field(default_factory=dict)
79
+
80
+
81
+ class ReplCommand(msgspec.Struct):
82
+ """A command to be sent to the REPL.
83
+
84
+ Attributes:
85
+ command (str): the command.
86
+ output_type (str): MIME type of expected output.
87
+ output (str | None): evaluated output for `output_type`.
88
+ expected (str | None): expected output.
89
+ outputs (list[ReplOutput] | None): everything the command produced, in
90
+ order, with all available MIME types.
91
+ """
92
+
93
+ command: str
94
+ output_type: str = "text/plain"
95
+ output: str | None = None
96
+ expected: str | None = None
97
+ outputs: list[ReplOutput] | None = None
98
+
99
+
100
+ class ReplSession(msgspec.Struct):
101
+ """A REPL session.
102
+
103
+ Attributes:
104
+ config (ReplConfig | JupyterConfig): Config for setting up a REPL
105
+ session, the `backend` field selects which.
106
+ commands (list[ReplCommand]): List of commands in the session.
107
+ """
108
+
109
+ config: ReplConfig | JupyterConfig
110
+ commands: list[ReplCommand]
111
+ # ~/~ end
112
+ # ~/~ begin <<docs/index.md#schema-shared>>[init]
113
+ Evaluator = Callable[[ReplCommand], list[ReplOutput]]
114
+
115
+ ANSI_ESCAPE = re.compile(r"(\u001b|\x1B)(\[[0-?]*[ -\/]*[@-~]|[\>\=])")
116
+ # ~/~ end
117
+ # ~/~ end
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repl-session
3
- Version: 0.2.0
3
+ Version: 0.3.0
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,9 +11,11 @@ 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
+ Provides-Extra: jupyter
18
+ Requires-Dist: jupyter-client>=8.6; extra == "jupyter"
17
19
  Dynamic: license-file
18
20
 
19
21
  # REPL Session
@@ -33,7 +35,7 @@ This is only really useful if you're hacking together a literate programming env
33
35
 
34
36
  The REPL of your choice is started and interacted with through the `pexpect` library. All I/O is dealt with through `msgspec`.
35
37
 
36
- The preferred way to solve this is by using `jupyter-client` in combination with an existing Jupyter kernel. However, not all languages have a Jupyter kernel available, and developing one takes a bit more than configuring `pexpect` for an existing REPL. I may still include Jupyter support in this application at a later stage.
38
+ The preferred way to solve this is by using `jupyter-client` in combination with an existing Jupyter kernel. However, not all languages have a Jupyter kernel available, and developing one takes a bit more than configuring `pexpect` for an existing REPL. If a kernel is available for your language, you can use it through the optional Jupyter backend: `pip install repl-session[jupyter]`, then see [the Jupyter backend docs](https://entangled.github.io/repl-session/jupyter).
37
39
 
38
40
  ## Install
39
41
 
@@ -64,7 +66,7 @@ config:
64
66
  first_prompt: "> "
65
67
  change_prompt: '(waiter-prompt-string "{key}>")'
66
68
  prompt: "{key}> "
67
- continuation_prompt: ""
69
+ # continuation_prompt: ""
68
70
  commands:
69
71
  - command: (* 6 7)
70
72
  - command: |
@@ -75,7 +77,7 @@ commands:
75
77
  - command: (fac 10 1)
76
78
  ```
77
79
 
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:
80
+ 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
81
 
80
82
  ```bash
81
83
  repl-session < test/scheme.yml | jq '.commands.[].output'
@@ -83,7 +85,7 @@ repl-session < test/scheme.yml | jq '.commands.[].output'
83
85
 
84
86
  ```
85
87
  "42"
86
- "(define (fac n init)\n (if (zero? n)\n init\n (fac (- n 1) (* init n)))))"
88
+ null
87
89
  "3628800"
88
90
  ```
89
91
 
@@ -126,6 +128,8 @@ repl-session < test/lua.yml | jq '.commands.[].output'
126
128
  ```
127
129
  "42"
128
130
  "Hello, World!"
131
+ null
132
+ "3628800"
129
133
  ```
130
134
 
131
135
  ### Python
@@ -142,6 +146,7 @@ config:
142
146
  continuation_prompt: "{key}\\+\\+\\+ "
143
147
  environment:
144
148
  NO_COLOR: "1"
149
+ PYTHON_BASIC_REPL: "1"
145
150
  commands:
146
151
  - command: print("Hello, World!")
147
152
  - command: 6 * 7
@@ -153,14 +158,26 @@ commands:
153
158
  - command: fac(10)
154
159
  ```
155
160
 
161
+ ```bash
162
+ repl-session < test/python.yml | jq '.commands.[].output'
163
+ ```
164
+
165
+ ```
166
+ "Hello, World!"
167
+ "42"
168
+ null
169
+ "3628800"
170
+ ```
171
+
156
172
  ## Input/Output structure
157
173
 
158
- The user can configure how the REPL is called and interpreted.
174
+ The user can configure how the REPL is called and interpreted. The `backend` field picks between this `pexpect` based REPL backend (`backend: repl`, the default) and the Jupyter backend (`backend: jupyter`).
159
175
 
160
176
  ```python
161
177
  #| id: input-data
162
- class ReplConfig(msgspec.Struct):
163
- """Configuration
178
+ class ReplConfig(msgspec.Struct, tag_field="backend", tag="repl"):
179
+ """Configuration for the `pexpect` backend; selected with `backend: repl`,
180
+ which is also the default when `backend` is absent.
164
181
 
165
182
  Attributes:
166
183
  command (str): Command to start the REPL
@@ -183,42 +200,66 @@ class ReplConfig(msgspec.Struct):
183
200
  strip_ansi: bool = False
184
201
  environment: dict[str, str] = msgspec.field(default_factory=dict)
185
202
  timeout: float = 5.0
186
-
187
-
188
203
  ```
189
204
 
190
205
  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.
191
206
 
207
+ The `output` field only holds the representation matching `output_type`. The `outputs` field records everything a command produced, in order. Each item has a `kind` (`result`, `display`, `stdout`, `stderr` or `error`) and a MIME bundle `data` with one or more representations of the same output. A Jupyter kernel may return `text/plain`, `text/html` and `image/png` together, for example. The REPL backend only sees terminal text, so it always gives a single `result`.
208
+
192
209
  ```python
193
210
  #| id: input-data
211
+ OutputKind = Literal["result", "display", "stdout", "stderr", "error"]
212
+
213
+
214
+ class ReplOutput(msgspec.Struct, omit_defaults=True):
215
+ """A single piece of output produced by a command.
216
+
217
+ Attributes:
218
+ kind (str): One of `result` (the value of the command), `display`
219
+ (rich output produced along the way, e.g. a plot), `stdout`,
220
+ `stderr` or `error` (a traceback). The `repl` backend cannot tell
221
+ these apart and reports everything as a single `result`.
222
+ data (dict[str, str]): MIME bundle: the same output in one or more
223
+ representations, keyed by MIME type. Binary data is base64 encoded;
224
+ JSON data is serialized to a string.
225
+ metadata (dict[str, Any]): Per MIME type metadata, e.g. image sizes.
226
+ """
227
+
228
+ kind: OutputKind
229
+ data: dict[str, str]
230
+ metadata: dict[str, Any] = msgspec.field(default_factory=dict)
231
+
232
+
194
233
  class ReplCommand(msgspec.Struct):
195
234
  """A command to be sent to the REPL.
196
235
 
197
236
  Attributes:
198
237
  command (str): the command.
199
238
  output_type (str): MIME type of expected output.
200
- output (str | None): evaluated output.
239
+ output (str | None): evaluated output for `output_type`.
201
240
  expected (str | None): expected output.
241
+ outputs (list[ReplOutput] | None): everything the command produced, in
242
+ order, with all available MIME types.
202
243
  """
203
244
 
204
245
  command: str
205
246
  output_type: str = "text/plain"
206
247
  output: str | None = None
207
248
  expected: str | None = None
249
+ outputs: list[ReplOutput] | None = None
208
250
 
209
251
 
210
252
  class ReplSession(msgspec.Struct):
211
253
  """A REPL session.
212
254
 
213
255
  Attributes:
214
- config (ReplConfig): Config for setting up a REPL session.
256
+ config (ReplConfig | JupyterConfig): Config for setting up a REPL
257
+ session, the `backend` field selects which.
215
258
  commands (list[ReplCommand]): List of commands in the session.
216
259
  """
217
260
 
218
- config: ReplConfig
261
+ config: ReplConfig | JupyterConfig
219
262
  commands: list[ReplCommand]
220
-
221
-
222
263
  ```
223
264
 
224
265
  ## License and contribution
@@ -2,10 +2,14 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/repl_session/__init__.py
5
+ src/repl_session/jupyter.py
5
6
  src/repl_session/py.typed
7
+ src/repl_session/repl.py
8
+ src/repl_session/schema.py
6
9
  src/repl_session.egg-info/PKG-INFO
7
10
  src/repl_session.egg-info/SOURCES.txt
8
11
  src/repl_session.egg-info/dependency_links.txt
9
12
  src/repl_session.egg-info/entry_points.txt
10
13
  src/repl_session.egg-info/requires.txt
11
- src/repl_session.egg-info/top_level.txt
14
+ src/repl_session.egg-info/top_level.txt
15
+ test/test_examples.py
@@ -0,0 +1,6 @@
1
+ click>=8.1
2
+ msgspec>=0.19.0
3
+ pexpect>=4.9.0
4
+
5
+ [jupyter]
6
+ jupyter-client>=8.6
@@ -0,0 +1,65 @@
1
+ """Run the example sessions from the README and check their outputs."""
2
+
3
+ import importlib.util
4
+ import os
5
+ import shlex
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from repl_session import ReplConfig, ReplSession, read_session, 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
+ with open(TEST_DIR / f"{name}.yml") as f:
26
+ return read_session(f)
27
+
28
+
29
+ def require(available: bool, what: str):
30
+ if available:
31
+ return
32
+ # In CI everything must be available; locally we allow skipping.
33
+ if os.environ.get("CI"):
34
+ pytest.fail(f"{what} not found")
35
+ pytest.skip(f"{what} not found")
36
+
37
+
38
+ @pytest.mark.parametrize("name", EXPECTED.keys())
39
+ def test_example(name: str):
40
+ session = load_session(name)
41
+ assert isinstance(session.config, ReplConfig)
42
+ executable = shlex.split(session.config.command)[0]
43
+ require(shutil.which(executable) is not None, f"`{executable}`")
44
+
45
+ result = run_session(session)
46
+ assert [cmd.output for cmd in result.commands] == EXPECTED[name]
47
+
48
+
49
+ def test_jupyter():
50
+ require(
51
+ importlib.util.find_spec("jupyter_client") is not None
52
+ and importlib.util.find_spec("ipykernel") is not None,
53
+ "`jupyter_client` and `ipykernel`",
54
+ )
55
+ result = run_session(load_session("jupyter"))
56
+ hello, answer, define, fac, html, error = result.commands
57
+
58
+ assert [cmd.output for cmd in (hello, answer, define, fac, html)] == [
59
+ "Hello, World!", "42", None, "3628800", "<b>Hello</b>, World!"
60
+ ]
61
+ assert [[out.kind for out in cmd.outputs or []] for cmd in result.commands] == [
62
+ ["stdout"], ["result"], [], ["result"], ["result"], ["error"]
63
+ ]
64
+ assert html.outputs and set(html.outputs[0].data) == {"text/plain", "text/html"}
65
+ assert error.output and error.output.endswith("ZeroDivisionError: division by zero")
@@ -1,226 +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
-
8
- # ~/~ begin <<docs/index.md#imports>>[init]
9
- #| id: imports
10
- # from datetime import datetime, tzinfo
11
- from typing import IO, cast
12
- from collections.abc import Generator, Callable
13
-
14
- # import re
15
- from contextlib import contextmanager
16
- import uuid
17
- import sys
18
- import re
19
- import logging
20
-
21
- import pexpect
22
- import msgspec
23
- import argh
24
- import importlib.metadata
25
-
26
-
27
- __version__ = importlib.metadata.version("repl-session")
28
- # ~/~ end
29
-
30
-
31
- # ~/~ begin <<README.md#input-data>>[init]
32
- #| id: input-data
33
- class ReplConfig(msgspec.Struct):
34
- """Configuration
35
-
36
- Attributes:
37
- command (str): Command to start the REPL
38
- first_prompt (str): Regex to match the first prompt
39
- change_prompt (str): Command to change prompt; should contain '{key}' as an
40
- argument.
41
- next_prompt (str): Regex to match the changed prompts; should contain '{key}'
42
- as an argument.
43
- append_newline (bool): Whether to append a newline to given commands.
44
- strip_command (bool): Whether to strip the original command from the gotten
45
- output; useful if the REPL echoes your input before answering.
46
- timeout (float): Command timeout for this session in seconds.
47
- """
48
-
49
- command: str
50
- first_prompt: str
51
- change_prompt: str
52
- prompt: str
53
- continuation_prompt: str | None = None
54
- strip_ansi: bool = False
55
- environment: dict[str, str] = msgspec.field(default_factory=dict)
56
- timeout: float = 5.0
57
-
58
-
59
- # ~/~ end
60
- # ~/~ begin <<README.md#input-data>>[1]
61
- #| id: input-data
62
- class ReplCommand(msgspec.Struct):
63
- """A command to be sent to the REPL.
64
-
65
- Attributes:
66
- command (str): the command.
67
- output_type (str): MIME type of expected output.
68
- output (str | None): evaluated output.
69
- expected (str | None): expected output.
70
- """
71
-
72
- command: str
73
- output_type: str = "text/plain"
74
- output: str | None = None
75
- expected: str | None = None
76
-
77
-
78
- class ReplSession(msgspec.Struct):
79
- """A REPL session.
80
-
81
- Attributes:
82
- config (ReplConfig): Config for setting up a REPL session.
83
- commands (list[ReplCommand]): List of commands in the session.
84
- """
85
-
86
- config: ReplConfig
87
- commands: list[ReplCommand]
88
-
89
-
90
- # ~/~ end
91
-
92
-
93
- # ~/~ begin <<docs/index.md#repl-contextmanager>>[init]
94
- #| id: repl-contextmanager
95
- def spawn(config: ReplConfig):
96
- child: pexpect.spawn[str] = pexpect.spawn(
97
- config.command,
98
- timeout=config.timeout,
99
- echo=False,
100
- encoding="utf-8",
101
- env=config.environment,
102
- )
103
- return child
104
-
105
-
106
- @contextmanager
107
- def repl(config: ReplConfig) -> Generator[Callable[[str], str | None]]:
108
- key = str(uuid.uuid4())
109
- change_prompt_cmd = config.change_prompt.format(key=key)
110
- prompt = config.prompt.format(key=key)
111
- continuation_prompt = (
112
- config.continuation_prompt.format(key=key)
113
- if config.continuation_prompt is not None
114
- else None
115
- )
116
-
117
- child: pexpect.spawn[str]
118
- with spawn(config) as child:
119
- _ = child.expect(config.first_prompt)
120
- _ = child.sendline(change_prompt_cmd)
121
- # if config.strip_command:
122
- # child.expect(key)
123
- _ = child.expect(prompt)
124
-
125
- def send(msg: str) -> str | None:
126
- nonlocal prompt, continuation_prompt, change_prompt_cmd
127
- lines = msg.splitlines()
128
- answer: list[str] = []
129
-
130
- still_waiting: bool = True
131
- for line in lines:
132
- logging.debug("sending: %s", line)
133
- _ = child.sendline(line)
134
- if continuation_prompt is not None:
135
- logging.debug("waiting for prompt or continuation")
136
- _ = child.expect(
137
- f"(?P<cont>{continuation_prompt})|(?P<norm>{prompt})"
138
- )
139
- if not isinstance(child.match, re.Match):
140
- continue
141
- if child.match.group("cont") is not None:
142
- logging.debug("continuation")
143
- still_waiting = True
144
- else:
145
- logging.debug("done: %s", child.before)
146
- if child.before is not None:
147
- answer.append(child.before)
148
- still_waiting = False
149
- else:
150
- logging.debug("waiting for prompt")
151
- _ = child.expect(prompt)
152
- if child.before:
153
- answer.append(child.before)
154
- still_waiting = False
155
-
156
- if still_waiting:
157
- _ = child.sendline("")
158
- _ = child.expect(prompt)
159
- if child.before:
160
- answer.append(child.before)
161
-
162
- if not answer:
163
- return None
164
-
165
- if config.strip_ansi:
166
- ansi_escape = re.compile(r"(\u001b\[|\x1B\[)[0-?]*[ -\/]*[@-~]")
167
- return ansi_escape.sub("", answer[-1].strip())
168
-
169
- return answer[-1].strip()
170
-
171
- yield send
172
-
173
-
174
- # ~/~ end
175
- # ~/~ begin <<docs/index.md#run-session>>[init]
176
- #| id: run-session
177
- def run_session(session: ReplSession):
178
- with repl(session.config) as run:
179
- for cmd in session.commands:
180
- expected = cmd.expected or cmd.output
181
- output = run(cmd.command)
182
- cmd.output = output
183
- cmd.expected = expected
184
-
185
- return session
186
-
187
-
188
- # ~/~ end
189
- # ~/~ begin <<docs/index.md#io>>[init]
190
- #| id: io
191
- def read_session(port: IO[str] = sys.stdin) -> ReplSession:
192
- data: str = port.read()
193
- return msgspec.yaml.decode(data, type=ReplSession)
194
-
195
-
196
- def write_session(session: ReplSession, port: IO[str] = sys.stdout):
197
- data = msgspec.json.encode(session)
198
- _ = port.write(data.decode())
199
-
200
-
201
- # ~/~ end
202
-
203
-
204
- @argh.arg("-v", "--version", help="show version and exit")
205
- @argh.arg("-l", "--log-enable", help="show debugging output")
206
- def repl_session(version: bool = False, log_enable: bool = False):
207
- """
208
- repl-session runs a REPL session, reading JSON from standard input and
209
- writing to standard output. Both the input and output follow the same
210
- schema.
211
- """
212
- if version:
213
- print(f"repl-session {__version__}")
214
- sys.exit(0)
215
-
216
- if log_enable:
217
- logging.basicConfig(level=logging.DEBUG)
218
-
219
- write_session(run_session(read_session()))
220
-
221
-
222
- def main():
223
- argh.dispatch_command(repl_session)
224
-
225
-
226
- # ~/~ end
@@ -1,3 +0,0 @@
1
- argh>=0.30.5
2
- msgspec>=0.19.0
3
- pexpect>=4.9.0
File without changes
File without changes