repl-session 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repl-session
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Runs a session with a Read-Eval-Print-Loop (REPL). Takes YAML as input gives JSON as output.
5
5
  Classifier: Development Status :: 3 - Alpha
6
6
  Classifier: Environment :: Console
@@ -11,7 +11,7 @@ Classifier: Topic :: Utilities
11
11
  Requires-Python: >=3.11
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: argh>=0.30.5
14
+ Requires-Dist: click>=8.1
15
15
  Requires-Dist: msgspec>=0.19.0
16
16
  Requires-Dist: pexpect>=4.9.0
17
17
  Dynamic: license-file
@@ -64,7 +64,7 @@ config:
64
64
  first_prompt: "> "
65
65
  change_prompt: '(waiter-prompt-string "{key}>")'
66
66
  prompt: "{key}> "
67
- continuation_prompt: ""
67
+ # continuation_prompt: ""
68
68
  commands:
69
69
  - command: (* 6 7)
70
70
  - command: |
@@ -75,7 +75,7 @@ commands:
75
75
  - command: (fac 10 1)
76
76
  ```
77
77
 
78
- Passing this to `repl-session`, it will start the Chez Scheme interpreter, waiting for the `>` prompt to appear. It then changes the prompt to a generated `uuid4` code, for instance `27e87a8a-742c-4501-b05d-b05814f5a010> `. This will make sure that we can't accidentally match something else for an interactive prompt (imagine we're generating some XML!). Since commands are also echoed to standard out, we need to strip them from the resulting output. Running this should give:
78
+ Passing this to `repl-session`, it will start the Chez Scheme interpreter, waiting for the `>` prompt to appear. It then changes the prompt to a generated `uuid4` code, for instance `27e87a8a-742c-4501-b05d-b05814f5a010> `. This will make sure that we can't accidentally match something else for an interactive prompt (imagine we're generating some XML!). Running this should give:
79
79
 
80
80
  ```bash
81
81
  repl-session < test/scheme.yml | jq '.commands.[].output'
@@ -83,7 +83,7 @@ repl-session < test/scheme.yml | jq '.commands.[].output'
83
83
 
84
84
  ```
85
85
  "42"
86
- "(define (fac n init)\n (if (zero? n)\n init\n (fac (- n 1) (* init n)))))"
86
+ null
87
87
  "3628800"
88
88
  ```
89
89
 
@@ -126,6 +126,8 @@ repl-session < test/lua.yml | jq '.commands.[].output'
126
126
  ```
127
127
  "42"
128
128
  "Hello, World!"
129
+ null
130
+ "3628800"
129
131
  ```
130
132
 
131
133
  ### Python
@@ -142,6 +144,7 @@ config:
142
144
  continuation_prompt: "{key}\\+\\+\\+ "
143
145
  environment:
144
146
  NO_COLOR: "1"
147
+ PYTHON_BASIC_REPL: "1"
145
148
  commands:
146
149
  - command: print("Hello, World!")
147
150
  - command: 6 * 7
@@ -153,6 +156,17 @@ commands:
153
156
  - command: fac(10)
154
157
  ```
155
158
 
159
+ ```bash
160
+ repl-session < test/python.yml | jq '.commands.[].output'
161
+ ```
162
+
163
+ ```
164
+ "Hello, World!"
165
+ "42"
166
+ null
167
+ "3628800"
168
+ ```
169
+
156
170
  ## Input/Output structure
157
171
 
158
172
  The user can configure how the REPL is called and interpreted.
@@ -183,8 +197,6 @@ class ReplConfig(msgspec.Struct):
183
197
  strip_ansi: bool = False
184
198
  environment: dict[str, str] = msgspec.field(default_factory=dict)
185
199
  timeout: float = 5.0
186
-
187
-
188
200
  ```
189
201
 
190
202
  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.
@@ -217,8 +229,6 @@ class ReplSession(msgspec.Struct):
217
229
 
218
230
  config: ReplConfig
219
231
  commands: list[ReplCommand]
220
-
221
-
222
232
  ```
223
233
 
224
234
  ## License and contribution
@@ -46,7 +46,7 @@ config:
46
46
  first_prompt: "> "
47
47
  change_prompt: '(waiter-prompt-string "{key}>")'
48
48
  prompt: "{key}> "
49
- continuation_prompt: ""
49
+ # continuation_prompt: ""
50
50
  commands:
51
51
  - command: (* 6 7)
52
52
  - command: |
@@ -57,7 +57,7 @@ commands:
57
57
  - command: (fac 10 1)
58
58
  ```
59
59
 
60
- Passing this to `repl-session`, it will start the Chez Scheme interpreter, waiting for the `>` prompt to appear. It then changes the prompt to a generated `uuid4` code, for instance `27e87a8a-742c-4501-b05d-b05814f5a010> `. This will make sure that we can't accidentally match something else for an interactive prompt (imagine we're generating some XML!). Since commands are also echoed to standard out, we need to strip them from the resulting output. Running this should give:
60
+ Passing this to `repl-session`, it will start the Chez Scheme interpreter, waiting for the `>` prompt to appear. It then changes the prompt to a generated `uuid4` code, for instance `27e87a8a-742c-4501-b05d-b05814f5a010> `. This will make sure that we can't accidentally match something else for an interactive prompt (imagine we're generating some XML!). Running this should give:
61
61
 
62
62
  ```bash
63
63
  repl-session < test/scheme.yml | jq '.commands.[].output'
@@ -65,7 +65,7 @@ repl-session < test/scheme.yml | jq '.commands.[].output'
65
65
 
66
66
  ```
67
67
  "42"
68
- "(define (fac n init)\n (if (zero? n)\n init\n (fac (- n 1) (* init n)))))"
68
+ null
69
69
  "3628800"
70
70
  ```
71
71
 
@@ -108,6 +108,8 @@ repl-session < test/lua.yml | jq '.commands.[].output'
108
108
  ```
109
109
  "42"
110
110
  "Hello, World!"
111
+ null
112
+ "3628800"
111
113
  ```
112
114
 
113
115
  ### Python
@@ -124,6 +126,7 @@ config:
124
126
  continuation_prompt: "{key}\\+\\+\\+ "
125
127
  environment:
126
128
  NO_COLOR: "1"
129
+ PYTHON_BASIC_REPL: "1"
127
130
  commands:
128
131
  - command: print("Hello, World!")
129
132
  - command: 6 * 7
@@ -135,6 +138,17 @@ commands:
135
138
  - command: fac(10)
136
139
  ```
137
140
 
141
+ ```bash
142
+ repl-session < test/python.yml | jq '.commands.[].output'
143
+ ```
144
+
145
+ ```
146
+ "Hello, World!"
147
+ "42"
148
+ null
149
+ "3628800"
150
+ ```
151
+
138
152
  ## Input/Output structure
139
153
 
140
154
  The user can configure how the REPL is called and interpreted.
@@ -165,8 +179,6 @@ class ReplConfig(msgspec.Struct):
165
179
  strip_ansi: bool = False
166
180
  environment: dict[str, str] = msgspec.field(default_factory=dict)
167
181
  timeout: float = 5.0
168
-
169
-
170
182
  ```
171
183
 
172
184
  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.
@@ -199,8 +211,6 @@ class ReplSession(msgspec.Struct):
199
211
 
200
212
  config: ReplConfig
201
213
  commands: list[ReplCommand]
202
-
203
-
204
214
  ```
205
215
 
206
216
  ## License and contribution
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repl-session"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Runs a session with a Read-Eval-Print-Loop (REPL). Takes YAML as input gives JSON as output."
5
5
  readme = "README.md"
6
6
  classifiers = [
@@ -13,7 +13,7 @@ classifiers = [
13
13
  ]
14
14
  requires-python = ">=3.11"
15
15
  dependencies = [
16
- "argh>=0.30.5",
16
+ "click>=8.1",
17
17
  "msgspec>=0.19.0",
18
18
  "pexpect>=4.9.0",
19
19
  ]
@@ -24,9 +24,11 @@ dev = [
24
24
  "entangled-cli>=2.1.13",
25
25
  "mkdocs-macros-plugin>=1.3.9",
26
26
  "mkdocs-material>=9.6.20",
27
+ "pytest>=9.2.0",
27
28
  "python-lsp-server>=1.13.1",
28
29
  "ruff>=0.13.2",
29
30
  "ty>=0.0.1a21",
31
+ "types-pexpect>=4.9.0.20250916",
30
32
  ]
31
33
 
32
34
  [project.scripts]
@@ -1,12 +1,10 @@
1
1
  # ~/~ begin <<docs/index.md#src/repl_session/__init__.py>>[init]
2
- #| file: src/repl_session/__init__.py
3
2
  """
4
3
  `repl-session` is a command-line tool to evaluate a given session
5
4
  in any REPL, and store the results.
6
5
  """
7
6
 
8
7
  # ~/~ begin <<docs/index.md#imports>>[init]
9
- #| id: imports
10
8
  # from datetime import datetime, tzinfo
11
9
  from typing import IO, cast
12
10
  from collections.abc import Generator, Callable
@@ -17,19 +15,17 @@ import uuid
17
15
  import sys
18
16
  import re
19
17
  import logging
18
+ import os
20
19
 
21
20
  import pexpect
22
21
  import msgspec
23
- import argh
22
+ import click
24
23
  import importlib.metadata
25
24
 
26
25
 
27
26
  __version__ = importlib.metadata.version("repl-session")
28
27
  # ~/~ end
29
-
30
-
31
28
  # ~/~ begin <<README.md#input-data>>[init]
32
- #| id: input-data
33
29
  class ReplConfig(msgspec.Struct):
34
30
  """Configuration
35
31
 
@@ -54,11 +50,8 @@ class ReplConfig(msgspec.Struct):
54
50
  strip_ansi: bool = False
55
51
  environment: dict[str, str] = msgspec.field(default_factory=dict)
56
52
  timeout: float = 5.0
57
-
58
-
59
53
  # ~/~ end
60
54
  # ~/~ begin <<README.md#input-data>>[1]
61
- #| id: input-data
62
55
  class ReplCommand(msgspec.Struct):
63
56
  """A command to be sent to the REPL.
64
57
 
@@ -85,24 +78,33 @@ class ReplSession(msgspec.Struct):
85
78
 
86
79
  config: ReplConfig
87
80
  commands: list[ReplCommand]
88
-
89
-
90
81
  # ~/~ end
91
-
92
-
93
82
  # ~/~ begin <<docs/index.md#repl-contextmanager>>[init]
94
- #| id: repl-contextmanager
95
83
  def spawn(config: ReplConfig):
84
+ env = dict(os.environ) | config.environment
96
85
  child: pexpect.spawn[str] = pexpect.spawn(
97
86
  config.command,
98
87
  timeout=config.timeout,
99
88
  echo=False,
100
89
  encoding="utf-8",
101
- env=config.environment,
90
+ env=dict(os.environ) | config.environment,
102
91
  )
103
92
  return child
104
93
 
105
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
+
106
108
  @contextmanager
107
109
  def repl(config: ReplConfig) -> Generator[Callable[[str], str | None]]:
108
110
  key = str(uuid.uuid4())
@@ -122,58 +124,61 @@ def repl(config: ReplConfig) -> Generator[Callable[[str], str | None]]:
122
124
  # child.expect(key)
123
125
  _ = child.expect(prompt)
124
126
 
125
- def send(msg: str) -> str | None:
126
- nonlocal prompt, continuation_prompt, change_prompt_cmd
127
- lines = msg.splitlines()
128
- answer: list[str] = []
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
129
134
 
130
- still_waiting: bool = True
131
- for line in lines:
132
- logging.debug("sending: %s", line)
133
- _ = child.sendline(line)
134
- if continuation_prompt is not None:
135
+ still_waiting: bool = True
136
+ for line in lines:
137
+ logging.debug("sending: '%s'", line)
138
+ _ = child.sendline(line)
135
139
  logging.debug("waiting for prompt or continuation")
136
140
  _ = child.expect(
137
- f"(?P<cont>{continuation_prompt})|(?P<norm>{prompt})"
141
+ f"(?P<norm>{prompt})|(?P<cont>{continuation_prompt})"
138
142
  )
139
143
  if not isinstance(child.match, re.Match):
140
144
  continue
141
145
  if child.match.group("cont") is not None:
142
- logging.debug("continuation")
146
+ logging.debug("continuation: %s -- %s", child.before, child.after)
143
147
  still_waiting = True
144
148
  else:
145
149
  logging.debug("done: %s", child.before)
146
150
  if child.before is not None:
147
151
  answer.append(child.before)
148
152
  still_waiting = False
149
- else:
150
- logging.debug("waiting for prompt")
153
+
154
+ if still_waiting:
155
+ logging.debug(f"waiting for last prompt")
156
+ _ = child.sendline("")
151
157
  _ = child.expect(prompt)
158
+ logging.debug(f"got: %s", child.before)
152
159
  if child.before:
153
160
  answer.append(child.before)
154
- still_waiting = False
155
161
 
156
- if still_waiting:
157
- _ = child.sendline("")
158
- _ = child.expect(prompt)
159
- if child.before:
160
- answer.append(child.before)
162
+ if not answer:
163
+ return None
161
164
 
162
- if not answer:
163
- return None
165
+ return clean_output(config, answer[-1])
164
166
 
165
- if config.strip_ansi:
166
- ansi_escape = re.compile(r"(\u001b\[|\x1B\[)[0-?]*[ -\/]*[@-~]")
167
- return ansi_escape.sub("", answer[-1].strip())
167
+ else:
168
+ def send(msg: str) -> str | None:
169
+ logging.debug("sending: %s", msg)
168
170
 
169
- return answer[-1].strip()
171
+ _ = child.sendline(msg)
172
+ _ = child.expect(prompt)
170
173
 
171
- yield send
174
+ if not child.before:
175
+ return None
172
176
 
177
+ return clean_output(config, child.before)
173
178
 
179
+ yield send
174
180
  # ~/~ end
175
181
  # ~/~ begin <<docs/index.md#run-session>>[init]
176
- #| id: run-session
177
182
  def run_session(session: ReplSession):
178
183
  with repl(session.config) as run:
179
184
  for cmd in session.commands:
@@ -183,11 +188,8 @@ def run_session(session: ReplSession):
183
188
  cmd.expected = expected
184
189
 
185
190
  return session
186
-
187
-
188
191
  # ~/~ end
189
192
  # ~/~ begin <<docs/index.md#io>>[init]
190
- #| id: io
191
193
  def read_session(port: IO[str] = sys.stdin) -> ReplSession:
192
194
  data: str = port.read()
193
195
  return msgspec.yaml.decode(data, type=ReplSession)
@@ -196,23 +198,20 @@ def read_session(port: IO[str] = sys.stdin) -> ReplSession:
196
198
  def write_session(session: ReplSession, port: IO[str] = sys.stdout):
197
199
  data = msgspec.json.encode(session)
198
200
  _ = port.write(data.decode())
199
-
200
-
201
201
  # ~/~ end
202
202
 
203
-
204
- @argh.arg("-v", "--version", help="show version and exit")
205
- @argh.arg("-l", "--log-enable", help="show debugging output")
206
- def repl_session(version: bool = False, log_enable: bool = False):
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):
207
210
  """
208
211
  repl-session runs a REPL session, reading JSON from standard input and
209
212
  writing to standard output. Both the input and output follow the same
210
213
  schema.
211
214
  """
212
- if version:
213
- print(f"repl-session {__version__}")
214
- sys.exit(0)
215
-
216
215
  if log_enable:
217
216
  logging.basicConfig(level=logging.DEBUG)
218
217
 
@@ -220,7 +219,5 @@ def repl_session(version: bool = False, log_enable: bool = False):
220
219
 
221
220
 
222
221
  def main():
223
- argh.dispatch_command(repl_session)
224
-
225
-
222
+ repl_session()
226
223
  # ~/~ end
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repl-session
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Runs a session with a Read-Eval-Print-Loop (REPL). Takes YAML as input gives JSON as output.
5
5
  Classifier: Development Status :: 3 - Alpha
6
6
  Classifier: Environment :: Console
@@ -11,7 +11,7 @@ Classifier: Topic :: Utilities
11
11
  Requires-Python: >=3.11
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: argh>=0.30.5
14
+ Requires-Dist: click>=8.1
15
15
  Requires-Dist: msgspec>=0.19.0
16
16
  Requires-Dist: pexpect>=4.9.0
17
17
  Dynamic: license-file
@@ -64,7 +64,7 @@ config:
64
64
  first_prompt: "> "
65
65
  change_prompt: '(waiter-prompt-string "{key}>")'
66
66
  prompt: "{key}> "
67
- continuation_prompt: ""
67
+ # continuation_prompt: ""
68
68
  commands:
69
69
  - command: (* 6 7)
70
70
  - command: |
@@ -75,7 +75,7 @@ commands:
75
75
  - command: (fac 10 1)
76
76
  ```
77
77
 
78
- Passing this to `repl-session`, it will start the Chez Scheme interpreter, waiting for the `>` prompt to appear. It then changes the prompt to a generated `uuid4` code, for instance `27e87a8a-742c-4501-b05d-b05814f5a010> `. This will make sure that we can't accidentally match something else for an interactive prompt (imagine we're generating some XML!). Since commands are also echoed to standard out, we need to strip them from the resulting output. Running this should give:
78
+ Passing this to `repl-session`, it will start the Chez Scheme interpreter, waiting for the `>` prompt to appear. It then changes the prompt to a generated `uuid4` code, for instance `27e87a8a-742c-4501-b05d-b05814f5a010> `. This will make sure that we can't accidentally match something else for an interactive prompt (imagine we're generating some XML!). Running this should give:
79
79
 
80
80
  ```bash
81
81
  repl-session < test/scheme.yml | jq '.commands.[].output'
@@ -83,7 +83,7 @@ repl-session < test/scheme.yml | jq '.commands.[].output'
83
83
 
84
84
  ```
85
85
  "42"
86
- "(define (fac n init)\n (if (zero? n)\n init\n (fac (- n 1) (* init n)))))"
86
+ null
87
87
  "3628800"
88
88
  ```
89
89
 
@@ -126,6 +126,8 @@ repl-session < test/lua.yml | jq '.commands.[].output'
126
126
  ```
127
127
  "42"
128
128
  "Hello, World!"
129
+ null
130
+ "3628800"
129
131
  ```
130
132
 
131
133
  ### Python
@@ -142,6 +144,7 @@ config:
142
144
  continuation_prompt: "{key}\\+\\+\\+ "
143
145
  environment:
144
146
  NO_COLOR: "1"
147
+ PYTHON_BASIC_REPL: "1"
145
148
  commands:
146
149
  - command: print("Hello, World!")
147
150
  - command: 6 * 7
@@ -153,6 +156,17 @@ commands:
153
156
  - command: fac(10)
154
157
  ```
155
158
 
159
+ ```bash
160
+ repl-session < test/python.yml | jq '.commands.[].output'
161
+ ```
162
+
163
+ ```
164
+ "Hello, World!"
165
+ "42"
166
+ null
167
+ "3628800"
168
+ ```
169
+
156
170
  ## Input/Output structure
157
171
 
158
172
  The user can configure how the REPL is called and interpreted.
@@ -183,8 +197,6 @@ class ReplConfig(msgspec.Struct):
183
197
  strip_ansi: bool = False
184
198
  environment: dict[str, str] = msgspec.field(default_factory=dict)
185
199
  timeout: float = 5.0
186
-
187
-
188
200
  ```
189
201
 
190
202
  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.
@@ -217,8 +229,6 @@ class ReplSession(msgspec.Struct):
217
229
 
218
230
  config: ReplConfig
219
231
  commands: list[ReplCommand]
220
-
221
-
222
232
  ```
223
233
 
224
234
  ## License and contribution
@@ -8,4 +8,5 @@ src/repl_session.egg-info/SOURCES.txt
8
8
  src/repl_session.egg-info/dependency_links.txt
9
9
  src/repl_session.egg-info/entry_points.txt
10
10
  src/repl_session.egg-info/requires.txt
11
- src/repl_session.egg-info/top_level.txt
11
+ src/repl_session.egg-info/top_level.txt
12
+ test/test_examples.py
@@ -1,3 +1,3 @@
1
- argh>=0.30.5
1
+ click>=8.1
2
2
  msgspec>=0.19.0
3
3
  pexpect>=4.9.0
@@ -0,0 +1,40 @@
1
+ """Run the example sessions from the README and check their outputs."""
2
+
3
+ import os
4
+ import shlex
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ import msgspec
9
+ import pytest
10
+
11
+ from repl_session import ReplSession, run_session
12
+
13
+
14
+ TEST_DIR = Path(__file__).parent
15
+
16
+
17
+ EXPECTED = {
18
+ "scheme": ["42", None, "3628800"],
19
+ "lua": ["42", "Hello, World!", None, "3628800"],
20
+ "python": ["Hello, World!", "42", None, "3628800"],
21
+ }
22
+
23
+
24
+ def load_session(name: str) -> ReplSession:
25
+ data = (TEST_DIR / f"{name}.yml").read_bytes()
26
+ return msgspec.yaml.decode(data, type=ReplSession)
27
+
28
+
29
+ @pytest.mark.parametrize("name", EXPECTED.keys())
30
+ def test_example(name: str):
31
+ session = load_session(name)
32
+ executable = shlex.split(session.config.command)[0]
33
+ if shutil.which(executable) is None:
34
+ # In CI every interpreter must be available; locally we allow skipping.
35
+ if os.environ.get("CI"):
36
+ pytest.fail(f"`{executable}` not found")
37
+ pytest.skip(f"`{executable}` not found")
38
+
39
+ result = run_session(session)
40
+ assert [cmd.output for cmd in result.commands] == EXPECTED[name]
File without changes
File without changes