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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repl-session
3
- Version: 0.2.1
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. I may still include Jupyter support in this application at a later stage.
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 session.
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. 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
 
@@ -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 session.
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.2.1"
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.2.1
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. I may still include Jupyter support in this application at a later stage.
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 session.
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,7 @@
1
+ click>=8.1
2
+ msgspec>=0.19.0
3
+ pexpect>=4.9.0
4
+ pyyaml>=6.0
5
+
6
+ [jupyter]
7
+ 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,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,3 +0,0 @@
1
- click>=8.1
2
- msgspec>=0.19.0
3
- pexpect>=4.9.0
@@ -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