de-shell 0.2.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {de_shell-0.2.1 → de_shell-0.4.0}/PKG-INFO +24 -8
  2. {de_shell-0.2.1 → de_shell-0.4.0}/README.md +21 -5
  3. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/__init__.py +1 -1
  4. de_shell-0.4.0/de_shell/js/main/framing.test.ts +125 -0
  5. de_shell-0.4.0/de_shell/js/main/framing.ts +34 -0
  6. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/index.ts +4 -0
  7. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/pythonEnv.test.ts +16 -4
  8. de_shell-0.4.0/de_shell/js/main/relay.test.ts +815 -0
  9. de_shell-0.4.0/de_shell/js/main/relay.ts +258 -0
  10. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/renderer/FigureFrame.tsx +15 -2
  11. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/renderer/figureBridge.test.ts +29 -1
  12. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/renderer/figureBridge.ts +30 -0
  13. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/renderer/index.ts +1 -1
  14. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/testing/harness.cjs +35 -20
  15. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/testing/harness.test.cjs +30 -0
  16. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/plotting/figure.py +80 -1
  17. de_shell-0.4.0/de_shell/remote_client.py +199 -0
  18. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell.egg-info/PKG-INFO +24 -8
  19. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell.egg-info/SOURCES.txt +6 -0
  20. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell.egg-info/requires.txt +1 -1
  21. {de_shell-0.2.1 → de_shell-0.4.0}/pyproject.toml +70 -2
  22. {de_shell-0.2.1 → de_shell-0.4.0}/tests/test_boundary.py +1 -0
  23. {de_shell-0.2.1 → de_shell-0.4.0}/tests/test_figure.py +82 -1
  24. de_shell-0.4.0/tests/test_remote_client.py +408 -0
  25. {de_shell-0.2.1 → de_shell-0.4.0}/LICENSE +0 -0
  26. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/actions/__init__.py +0 -0
  27. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/actions/context.py +0 -0
  28. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/actions/figure_registry.py +0 -0
  29. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/actions/lifecycle.py +0 -0
  30. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/actions/registry.py +0 -0
  31. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/actions/wizard.py +0 -0
  32. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/app.py +0 -0
  33. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/compute.py +0 -0
  34. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/debug_flags.py +0 -0
  35. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/ipc.py +0 -0
  36. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/__init__.py +0 -0
  37. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/__main__.py +0 -0
  38. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/backendProcess.test.ts +0 -0
  39. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/backendProcess.ts +0 -0
  40. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/config.ts +0 -0
  41. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/dialogs.ts +0 -0
  42. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/envProgress.ts +0 -0
  43. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/errorReport.ts +0 -0
  44. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/problemLog.ts +0 -0
  45. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/pythonEnv.ts +0 -0
  46. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/sentryEnvelope.test.ts +0 -0
  47. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/sentryEnvelope.ts +0 -0
  48. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/stdoutDemux.test.ts +0 -0
  49. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/stdoutDemux.ts +0 -0
  50. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/updater.ts +0 -0
  51. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/updaterErrors.test.ts +0 -0
  52. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/updaterErrors.ts +0 -0
  53. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/main/window.ts +0 -0
  54. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/package.json +0 -0
  55. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/preload/index.ts +0 -0
  56. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/renderer/figureBridge.react.ts +0 -0
  57. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/renderer/protocol.ts +0 -0
  58. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/renderer/shellState.test.ts +0 -0
  59. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/renderer/shellState.ts +0 -0
  60. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/renderer/sizeReporter.test.ts +0 -0
  61. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/js/renderer/sizeReporter.ts +0 -0
  62. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/log_stream.py +0 -0
  63. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/plotting/__init__.py +0 -0
  64. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/plotting/colormaps.py +0 -0
  65. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/plotting/selectors/__init__.py +0 -0
  66. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/plotting/selectors/utils.py +0 -0
  67. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/plotting/stream.py +0 -0
  68. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/process_guard.py +0 -0
  69. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/session.py +0 -0
  70. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/testing/__init__.py +0 -0
  71. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell/timing.py +0 -0
  72. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell.egg-info/dependency_links.txt +0 -0
  73. {de_shell-0.2.1 → de_shell-0.4.0}/de_shell.egg-info/top_level.txt +0 -0
  74. {de_shell-0.2.1 → de_shell-0.4.0}/setup.cfg +0 -0
  75. {de_shell-0.2.1 → de_shell-0.4.0}/tests/test_actions.py +0 -0
  76. {de_shell-0.2.1 → de_shell-0.4.0}/tests/test_session_base.py +0 -0
  77. {de_shell-0.2.1 → de_shell-0.4.0}/tests/test_stream.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: de-shell
3
- Version: 0.2.1
3
+ Version: 0.4.0
4
4
  Summary: The Electron + Python desktop-app shell Direct Electron's apps (SpyDE, Ground Crew, Autopilot) are assembled from
5
5
  Author-email: Carter Francis <cartsfrancis@gmail.com>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/directelectron/de-shell
8
8
  Project-URL: Repository, https://github.com/directelectron/de-shell
9
9
  Project-URL: Issues, https://github.com/directelectron/de-shell/issues
10
- Project-URL: Changelog, https://github.com/directelectron/de-shell/blob/main/CHANGELOG.md
10
+ Project-URL: Changelog, https://github.com/directelectron/de-shell/blob/main/CHANGELOG.rst
11
11
  Keywords: electron,desktop,anyplotlib,electron-microscopy,direct-electron
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Intended Audience :: Developers
@@ -26,7 +26,7 @@ Requires-Python: >=3.10
26
26
  Description-Content-Type: text/markdown
27
27
  License-File: LICENSE
28
28
  Requires-Dist: numpy>=1.20.0
29
- Requires-Dist: anyplotlib>=0.8.0
29
+ Requires-Dist: anyplotlib>=0.10.0
30
30
  Requires-Dist: pyyaml
31
31
  Provides-Extra: tests
32
32
  Requires-Dist: pytest>=3.6; extra == "tests"
@@ -122,9 +122,10 @@ peer dependencies — react, electron, electron-updater, @playwright/test —
122
122
  are the app's to declare; every app already does. The e2e specs take the
123
123
  harness from `shell/testing/harness.cjs`.
124
124
 
125
- Autopilot and SpyDE ([directelectron/spyde#152](https://github.com/directelectron/spyde/pull/152))
126
- are wired this way, pinned `de-shell>=0.2,<0.3`; Ground Crew still carries a
127
- copy under its `packages/` and is next.
125
+ All three apps are wired this way: Autopilot, SpyDE
126
+ ([directelectron/spyde#152](https://github.com/directelectron/spyde/pull/152))
127
+ and Ground Crew ([directelectron/de_ground_crew#183](https://github.com/directelectron/de_ground_crew/pull/183)),
128
+ pinned to the `0.2` line.
128
129
 
129
130
  ## Developing it
130
131
 
@@ -149,8 +150,20 @@ tests, and builds the wheel and checks what it carries.
149
150
 
150
151
  The version is written once, in `de_shell/__init__.py`. To release:
151
152
 
152
- 1. Bump `__version__`, move the `CHANGELOG.md` entries under the new version.
153
- 2. Commit, tag `vX.Y.Z`, push the tag.
153
+ 1. Run **Prepare Release** from the Actions tab and pick the bump (`minor`,
154
+ `bugfix`, `major`, `pre-release`, or `finalize` to drop a `bN` suffix). It
155
+ bumps `__version__`, assembles `CHANGELOG.rst` from the news fragments in
156
+ `upcoming_changes/`, runs the pre-flight checks, and opens a release PR that
157
+ names the one tag that will pass.
158
+ 2. Review and merge that PR, then tag the merge commit and push the tag — the
159
+ PR body has the exact commands.
160
+
161
+ The tag has to match `__version__` exactly; going through the workflow makes
162
+ them agree by construction, because the PR *is* the bump. To assemble the
163
+ changelog by hand instead, `uv tool run towncrier build --version X.Y.Z` does
164
+ the same thing — but stage `upcoming_changes/` with `git add -A`, because
165
+ towncrier deletes the fragments it consumed and a plain `git add CHANGELOG.rst`
166
+ leaves the deletions behind for the next release to re-publish.
154
167
 
155
168
  `.github/workflows/publish.yml` builds the distributions, refuses a tag that
156
169
  does not match `__version__`, and uploads to PyPI through trusted publishing
@@ -190,6 +203,9 @@ SpyDE commit the app copies were taken from:
190
203
  * **The protocol is the contract.** `PLOTAPP:` JSON lines and `PLOTBIN:`
191
204
  binary frames over the sidecar's stdio. Both halves of it live in this one
192
205
  package on purpose; keep it that way.
206
+ * **Every pull request carries its own changelog entry**, as a news fragment
207
+ under [`upcoming_changes/`](upcoming_changes/README.rst) — one file per PR,
208
+ so two of them never conflict over the same lines of `CHANGELOG.rst`.
193
209
  * **LF line endings**, enforced by `.gitattributes`.
194
210
 
195
211
  ## License
@@ -87,9 +87,10 @@ peer dependencies — react, electron, electron-updater, @playwright/test —
87
87
  are the app's to declare; every app already does. The e2e specs take the
88
88
  harness from `shell/testing/harness.cjs`.
89
89
 
90
- Autopilot and SpyDE ([directelectron/spyde#152](https://github.com/directelectron/spyde/pull/152))
91
- are wired this way, pinned `de-shell>=0.2,<0.3`; Ground Crew still carries a
92
- copy under its `packages/` and is next.
90
+ All three apps are wired this way: Autopilot, SpyDE
91
+ ([directelectron/spyde#152](https://github.com/directelectron/spyde/pull/152))
92
+ and Ground Crew ([directelectron/de_ground_crew#183](https://github.com/directelectron/de_ground_crew/pull/183)),
93
+ pinned to the `0.2` line.
93
94
 
94
95
  ## Developing it
95
96
 
@@ -114,8 +115,20 @@ tests, and builds the wheel and checks what it carries.
114
115
 
115
116
  The version is written once, in `de_shell/__init__.py`. To release:
116
117
 
117
- 1. Bump `__version__`, move the `CHANGELOG.md` entries under the new version.
118
- 2. Commit, tag `vX.Y.Z`, push the tag.
118
+ 1. Run **Prepare Release** from the Actions tab and pick the bump (`minor`,
119
+ `bugfix`, `major`, `pre-release`, or `finalize` to drop a `bN` suffix). It
120
+ bumps `__version__`, assembles `CHANGELOG.rst` from the news fragments in
121
+ `upcoming_changes/`, runs the pre-flight checks, and opens a release PR that
122
+ names the one tag that will pass.
123
+ 2. Review and merge that PR, then tag the merge commit and push the tag — the
124
+ PR body has the exact commands.
125
+
126
+ The tag has to match `__version__` exactly; going through the workflow makes
127
+ them agree by construction, because the PR *is* the bump. To assemble the
128
+ changelog by hand instead, `uv tool run towncrier build --version X.Y.Z` does
129
+ the same thing — but stage `upcoming_changes/` with `git add -A`, because
130
+ towncrier deletes the fragments it consumed and a plain `git add CHANGELOG.rst`
131
+ leaves the deletions behind for the next release to re-publish.
119
132
 
120
133
  `.github/workflows/publish.yml` builds the distributions, refuses a tag that
121
134
  does not match `__version__`, and uploads to PyPI through trusted publishing
@@ -155,6 +168,9 @@ SpyDE commit the app copies were taken from:
155
168
  * **The protocol is the contract.** `PLOTAPP:` JSON lines and `PLOTBIN:`
156
169
  binary frames over the sidecar's stdio. Both halves of it live in this one
157
170
  package on purpose; keep it that way.
171
+ * **Every pull request carries its own changelog entry**, as a news fragment
172
+ under [`upcoming_changes/`](upcoming_changes/README.rst) — one file per PR,
173
+ so two of them never conflict over the same lines of `CHANGELOG.rst`.
158
174
  * **LF line endings**, enforced by `.gitattributes`.
159
175
 
160
176
  ## License
@@ -20,6 +20,6 @@ from __future__ import annotations
20
20
 
21
21
  #: The package version — the one place it is written. pyproject.toml reads it
22
22
  #: (dynamic version) and the release workflow refuses a tag that disagrees.
23
- __version__ = "0.2.1"
23
+ __version__ = "0.4.0"
24
24
 
25
25
  __all__ = ["ipc", "log_stream", "process_guard", "debug_flags", "compute"]
@@ -0,0 +1,125 @@
1
+ /**
2
+ * framing.test.ts — the encoders against the existing demuxer: whatever
3
+ * framing.ts writes, stdoutDemux.ts must read back unchanged, whole or split
4
+ * one byte at a time.
5
+ *
6
+ * Run: `node --test de_shell/js/main/framing.test.ts`, or via `npm run test:unit`.
7
+ */
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { encodeBinary, encodeMessage } from './framing.ts'
11
+ import { createStdoutDemux } from './stdoutDemux.ts'
12
+
13
+ type Event =
14
+ | { kind: 'message'; msg: Record<string, unknown> }
15
+ | { kind: 'stream'; text: string }
16
+ | { kind: 'binary'; header: Record<string, unknown>; payload: string }
17
+
18
+ /** Feed `stream` to a fresh demuxer in `step`-byte slices; return the event trace. */
19
+ function decode(stream: Buffer, step = stream.length || 1): Event[] {
20
+ const events: Event[] = []
21
+ const demux = createStdoutDemux({
22
+ onMessage: (msg) => events.push({ kind: 'message', msg }),
23
+ onStream: (text) => events.push({ kind: 'stream', text }),
24
+ onBinary: (header, payload) =>
25
+ events.push({ kind: 'binary', header, payload: payload.toString('hex') }),
26
+ })
27
+ for (let pos = 0; pos < stream.length; pos += step) {
28
+ demux.push(stream.subarray(pos, pos + step))
29
+ }
30
+ return events
31
+ }
32
+
33
+ /** A whole binary frame as it goes on the wire: prefix, header, then the caller's payload. */
34
+ function frame(header: Record<string, unknown>, payload: Buffer): Buffer {
35
+ return Buffer.concat([...encodeBinary(header, payload), payload])
36
+ }
37
+
38
+ function patternPayload(n: number): Buffer {
39
+ const p = Buffer.allocUnsafe(n)
40
+ for (let i = 0; i < n; i++) p[i] = i & 0xff // includes 0x0a bytes
41
+ return p
42
+ }
43
+
44
+ test('a message is one PLOTAPP line and decodes to the same object', () => {
45
+ const msg = { type: 'state_update', key: 'clim', value: [0, 255], nested: { ok: true, none: null } }
46
+ const bytes = encodeMessage(msg)
47
+ assert.equal(bytes.subarray(0, 8).toString('ascii'), 'PLOTAPP:')
48
+ assert.equal(bytes.indexOf(0x0a), bytes.length - 1, 'exactly one newline, at the end')
49
+ assert.deepEqual(decode(bytes), [{ kind: 'message', msg }])
50
+ })
51
+
52
+ test('a newline inside a string stays escaped, so the message is still one line', () => {
53
+ const msg = { type: 'status', text: 'line one\nline two\r\n' }
54
+ const bytes = encodeMessage(msg)
55
+ assert.equal(bytes.indexOf(0x0a), bytes.length - 1)
56
+ assert.deepEqual(decode(bytes), [{ kind: 'message', msg }])
57
+ })
58
+
59
+ test('a frame with an empty payload decodes with an empty payload', () => {
60
+ assert.deepEqual(decode(frame({ fig_id: 'f3', key: 'spec' }, Buffer.alloc(0))), [
61
+ { kind: 'binary', header: { fig_id: 'f3', key: 'spec' }, payload: '' },
62
+ ])
63
+ })
64
+
65
+ test('a payload holding newlines and PLOTBIN:/PLOTAPP: lookalikes decodes whole, at any chunking', () => {
66
+ const nasty = Buffer.concat([
67
+ Buffer.from('\nPLOTBIN:9:9\nPLOTAPP:{}\n', 'ascii'),
68
+ patternPayload(3000),
69
+ ])
70
+ const stream = Buffer.concat([
71
+ frame({ fig_id: 'f1', key: 'image' }, nasty),
72
+ encodeMessage({ type: 'done' }),
73
+ ])
74
+ const expected: Event[] = [
75
+ { kind: 'binary', header: { fig_id: 'f1', key: 'image' }, payload: nasty.toString('hex') },
76
+ { kind: 'message', msg: { type: 'done' } },
77
+ ]
78
+ for (const step of [stream.length, 65536, 7, 1]) {
79
+ assert.deepEqual(decode(stream, step), expected, `diverged at ${step}-byte chunks`)
80
+ }
81
+ })
82
+
83
+ test('a non-ASCII header: hlen counts UTF-8 bytes, not characters', () => {
84
+ const header = { fig_id: 'f', key: 'k', label: 'εxx Å' }
85
+ const [prefix, head] = encodeBinary(header, Buffer.from([1, 2, 3]))
86
+ const json = JSON.stringify(header)
87
+ assert.notEqual(Buffer.byteLength(json, 'utf8'), json.length, 'the fixture must be multi-byte')
88
+ assert.equal(prefix.toString('ascii'), `PLOTBIN:${Buffer.byteLength(json, 'utf8')}:3\n`)
89
+ assert.equal(head.toString('utf8'), json)
90
+ assert.deepEqual(decode(frame(header, Buffer.from([1, 2, 3])), 1), [
91
+ { kind: 'binary', header, payload: '010203' },
92
+ ])
93
+ })
94
+
95
+ test('encodeBinary returns the prefix and header only; the payload is never copied', () => {
96
+ const payload = patternPayload(64)
97
+ const parts = encodeBinary({ fig_id: 'f', key: 'k' }, payload)
98
+ assert.equal(parts.length, 2)
99
+ assert.ok(!parts.includes(payload))
100
+ assert.match(parts[0].toString('ascii'), /^PLOTBIN:\d+:64\n$/)
101
+ })
102
+
103
+ test('the layout is the documented wire format, byte for byte', () => {
104
+ // PLOTBIN:<hlen>:<plen>\n<header json><payload>, as anyplotlib's
105
+ // _binary_frame.encode_frame writes it and stdoutDemux.ts parses it.
106
+ const header = { fig_id: 'f', key: 'k', dims: [2, 2] }
107
+ const payload = Buffer.from([0, 10, 255, 10])
108
+ const json = JSON.stringify(header)
109
+ assert.deepEqual(frame(header, payload), Buffer.concat([
110
+ Buffer.from(`PLOTBIN:${Buffer.byteLength(json)}:4\n${json}`, 'utf8'),
111
+ payload,
112
+ ]))
113
+ assert.deepEqual(encodeMessage({ type: 'done' }), Buffer.from('PLOTAPP:{"type":"done"}\n', 'utf8'))
114
+ })
115
+
116
+ test('a non-finite number is refused rather than framed', () => {
117
+ for (const bad of [NaN, Infinity, -Infinity]) {
118
+ assert.throws(() => encodeMessage({ type: 'fit', value: bad }), RangeError)
119
+ assert.throws(
120
+ () => encodeBinary({ fig_id: 'f', key: 'k', clim: [0, bad] }, Buffer.alloc(0)),
121
+ RangeError,
122
+ )
123
+ }
124
+ assert.throws(() => encodeMessage({ a: { b: [1, NaN] } }), /non-finite/)
125
+ })
@@ -0,0 +1,34 @@
1
+ /**
2
+ * framing.ts — encoders for the backend wire format; the inverse of
3
+ * stdoutDemux.ts.
4
+ *
5
+ * PLOTAPP:<json>\n a message
6
+ * PLOTBIN:<hlen>:<plen>\n<header json><payload> a binary frame
7
+ *
8
+ * The layout ipc.emit and anyplotlib's _binary_frame.encode_frame write on the
9
+ * Python side. hlen and plen are byte counts. The JSON text is JSON.stringify's
10
+ * own (compact, raw UTF-8); every decoder of the format accepts it. Imports
11
+ * nothing.
12
+ */
13
+
14
+ /** JSON.stringify replacer: a non-finite number is not JSON, so refuse it rather than emit null or NaN. */
15
+ function refuseNonFinite(key: string, value: unknown): unknown {
16
+ if (typeof value === 'number' && !Number.isFinite(value)) {
17
+ throw new RangeError(`cannot frame the non-finite number ${value}${key ? ` at key "${key}"` : ''}`)
18
+ }
19
+ return value
20
+ }
21
+
22
+ /** `PLOTAPP:<json>\n`. JSON escapes every newline inside a string, so the message is one line. */
23
+ export function encodeMessage(obj: Record<string, unknown>): Buffer {
24
+ return Buffer.from(`PLOTAPP:${JSON.stringify(obj, refuseNonFinite)}\n`, 'utf8')
25
+ }
26
+
27
+ /**
28
+ * The prefix line and the header bytes of a PLOTBIN frame. The payload is the
29
+ * caller's Buffer, written as its own chunk after these two, never copied.
30
+ */
31
+ export function encodeBinary(header: Record<string, unknown>, payload: Buffer): [Buffer, Buffer] {
32
+ const head = Buffer.from(JSON.stringify(header, refuseNonFinite), 'utf8')
33
+ return [Buffer.from(`PLOTBIN:${head.length}:${payload.length}\n`, 'ascii'), head]
34
+ }
@@ -22,6 +22,10 @@ export {
22
22
  export { recordProblem, recordedProblems } from './problemLog'
23
23
  export type { Problem } from './problemLog'
24
24
 
25
+ export { createRelay } from './relay'
26
+ export type { Relay, RelayConnection, RelayCloseReason, RelayOptions } from './relay'
27
+ export { encodeMessage, encodeBinary } from './framing'
28
+
25
29
  export {
26
30
  initErrorReporting, reportingConfigured, collectDiagnostics, submitReport,
27
31
  } from './errorReport'
@@ -10,9 +10,9 @@
10
10
  * Run: `node --test src/pythonEnv.test.ts` (from packages/shell-main/), or via
11
11
  * the `test:unit` npm script.
12
12
  */
13
- import { test } from 'node:test'
13
+ import { test, after } from 'node:test'
14
14
  import assert from 'node:assert/strict'
15
- import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'
15
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'
16
16
  import { join } from 'path'
17
17
  import { tmpdir } from 'os'
18
18
  import { configureShell } from './config.ts'
@@ -24,17 +24,29 @@ configureShell({
24
24
  pythonModule: 'testapp',
25
25
  })
26
26
 
27
+ /** Every temp directory these tests make, removed when the file is done. */
28
+ const tempDirs: string[] = []
29
+ after(() => {
30
+ for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true })
31
+ })
32
+
33
+ function tempDir(prefix: string): string {
34
+ const dir = mkdtempSync(join(tmpdir(), prefix))
35
+ tempDirs.push(dir)
36
+ return dir
37
+ }
38
+
27
39
  /** A directory containing a uv stub under both spellings, so these tests do not
28
40
  * fork on the host platform. */
29
41
  function dirWithUv(): string {
30
- const dir = mkdtempSync(join(tmpdir(), 'uv-stub-'))
42
+ const dir = tempDir('uv-stub-')
31
43
  writeFileSync(join(dir, 'uv'), '')
32
44
  writeFileSync(join(dir, 'uv.exe'), '')
33
45
  return dir
34
46
  }
35
47
 
36
48
  function emptyDir(): string {
37
- return mkdtempSync(join(tmpdir(), 'uv-none-'))
49
+ return tempDir('uv-none-')
38
50
  }
39
51
 
40
52
  /** An env whose PATH and every fallback root point somewhere we control. */