repl-session 0.2.1__tar.gz → 0.3.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.
- {repl_session-0.2.1 → repl_session-0.3.1}/PKG-INFO +40 -8
- {repl_session-0.2.1 → repl_session-0.3.1}/README.md +36 -7
- {repl_session-0.2.1 → repl_session-0.3.1}/pyproject.toml +12 -1
- repl_session-0.3.1/src/repl_session/__init__.py +115 -0
- repl_session-0.3.1/src/repl_session/jupyter.py +105 -0
- repl_session-0.3.1/src/repl_session/repl.py +121 -0
- repl_session-0.3.1/src/repl_session/schema.py +117 -0
- {repl_session-0.2.1 → repl_session-0.3.1}/src/repl_session.egg-info/PKG-INFO +40 -8
- {repl_session-0.2.1 → repl_session-0.3.1}/src/repl_session.egg-info/SOURCES.txt +3 -0
- repl_session-0.3.1/src/repl_session.egg-info/requires.txt +7 -0
- repl_session-0.3.1/test/test_examples.py +65 -0
- repl_session-0.2.1/src/repl_session/__init__.py +0 -223
- repl_session-0.2.1/src/repl_session.egg-info/requires.txt +0 -3
- repl_session-0.2.1/test/test_examples.py +0 -40
- {repl_session-0.2.1 → repl_session-0.3.1}/LICENSE +0 -0
- {repl_session-0.2.1 → repl_session-0.3.1}/setup.cfg +0 -0
- {repl_session-0.2.1 → repl_session-0.3.1}/src/repl_session/py.typed +0 -0
- {repl_session-0.2.1 → repl_session-0.3.1}/src/repl_session.egg-info/dependency_links.txt +0 -0
- {repl_session-0.2.1 → repl_session-0.3.1}/src/repl_session.egg-info/entry_points.txt +0 -0
- {repl_session-0.2.1 → repl_session-0.3.1}/src/repl_session.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: repl-session
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -14,6 +14,9 @@ License-File: LICENSE
|
|
|
14
14
|
Requires-Dist: click>=8.1
|
|
15
15
|
Requires-Dist: msgspec>=0.19.0
|
|
16
16
|
Requires-Dist: pexpect>=4.9.0
|
|
17
|
+
Requires-Dist: pyyaml>=6.0
|
|
18
|
+
Provides-Extra: jupyter
|
|
19
|
+
Requires-Dist: jupyter-client>=8.6; extra == "jupyter"
|
|
17
20
|
Dynamic: license-file
|
|
18
21
|
|
|
19
22
|
# REPL Session
|
|
@@ -33,7 +36,7 @@ This is only really useful if you're hacking together a literate programming env
|
|
|
33
36
|
|
|
34
37
|
The REPL of your choice is started and interacted with through the `pexpect` library. All I/O is dealt with through `msgspec`.
|
|
35
38
|
|
|
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.
|
|
39
|
+
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
40
|
|
|
38
41
|
## Install
|
|
39
42
|
|
|
@@ -169,12 +172,13 @@ null
|
|
|
169
172
|
|
|
170
173
|
## Input/Output structure
|
|
171
174
|
|
|
172
|
-
The user can configure how the REPL is called and interpreted.
|
|
175
|
+
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`).
|
|
173
176
|
|
|
174
177
|
```python
|
|
175
178
|
#| id: input-data
|
|
176
|
-
class ReplConfig(msgspec.Struct):
|
|
177
|
-
"""Configuration
|
|
179
|
+
class ReplConfig(msgspec.Struct, tag_field="backend", tag="repl"):
|
|
180
|
+
"""Configuration for the `pexpect` backend; selected with `backend: repl`,
|
|
181
|
+
which is also the default when `backend` is absent.
|
|
178
182
|
|
|
179
183
|
Attributes:
|
|
180
184
|
command (str): Command to start the REPL
|
|
@@ -201,33 +205,61 @@ class ReplConfig(msgspec.Struct):
|
|
|
201
205
|
|
|
202
206
|
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.
|
|
203
207
|
|
|
208
|
+
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`.
|
|
209
|
+
|
|
204
210
|
```python
|
|
205
211
|
#| id: input-data
|
|
212
|
+
OutputKind = Literal["result", "display", "stdout", "stderr", "error"]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class ReplOutput(msgspec.Struct, omit_defaults=True):
|
|
216
|
+
"""A single piece of output produced by a command.
|
|
217
|
+
|
|
218
|
+
Attributes:
|
|
219
|
+
kind (str): One of `result` (the value of the command), `display`
|
|
220
|
+
(rich output produced along the way, e.g. a plot), `stdout`,
|
|
221
|
+
`stderr` or `error` (a traceback). The `repl` backend cannot tell
|
|
222
|
+
these apart and reports everything as a single `result`.
|
|
223
|
+
data (dict[str, str]): MIME bundle: the same output in one or more
|
|
224
|
+
representations, keyed by MIME type. Binary data is base64 encoded;
|
|
225
|
+
JSON data is serialized to a string.
|
|
226
|
+
metadata (dict[str, Any]): Per MIME type metadata, e.g. image sizes.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
kind: OutputKind
|
|
230
|
+
data: dict[str, str]
|
|
231
|
+
metadata: dict[str, Any] = msgspec.field(default_factory=dict)
|
|
232
|
+
|
|
233
|
+
|
|
206
234
|
class ReplCommand(msgspec.Struct):
|
|
207
235
|
"""A command to be sent to the REPL.
|
|
208
236
|
|
|
209
237
|
Attributes:
|
|
210
238
|
command (str): the command.
|
|
211
239
|
output_type (str): MIME type of expected output.
|
|
212
|
-
output (str | None): evaluated output
|
|
240
|
+
output (str | None): evaluated output for `output_type`.
|
|
213
241
|
expected (str | None): expected output.
|
|
242
|
+
outputs (list[ReplOutput] | None): everything the command produced, in
|
|
243
|
+
order, with all available MIME types.
|
|
214
244
|
"""
|
|
215
245
|
|
|
216
246
|
command: str
|
|
217
247
|
output_type: str = "text/plain"
|
|
218
248
|
output: str | None = None
|
|
219
249
|
expected: str | None = None
|
|
250
|
+
outputs: list[ReplOutput] | None = None
|
|
220
251
|
|
|
221
252
|
|
|
222
253
|
class ReplSession(msgspec.Struct):
|
|
223
254
|
"""A REPL session.
|
|
224
255
|
|
|
225
256
|
Attributes:
|
|
226
|
-
config (ReplConfig): Config for setting up a REPL
|
|
257
|
+
config (ReplConfig | JupyterConfig): Config for setting up a REPL
|
|
258
|
+
session, the `backend` field selects which.
|
|
227
259
|
commands (list[ReplCommand]): List of commands in the session.
|
|
228
260
|
"""
|
|
229
261
|
|
|
230
|
-
config: ReplConfig
|
|
262
|
+
config: ReplConfig | JupyterConfig
|
|
231
263
|
commands: list[ReplCommand]
|
|
232
264
|
```
|
|
233
265
|
|
|
@@ -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.
|
|
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
|
|
|
@@ -151,12 +151,13 @@ null
|
|
|
151
151
|
|
|
152
152
|
## Input/Output structure
|
|
153
153
|
|
|
154
|
-
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`).
|
|
155
155
|
|
|
156
156
|
```python
|
|
157
157
|
#| id: input-data
|
|
158
|
-
class ReplConfig(msgspec.Struct):
|
|
159
|
-
"""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.
|
|
160
161
|
|
|
161
162
|
Attributes:
|
|
162
163
|
command (str): Command to start the REPL
|
|
@@ -183,33 +184,61 @@ class ReplConfig(msgspec.Struct):
|
|
|
183
184
|
|
|
184
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.
|
|
185
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
|
+
|
|
186
189
|
```python
|
|
187
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
|
+
|
|
188
213
|
class ReplCommand(msgspec.Struct):
|
|
189
214
|
"""A command to be sent to the REPL.
|
|
190
215
|
|
|
191
216
|
Attributes:
|
|
192
217
|
command (str): the command.
|
|
193
218
|
output_type (str): MIME type of expected output.
|
|
194
|
-
output (str | None): evaluated output
|
|
219
|
+
output (str | None): evaluated output for `output_type`.
|
|
195
220
|
expected (str | None): expected output.
|
|
221
|
+
outputs (list[ReplOutput] | None): everything the command produced, in
|
|
222
|
+
order, with all available MIME types.
|
|
196
223
|
"""
|
|
197
224
|
|
|
198
225
|
command: str
|
|
199
226
|
output_type: str = "text/plain"
|
|
200
227
|
output: str | None = None
|
|
201
228
|
expected: str | None = None
|
|
229
|
+
outputs: list[ReplOutput] | None = None
|
|
202
230
|
|
|
203
231
|
|
|
204
232
|
class ReplSession(msgspec.Struct):
|
|
205
233
|
"""A REPL session.
|
|
206
234
|
|
|
207
235
|
Attributes:
|
|
208
|
-
config (ReplConfig): Config for setting up a REPL
|
|
236
|
+
config (ReplConfig | JupyterConfig): Config for setting up a REPL
|
|
237
|
+
session, the `backend` field selects which.
|
|
209
238
|
commands (list[ReplCommand]): List of commands in the session.
|
|
210
239
|
"""
|
|
211
240
|
|
|
212
|
-
config: ReplConfig
|
|
241
|
+
config: ReplConfig | JupyterConfig
|
|
213
242
|
commands: list[ReplCommand]
|
|
214
243
|
```
|
|
215
244
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "repl-session"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.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 = [
|
|
@@ -16,12 +16,14 @@ dependencies = [
|
|
|
16
16
|
"click>=8.1",
|
|
17
17
|
"msgspec>=0.19.0",
|
|
18
18
|
"pexpect>=4.9.0",
|
|
19
|
+
"pyyaml>=6.0",
|
|
19
20
|
]
|
|
20
21
|
|
|
21
22
|
[dependency-groups]
|
|
22
23
|
dev = [
|
|
23
24
|
"basedpyright>=1.31.4",
|
|
24
25
|
"entangled-cli>=2.1.13",
|
|
26
|
+
"ipykernel>=7.4.0",
|
|
25
27
|
"mkdocs-macros-plugin>=1.3.9",
|
|
26
28
|
"mkdocs-material>=9.6.20",
|
|
27
29
|
"pytest>=9.2.0",
|
|
@@ -34,5 +36,14 @@ dev = [
|
|
|
34
36
|
[project.scripts]
|
|
35
37
|
repl-session = "repl_session:main"
|
|
36
38
|
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
jupyter = [
|
|
41
|
+
"jupyter-client>=8.6",
|
|
42
|
+
]
|
|
43
|
+
|
|
37
44
|
[tool.uv]
|
|
38
45
|
package = true
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
# Imports are grouped by theme (stdlib, dependencies, internal), not sorted.
|
|
49
|
+
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.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -14,6 +14,9 @@ License-File: LICENSE
|
|
|
14
14
|
Requires-Dist: click>=8.1
|
|
15
15
|
Requires-Dist: msgspec>=0.19.0
|
|
16
16
|
Requires-Dist: pexpect>=4.9.0
|
|
17
|
+
Requires-Dist: pyyaml>=6.0
|
|
18
|
+
Provides-Extra: jupyter
|
|
19
|
+
Requires-Dist: jupyter-client>=8.6; extra == "jupyter"
|
|
17
20
|
Dynamic: license-file
|
|
18
21
|
|
|
19
22
|
# REPL Session
|
|
@@ -33,7 +36,7 @@ This is only really useful if you're hacking together a literate programming env
|
|
|
33
36
|
|
|
34
37
|
The REPL of your choice is started and interacted with through the `pexpect` library. All I/O is dealt with through `msgspec`.
|
|
35
38
|
|
|
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.
|
|
39
|
+
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
40
|
|
|
38
41
|
## Install
|
|
39
42
|
|
|
@@ -169,12 +172,13 @@ null
|
|
|
169
172
|
|
|
170
173
|
## Input/Output structure
|
|
171
174
|
|
|
172
|
-
The user can configure how the REPL is called and interpreted.
|
|
175
|
+
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`).
|
|
173
176
|
|
|
174
177
|
```python
|
|
175
178
|
#| id: input-data
|
|
176
|
-
class ReplConfig(msgspec.Struct):
|
|
177
|
-
"""Configuration
|
|
179
|
+
class ReplConfig(msgspec.Struct, tag_field="backend", tag="repl"):
|
|
180
|
+
"""Configuration for the `pexpect` backend; selected with `backend: repl`,
|
|
181
|
+
which is also the default when `backend` is absent.
|
|
178
182
|
|
|
179
183
|
Attributes:
|
|
180
184
|
command (str): Command to start the REPL
|
|
@@ -201,33 +205,61 @@ class ReplConfig(msgspec.Struct):
|
|
|
201
205
|
|
|
202
206
|
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.
|
|
203
207
|
|
|
208
|
+
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`.
|
|
209
|
+
|
|
204
210
|
```python
|
|
205
211
|
#| id: input-data
|
|
212
|
+
OutputKind = Literal["result", "display", "stdout", "stderr", "error"]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class ReplOutput(msgspec.Struct, omit_defaults=True):
|
|
216
|
+
"""A single piece of output produced by a command.
|
|
217
|
+
|
|
218
|
+
Attributes:
|
|
219
|
+
kind (str): One of `result` (the value of the command), `display`
|
|
220
|
+
(rich output produced along the way, e.g. a plot), `stdout`,
|
|
221
|
+
`stderr` or `error` (a traceback). The `repl` backend cannot tell
|
|
222
|
+
these apart and reports everything as a single `result`.
|
|
223
|
+
data (dict[str, str]): MIME bundle: the same output in one or more
|
|
224
|
+
representations, keyed by MIME type. Binary data is base64 encoded;
|
|
225
|
+
JSON data is serialized to a string.
|
|
226
|
+
metadata (dict[str, Any]): Per MIME type metadata, e.g. image sizes.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
kind: OutputKind
|
|
230
|
+
data: dict[str, str]
|
|
231
|
+
metadata: dict[str, Any] = msgspec.field(default_factory=dict)
|
|
232
|
+
|
|
233
|
+
|
|
206
234
|
class ReplCommand(msgspec.Struct):
|
|
207
235
|
"""A command to be sent to the REPL.
|
|
208
236
|
|
|
209
237
|
Attributes:
|
|
210
238
|
command (str): the command.
|
|
211
239
|
output_type (str): MIME type of expected output.
|
|
212
|
-
output (str | None): evaluated output
|
|
240
|
+
output (str | None): evaluated output for `output_type`.
|
|
213
241
|
expected (str | None): expected output.
|
|
242
|
+
outputs (list[ReplOutput] | None): everything the command produced, in
|
|
243
|
+
order, with all available MIME types.
|
|
214
244
|
"""
|
|
215
245
|
|
|
216
246
|
command: str
|
|
217
247
|
output_type: str = "text/plain"
|
|
218
248
|
output: str | None = None
|
|
219
249
|
expected: str | None = None
|
|
250
|
+
outputs: list[ReplOutput] | None = None
|
|
220
251
|
|
|
221
252
|
|
|
222
253
|
class ReplSession(msgspec.Struct):
|
|
223
254
|
"""A REPL session.
|
|
224
255
|
|
|
225
256
|
Attributes:
|
|
226
|
-
config (ReplConfig): Config for setting up a REPL
|
|
257
|
+
config (ReplConfig | JupyterConfig): Config for setting up a REPL
|
|
258
|
+
session, the `backend` field selects which.
|
|
227
259
|
commands (list[ReplCommand]): List of commands in the session.
|
|
228
260
|
"""
|
|
229
261
|
|
|
230
|
-
config: ReplConfig
|
|
262
|
+
config: ReplConfig | JupyterConfig
|
|
231
263
|
commands: list[ReplCommand]
|
|
232
264
|
```
|
|
233
265
|
|
|
@@ -2,7 +2,10 @@ 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
|
|
@@ -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,223 +0,0 @@
|
|
|
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,40 +0,0 @@
|
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|