clikernel 0.1.0__tar.gz → 0.1.2__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.
@@ -0,0 +1,20 @@
1
+ <!-- do not remove -->
2
+
3
+ ## 0.1.2
4
+
5
+ ### New Features
6
+
7
+ - Add ONLCR terminal flag control ([#2](https://github.com/AnswerDotAI/clikernel/issues/2))
8
+
9
+
10
+ ## 0.1.1
11
+
12
+ ### New Features
13
+
14
+ - Use a fixed per-session delimiter and emit `.` ack before each response ([#1](https://github.com/AnswerDotAI/clikernel/issues/1))
15
+
16
+
17
+ ## 0.1.0
18
+
19
+ - Initial release
20
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clikernel
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: A tiny stdin/stdout IPython worker
5
5
  Author: clikernel contributors
6
6
  License: Apache-2.0
@@ -34,7 +34,7 @@ This is especially useful with [pyskills](https://github.com/AnswerDotAI/pyskill
34
34
 
35
35
  ## Protocol
36
36
 
37
- On startup, `clikernel` prints loading status followed by a fresh ready delimiter:
37
+ On startup, `clikernel` prints loading status followed by a random session delimiter:
38
38
 
39
39
  ```text
40
40
  please wait, loading...
@@ -42,13 +42,17 @@ loading complete. first delimiter:
42
42
  --aB3x9
43
43
  ```
44
44
 
45
+ That delimiter stays the same until the worker exits.
46
+
45
47
  Send one line to execute it immediately:
46
48
 
47
49
  ```text
48
50
  1+1
49
51
  ```
50
52
 
51
- For multiline code, send `--` on its own line, then the code, then the latest ready delimiter exactly:
53
+ Each complete request is acknowledged with `.` before execution starts. Send `exit()` or `quit()` to receive an acknowledgement, a final delimiter, and stop the worker.
54
+
55
+ For multiline code, send `--` on its own line, then the code, then the session delimiter exactly:
52
56
 
53
57
  ```text
54
58
  --
@@ -59,11 +63,12 @@ f(2)
59
63
  --aB3x9
60
64
  ```
61
65
 
62
- After execution, `clikernel` prints the rendered output followed by a new ready delimiter:
66
+ After execution, `clikernel` prints the acknowledgement, the rendered output, and the session delimiter:
63
67
 
64
68
  ```text
69
+ .
65
70
  3
66
- --Q7z2M
71
+ --aB3x9
67
72
  ```
68
73
 
69
74
  Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty output is printed directly. Multiple outputs use raw XML-ish tags, for example `<stdout>`, `<display_data mime="text/markdown">`, and `<execute_result>`.
@@ -74,7 +79,7 @@ Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty output
74
79
 
75
80
  `clikernel` is built for a client that reads stdout as tokens. Local echo is disabled when stdin is a TTY. The client already knows the code it sent, so echoing it back only makes the LLM read slow, expensive tokens that add no information.
76
81
 
77
- Each response ends with a delimiter on its own line. The client can read until that line appears instead of parsing prompts or waiting and guessing. The delimiter changes after every request. This matters for multiline input, where the current delimiter is also the block terminator. A random current delimiter is unlikely to appear in generated code, copied logs, examples, or earlier transcript text. Old delimiters stop working as soon as the next response is printed.
82
+ Each complete request prints `.` on its own line before execution starts. That gives the client a cheap early byte to read, which is useful when the request will run for a while. Each response ends with the same delimiter on its own line. The client can read until that line appears instead of parsing prompts or waiting and guessing. The delimiter is random per process, so it is unlikely to appear in generated code, copied logs, examples, or earlier transcript text. Keeping it fixed for the session means a client does not get stuck just because it missed a rotated delimiter.
78
83
 
79
84
  Startup messages appear before the first delimiter. After that first delimiter, the stream follows the request-response protocol. Outputs are rendered as concise text, using unescaped XML if required when there's multiple outputs.
80
85
 
@@ -100,4 +105,3 @@ ship-gh
100
105
  ship-pypi
101
106
  ship-bump # dev release always later than prod release
102
107
  ```
103
-
@@ -14,7 +14,7 @@ This is especially useful with [pyskills](https://github.com/AnswerDotAI/pyskill
14
14
 
15
15
  ## Protocol
16
16
 
17
- On startup, `clikernel` prints loading status followed by a fresh ready delimiter:
17
+ On startup, `clikernel` prints loading status followed by a random session delimiter:
18
18
 
19
19
  ```text
20
20
  please wait, loading...
@@ -22,13 +22,17 @@ loading complete. first delimiter:
22
22
  --aB3x9
23
23
  ```
24
24
 
25
+ That delimiter stays the same until the worker exits.
26
+
25
27
  Send one line to execute it immediately:
26
28
 
27
29
  ```text
28
30
  1+1
29
31
  ```
30
32
 
31
- For multiline code, send `--` on its own line, then the code, then the latest ready delimiter exactly:
33
+ Each complete request is acknowledged with `.` before execution starts. Send `exit()` or `quit()` to receive an acknowledgement, a final delimiter, and stop the worker.
34
+
35
+ For multiline code, send `--` on its own line, then the code, then the session delimiter exactly:
32
36
 
33
37
  ```text
34
38
  --
@@ -39,11 +43,12 @@ f(2)
39
43
  --aB3x9
40
44
  ```
41
45
 
42
- After execution, `clikernel` prints the rendered output followed by a new ready delimiter:
46
+ After execution, `clikernel` prints the acknowledgement, the rendered output, and the session delimiter:
43
47
 
44
48
  ```text
49
+ .
45
50
  3
46
- --Q7z2M
51
+ --aB3x9
47
52
  ```
48
53
 
49
54
  Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty output is printed directly. Multiple outputs use raw XML-ish tags, for example `<stdout>`, `<display_data mime="text/markdown">`, and `<execute_result>`.
@@ -54,7 +59,7 @@ Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty output
54
59
 
55
60
  `clikernel` is built for a client that reads stdout as tokens. Local echo is disabled when stdin is a TTY. The client already knows the code it sent, so echoing it back only makes the LLM read slow, expensive tokens that add no information.
56
61
 
57
- Each response ends with a delimiter on its own line. The client can read until that line appears instead of parsing prompts or waiting and guessing. The delimiter changes after every request. This matters for multiline input, where the current delimiter is also the block terminator. A random current delimiter is unlikely to appear in generated code, copied logs, examples, or earlier transcript text. Old delimiters stop working as soon as the next response is printed.
62
+ Each complete request prints `.` on its own line before execution starts. That gives the client a cheap early byte to read, which is useful when the request will run for a while. Each response ends with the same delimiter on its own line. The client can read until that line appears instead of parsing prompts or waiting and guessing. The delimiter is random per process, so it is unlikely to appear in generated code, copied logs, examples, or earlier transcript text. Keeping it fixed for the session means a client does not get stuck just because it missed a rotated delimiter.
58
63
 
59
64
  Startup messages appear before the first delimiter. After that first delimiter, the stream follows the request-response protocol. Outputs are rendered as concise text, using unescaped XML if required when there's multiple outputs.
60
65
 
@@ -80,4 +85,3 @@ ship-gh
80
85
  ship-pypi
81
86
  ship-bump # dev release always later than prod release
82
87
  ```
83
-
@@ -0,0 +1,3 @@
1
+ __version__ = "0.1.2"
2
+
3
+
@@ -48,9 +48,18 @@ def _execute(shell, code):
48
48
  return ("error" if shell.exc else "ok"), render_text(outputs)
49
49
 
50
50
 
51
+ def _request_exit(shell): shell._clikernel_exit = True
52
+
53
+
51
54
  def _make_shell():
52
55
  from execnb.shell import CaptureShell
53
- return CaptureShell(mpl_format=None, history=False)
56
+ shell = CaptureShell(mpl_format=None, history=False)
57
+ shell._clikernel_exit = False
58
+ shell.ask_exit = lambda: _request_exit(shell)
59
+ return shell
60
+
61
+
62
+ def _should_exit(shell): return getattr(shell, "_clikernel_exit", False)
54
63
 
55
64
 
56
65
  def _disable_echo():
@@ -66,10 +75,24 @@ def _disable_echo():
66
75
  def _restore_echo(state):
67
76
  if state: termios.tcsetattr(state[0], termios.TCSADRAIN, state[1])
68
77
 
78
+ def _disable_output_newline_translation():
79
+ if not sys.__stdout__.isatty(): return None
80
+ fd = sys.__stdout__.fileno()
81
+ attrs = termios.tcgetattr(fd)
82
+ new_attrs = attrs[:]
83
+ new_attrs[1] &= ~termios.ONLCR
84
+ termios.tcsetattr(fd, termios.TCSADRAIN, new_attrs)
85
+ return fd, attrs
86
+
87
+
88
+ def _restore_termios(state):
89
+ if state: termios.tcsetattr(state[0], termios.TCSADRAIN, state[1])
90
+
69
91
 
70
92
  def main():
71
- echo_state = _disable_echo()
72
93
  shell = _make_shell()
94
+ output_state = _disable_output_newline_translation()
95
+ echo_state = _disable_echo()
73
96
  delim = _new_delim()
74
97
  print("loading complete. first delimiter:", flush=True)
75
98
  _write_response(delim)
@@ -79,15 +102,17 @@ def main():
79
102
  if line == _MULTILINE:
80
103
  code, err = _read_block(sys.stdin, delim)
81
104
  if err:
82
- delim = _new_delim()
83
105
  _write_response(delim, _format_error("protocol-error", err))
84
106
  continue
85
107
  else: code = line
108
+ print(".", flush=True)
86
109
  try: _, outputs = _execute(shell, code)
87
110
  except BaseException: outputs = _format_error("internal-error", traceback.format_exc())
88
- delim = _new_delim()
89
111
  _write_response(delim, outputs)
90
- finally: _restore_echo(echo_state)
112
+ if _should_exit(shell): break
113
+ finally:
114
+ _restore_echo(echo_state)
115
+ _restore_termios(output_state)
91
116
 
92
117
 
93
118
  if __name__ == "__main__": main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clikernel
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: A tiny stdin/stdout IPython worker
5
5
  Author: clikernel contributors
6
6
  License: Apache-2.0
@@ -34,7 +34,7 @@ This is especially useful with [pyskills](https://github.com/AnswerDotAI/pyskill
34
34
 
35
35
  ## Protocol
36
36
 
37
- On startup, `clikernel` prints loading status followed by a fresh ready delimiter:
37
+ On startup, `clikernel` prints loading status followed by a random session delimiter:
38
38
 
39
39
  ```text
40
40
  please wait, loading...
@@ -42,13 +42,17 @@ loading complete. first delimiter:
42
42
  --aB3x9
43
43
  ```
44
44
 
45
+ That delimiter stays the same until the worker exits.
46
+
45
47
  Send one line to execute it immediately:
46
48
 
47
49
  ```text
48
50
  1+1
49
51
  ```
50
52
 
51
- For multiline code, send `--` on its own line, then the code, then the latest ready delimiter exactly:
53
+ Each complete request is acknowledged with `.` before execution starts. Send `exit()` or `quit()` to receive an acknowledgement, a final delimiter, and stop the worker.
54
+
55
+ For multiline code, send `--` on its own line, then the code, then the session delimiter exactly:
52
56
 
53
57
  ```text
54
58
  --
@@ -59,11 +63,12 @@ f(2)
59
63
  --aB3x9
60
64
  ```
61
65
 
62
- After execution, `clikernel` prints the rendered output followed by a new ready delimiter:
66
+ After execution, `clikernel` prints the acknowledgement, the rendered output, and the session delimiter:
63
67
 
64
68
  ```text
69
+ .
65
70
  3
66
- --Q7z2M
71
+ --aB3x9
67
72
  ```
68
73
 
69
74
  Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty output is printed directly. Multiple outputs use raw XML-ish tags, for example `<stdout>`, `<display_data mime="text/markdown">`, and `<execute_result>`.
@@ -74,7 +79,7 @@ Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty output
74
79
 
75
80
  `clikernel` is built for a client that reads stdout as tokens. Local echo is disabled when stdin is a TTY. The client already knows the code it sent, so echoing it back only makes the LLM read slow, expensive tokens that add no information.
76
81
 
77
- Each response ends with a delimiter on its own line. The client can read until that line appears instead of parsing prompts or waiting and guessing. The delimiter changes after every request. This matters for multiline input, where the current delimiter is also the block terminator. A random current delimiter is unlikely to appear in generated code, copied logs, examples, or earlier transcript text. Old delimiters stop working as soon as the next response is printed.
82
+ Each complete request prints `.` on its own line before execution starts. That gives the client a cheap early byte to read, which is useful when the request will run for a while. Each response ends with the same delimiter on its own line. The client can read until that line appears instead of parsing prompts or waiting and guessing. The delimiter is random per process, so it is unlikely to appear in generated code, copied logs, examples, or earlier transcript text. Keeping it fixed for the session means a client does not get stuck just because it missed a rotated delimiter.
78
83
 
79
84
  Startup messages appear before the first delimiter. After that first delimiter, the stream follows the request-response protocol. Outputs are rendered as concise text, using unescaped XML if required when there's multiple outputs.
80
85
 
@@ -100,4 +105,3 @@ ship-gh
100
105
  ship-pypi
101
106
  ship-bump # dev release always later than prod release
102
107
  ```
103
-
@@ -0,0 +1,260 @@
1
+ import os,pty,re,select,shutil,subprocess,tempfile,time
2
+
3
+ import pytest
4
+
5
+ DELIM_RE = re.compile(r"--[A-Za-z0-9]{5}")
6
+ TIMEOUT = 5
7
+
8
+
9
+ def _failure_detail(proc):
10
+ if proc.stderr is None: return ""
11
+ ready, _, _ = select.select([proc.stderr], [], [], 0)
12
+ if not ready: return ""
13
+ err = os.read(proc.stderr.fileno(), 65536).decode("utf-8", "replace")
14
+ return f"\nstderr:\n{err}" if err else ""
15
+
16
+
17
+ def _readline(proc, timeout):
18
+ assert proc.stdout is not None
19
+ buf = proc._stdout_buffer
20
+ while b"\n" not in buf:
21
+ ready, _, _ = select.select([proc.stdout], [], [], timeout)
22
+ if not ready: raise AssertionError(f"timed out waiting for clikernel output{_failure_detail(proc)}")
23
+ chunk = os.read(proc.stdout.fileno(), 4096)
24
+ assert chunk, _failure_detail(proc)
25
+ buf.extend(chunk)
26
+ line, _, rest = buf.partition(b"\n")
27
+ proc._stdout_buffer = bytearray(rest)
28
+ return line.decode("utf-8", "replace") + "\n"
29
+
30
+
31
+ def read_until_ready(proc, timeout=TIMEOUT):
32
+ lines = []
33
+ deadline = time.monotonic() + timeout
34
+ while True:
35
+ remaining = deadline - time.monotonic()
36
+ if remaining <= 0: raise AssertionError(f"timed out waiting for ready delimiter{_failure_detail(proc)}")
37
+ line = _readline(proc, remaining)
38
+ s = line.rstrip("\n")
39
+ if DELIM_RE.fullmatch(s): return "".join(lines), s
40
+ lines.append(line)
41
+
42
+
43
+ def start_kernel(tmp_path):
44
+ cmd = shutil.which("clikernel")
45
+ assert cmd, "clikernel console script is not on PATH"
46
+ env = os.environ.copy()
47
+ state = os.path.join(tempfile.gettempdir(), f"clikernel-{os.getuid()}")
48
+ env["CLIKERNEL_STATE_DIR"] = str(tmp_path / "state")
49
+ env.setdefault("IPYTHONDIR", os.path.join(state, "ipython"))
50
+ env.setdefault("MPLCONFIGDIR", os.path.join(state, "matplotlib"))
51
+ env.setdefault("MPLBACKEND", "Agg")
52
+ env["PYTHONUNBUFFERED"] = "1"
53
+ proc = subprocess.Popen([cmd], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
54
+ proc._stdout_buffer = bytearray()
55
+ return proc
56
+
57
+
58
+ def start_kernel_pty(tmp_path):
59
+ cmd = shutil.which("clikernel")
60
+ assert cmd, "clikernel console script is not on PATH"
61
+ env = os.environ.copy()
62
+ state = os.path.join(tempfile.gettempdir(), f"clikernel-{os.getuid()}")
63
+ env["CLIKERNEL_STATE_DIR"] = str(tmp_path / "state")
64
+ env.setdefault("IPYTHONDIR", os.path.join(state, "ipython"))
65
+ env.setdefault("MPLCONFIGDIR", os.path.join(state, "matplotlib"))
66
+ env.setdefault("MPLBACKEND", "Agg")
67
+ env["PYTHONUNBUFFERED"] = "1"
68
+ master, slave = pty.openpty()
69
+ try: proc = subprocess.Popen([cmd], stdin=slave, stdout=slave, stderr=subprocess.PIPE, env=env, close_fds=True)
70
+ finally: os.close(slave)
71
+ proc._pty_master = master
72
+ proc._pty_buffer = bytearray()
73
+ return proc
74
+
75
+
76
+ def stop_kernel(proc):
77
+ if proc.stdin is not None:
78
+ try: proc.stdin.close()
79
+ except OSError: pass
80
+ if hasattr(proc, "_pty_master"):
81
+ try: os.close(proc._pty_master)
82
+ except OSError: pass
83
+ if proc.poll() is None:
84
+ try: proc.wait(timeout=1)
85
+ except subprocess.TimeoutExpired:
86
+ proc.terminate()
87
+ try: proc.wait(timeout=1)
88
+ except subprocess.TimeoutExpired:
89
+ proc.kill()
90
+ proc.wait(timeout=1)
91
+
92
+
93
+ def send(proc, text, timeout=TIMEOUT):
94
+ assert proc.stdin is not None
95
+ proc.stdin.write(text.encode("utf-8"))
96
+ proc.stdin.flush()
97
+ assert _readline(proc, timeout) == ".\n"
98
+ return read_until_ready(proc, timeout)
99
+
100
+
101
+ def _read_ptyline(proc, timeout):
102
+ buf = proc._pty_buffer
103
+ while b"\n" not in buf:
104
+ ready, _, _ = select.select([proc._pty_master], [], [], timeout)
105
+ if not ready: raise AssertionError(f"timed out waiting for clikernel pty output{_failure_detail(proc)}")
106
+ chunk = os.read(proc._pty_master, 4096)
107
+ assert chunk, _failure_detail(proc)
108
+ buf.extend(chunk)
109
+ line, _, rest = buf.partition(b"\n")
110
+ proc._pty_buffer = bytearray(rest)
111
+ return line.decode("utf-8", "replace").rstrip("\r") + "\n"
112
+
113
+
114
+ def read_pty_until_ready(proc, timeout=TIMEOUT):
115
+ lines = []
116
+ deadline = time.monotonic() + timeout
117
+ while True:
118
+ remaining = deadline - time.monotonic()
119
+ if remaining <= 0: raise AssertionError(f"timed out waiting for ready delimiter{_failure_detail(proc)}")
120
+ line = _read_ptyline(proc, remaining)
121
+ s = line.rstrip("\n")
122
+ if DELIM_RE.fullmatch(s): return "".join(lines), s
123
+ lines.append(line)
124
+
125
+ def _read_pty_rawline(proc, timeout):
126
+ buf = proc._pty_buffer
127
+ while b"\n" not in buf:
128
+ ready, _, _ = select.select([proc._pty_master], [], [], timeout)
129
+ if not ready: raise AssertionError(f"timed out waiting for clikernel pty output{_failure_detail(proc)}")
130
+ chunk = os.read(proc._pty_master, 4096)
131
+ assert chunk, _failure_detail(proc)
132
+ buf.extend(chunk)
133
+ line, _, rest = buf.partition(b"\n")
134
+ proc._pty_buffer = bytearray(rest)
135
+ return bytes(line + b"\n")
136
+
137
+
138
+ def read_pty_raw_until_ready(proc, timeout=TIMEOUT):
139
+ body = bytearray()
140
+ deadline = time.monotonic() + timeout
141
+ while True:
142
+ remaining = deadline - time.monotonic()
143
+ if remaining <= 0: raise AssertionError(f"timed out waiting for ready delimiter{_failure_detail(proc)}")
144
+ line = _read_pty_rawline(proc, remaining)
145
+ s = line.rstrip(b"\r\n").decode("utf-8", "replace")
146
+ if DELIM_RE.fullmatch(s): return bytes(body), s, line
147
+ body.extend(line)
148
+
149
+
150
+ @pytest.fixture
151
+ def kernel(tmp_path):
152
+ proc = start_kernel(tmp_path)
153
+ try:
154
+ body, delim = read_until_ready(proc)
155
+ yield proc, body, delim
156
+ finally: stop_kernel(proc)
157
+
158
+
159
+ def test_startup_prints_valid_ready_delimiter(kernel):
160
+ _, body, delim = kernel
161
+ assert body == "please wait, loading...\nloading complete. first delimiter:\n"
162
+ assert DELIM_RE.fullmatch(delim)
163
+
164
+
165
+ def test_single_line_request_returns_result_and_same_delimiter(kernel):
166
+ proc, _, delim = kernel
167
+ body, next_delim = send(proc, "1+1\n")
168
+ assert body == "2\n"
169
+ assert DELIM_RE.fullmatch(next_delim)
170
+ assert next_delim == delim
171
+
172
+
173
+ def test_tty_input_is_not_echoed_after_startup(tmp_path):
174
+ proc = start_kernel_pty(tmp_path)
175
+ try:
176
+ body, delim = read_pty_until_ready(proc)
177
+ assert body == "please wait, loading...\nloading complete. first delimiter:\n"
178
+ os.write(proc._pty_master, b"1+1\n")
179
+ assert _read_ptyline(proc, TIMEOUT) == ".\n"
180
+ body, next_delim = read_pty_until_ready(proc)
181
+ assert body == "2\n"
182
+ assert next_delim == delim
183
+ finally: stop_kernel(proc)
184
+
185
+ def test_tty_output_keeps_newlines_as_lf(tmp_path):
186
+ proc = start_kernel_pty(tmp_path)
187
+ try:
188
+ _, delim = read_pty_until_ready(proc)
189
+ os.write(proc._pty_master, b"print('hello')\n")
190
+ body, next_delim, raw_delim = read_pty_raw_until_ready(proc)
191
+ assert body == b".\nhello\n"
192
+ assert raw_delim == next_delim.encode() + b"\n"
193
+ assert b"\r" not in body + raw_delim
194
+ assert next_delim == delim
195
+ finally: stop_kernel(proc)
196
+
197
+
198
+ def test_request_ack_is_written_before_result(kernel):
199
+ proc, _, delim = kernel
200
+ assert proc.stdin is not None
201
+ proc.stdin.write(b"1+1\n")
202
+ proc.stdin.flush()
203
+ assert _readline(proc, TIMEOUT) == ".\n"
204
+ body, next_delim = read_until_ready(proc)
205
+ assert body == "2\n"
206
+ assert next_delim == delim
207
+
208
+
209
+ def test_state_persists_across_requests(kernel):
210
+ proc, _, _ = kernel
211
+ body, _ = send(proc, "x=41\n")
212
+ assert body == ""
213
+ body, _ = send(proc, "x+1\n")
214
+ assert body == "42\n"
215
+
216
+
217
+ def test_multiline_request_uses_current_delimiter(kernel):
218
+ proc, _, delim = kernel
219
+ body, _ = send(proc, f"--\ndef f(x):\n return x + 1\n\nf(41)\n{delim}\n")
220
+ assert body == "42\n"
221
+
222
+
223
+ def test_multiple_outputs_use_xmlish_blocks(kernel):
224
+ proc, _, delim = kernel
225
+ code = (
226
+ "--\n"
227
+ "from IPython.display import Markdown, display\n"
228
+ "print('hello')\n"
229
+ "display(Markdown('**shown**'))\n"
230
+ "42\n"
231
+ f"{delim}\n")
232
+ body, _ = send(proc, code)
233
+ assert '<stdout>\nhello\n</stdout>\n' in body
234
+ assert '<display_data mime="text/markdown">\n**shown**\n</display_data>\n' in body
235
+ assert '<execute_result>\n42\n</execute_result>\n' in body
236
+
237
+
238
+ def test_runtime_errors_return_error_text_and_same_delimiter(kernel):
239
+ proc, _, delim = kernel
240
+ body, next_delim = send(proc, "1/0\n")
241
+ assert "ZeroDivisionError" in body
242
+ assert DELIM_RE.fullmatch(next_delim)
243
+ assert next_delim == delim
244
+
245
+
246
+ @pytest.mark.parametrize("cmd", ["exit()", "quit()"])
247
+ def test_exit_request_returns_final_delimiter_and_stops_process(kernel, cmd):
248
+ proc, _, start_delim = kernel
249
+ body, delim = send(proc, f"{cmd}\n")
250
+ assert body == ""
251
+ assert DELIM_RE.fullmatch(delim)
252
+ assert delim == start_delim
253
+ proc.wait(timeout=TIMEOUT)
254
+ assert proc.returncode == 0
255
+
256
+
257
+ def test_history_is_disabled(kernel):
258
+ proc, _, _ = kernel
259
+ body, _ = send(proc, "get_ipython().history_manager.enabled\n")
260
+ assert body == "False\n"
@@ -1,6 +0,0 @@
1
- <!-- do not remove -->
2
-
3
- ## 0.1.0
4
-
5
- - Initial release
6
-
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
@@ -1,142 +0,0 @@
1
- import os,re,select,shutil,subprocess,tempfile,time
2
-
3
- import pytest
4
-
5
- DELIM_RE = re.compile(r"--[A-Za-z0-9]{5}")
6
- TIMEOUT = 5
7
-
8
-
9
- def _failure_detail(proc):
10
- if proc.stderr is None: return ""
11
- ready, _, _ = select.select([proc.stderr], [], [], 0)
12
- if not ready: return ""
13
- err = os.read(proc.stderr.fileno(), 65536).decode("utf-8", "replace")
14
- return f"\nstderr:\n{err}" if err else ""
15
-
16
-
17
- def _readline(proc, timeout):
18
- assert proc.stdout is not None
19
- buf = proc._stdout_buffer
20
- while b"\n" not in buf:
21
- ready, _, _ = select.select([proc.stdout], [], [], timeout)
22
- if not ready: raise AssertionError(f"timed out waiting for clikernel output{_failure_detail(proc)}")
23
- chunk = os.read(proc.stdout.fileno(), 4096)
24
- assert chunk, _failure_detail(proc)
25
- buf.extend(chunk)
26
- line, _, rest = buf.partition(b"\n")
27
- proc._stdout_buffer = bytearray(rest)
28
- return line.decode("utf-8", "replace") + "\n"
29
-
30
-
31
- def read_until_ready(proc, timeout=TIMEOUT):
32
- lines = []
33
- deadline = time.monotonic() + timeout
34
- while True:
35
- remaining = deadline - time.monotonic()
36
- if remaining <= 0: raise AssertionError(f"timed out waiting for ready delimiter{_failure_detail(proc)}")
37
- line = _readline(proc, remaining)
38
- s = line.rstrip("\n")
39
- if DELIM_RE.fullmatch(s): return "".join(lines), s
40
- lines.append(line)
41
-
42
-
43
- def start_kernel(tmp_path):
44
- cmd = shutil.which("clikernel")
45
- assert cmd, "clikernel console script is not on PATH"
46
- env = os.environ.copy()
47
- state = os.path.join(tempfile.gettempdir(), f"clikernel-{os.getuid()}")
48
- env["CLIKERNEL_STATE_DIR"] = str(tmp_path / "state")
49
- env.setdefault("IPYTHONDIR", os.path.join(state, "ipython"))
50
- env.setdefault("MPLCONFIGDIR", os.path.join(state, "matplotlib"))
51
- env.setdefault("MPLBACKEND", "Agg")
52
- env["PYTHONUNBUFFERED"] = "1"
53
- proc = subprocess.Popen([cmd], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
54
- proc._stdout_buffer = bytearray()
55
- return proc
56
-
57
-
58
- def stop_kernel(proc):
59
- if proc.stdin is not None:
60
- try: proc.stdin.close()
61
- except OSError: pass
62
- if proc.poll() is None:
63
- try: proc.wait(timeout=1)
64
- except subprocess.TimeoutExpired:
65
- proc.terminate()
66
- try: proc.wait(timeout=1)
67
- except subprocess.TimeoutExpired:
68
- proc.kill()
69
- proc.wait(timeout=1)
70
-
71
-
72
- def send(proc, text, timeout=TIMEOUT):
73
- assert proc.stdin is not None
74
- proc.stdin.write(text.encode("utf-8"))
75
- proc.stdin.flush()
76
- return read_until_ready(proc, timeout)
77
-
78
-
79
- @pytest.fixture
80
- def kernel(tmp_path):
81
- proc = start_kernel(tmp_path)
82
- try:
83
- body, delim = read_until_ready(proc)
84
- yield proc, body, delim
85
- finally: stop_kernel(proc)
86
-
87
-
88
- def test_startup_prints_valid_ready_delimiter(kernel):
89
- _, body, delim = kernel
90
- assert body == "please wait, loading...\nloading complete. first delimiter:\n"
91
- assert DELIM_RE.fullmatch(delim)
92
-
93
-
94
- def test_single_line_request_returns_result_and_fresh_delimiter(kernel):
95
- proc, _, delim = kernel
96
- body, next_delim = send(proc, "1+1\n")
97
- assert body == "2\n"
98
- assert DELIM_RE.fullmatch(next_delim)
99
- assert next_delim != delim
100
-
101
-
102
- def test_state_persists_across_requests(kernel):
103
- proc, _, _ = kernel
104
- body, _ = send(proc, "x=41\n")
105
- assert body == ""
106
- body, _ = send(proc, "x+1\n")
107
- assert body == "42\n"
108
-
109
-
110
- def test_multiline_request_uses_current_delimiter(kernel):
111
- proc, _, delim = kernel
112
- body, _ = send(proc, f"--\ndef f(x):\n return x + 1\n\nf(41)\n{delim}\n")
113
- assert body == "42\n"
114
-
115
-
116
- def test_multiple_outputs_use_xmlish_blocks(kernel):
117
- proc, _, delim = kernel
118
- code = (
119
- "--\n"
120
- "from IPython.display import Markdown, display\n"
121
- "print('hello')\n"
122
- "display(Markdown('**shown**'))\n"
123
- "42\n"
124
- f"{delim}\n")
125
- body, _ = send(proc, code)
126
- assert '<stdout>\nhello\n</stdout>\n' in body
127
- assert '<display_data mime="text/markdown">\n**shown**\n</display_data>\n' in body
128
- assert '<execute_result>\n42\n</execute_result>\n' in body
129
-
130
-
131
- def test_runtime_errors_return_error_text_and_fresh_delimiter(kernel):
132
- proc, _, delim = kernel
133
- body, next_delim = send(proc, "1/0\n")
134
- assert "ZeroDivisionError" in body
135
- assert DELIM_RE.fullmatch(next_delim)
136
- assert next_delim != delim
137
-
138
-
139
- def test_history_is_disabled(kernel):
140
- proc, _, _ = kernel
141
- body, _ = send(proc, "get_ipython().history_manager.enabled\n")
142
- assert body == "False\n"
File without changes
File without changes
File without changes
File without changes