de-shell 0.2.0__py3-none-any.whl
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.
- de_shell/__init__.py +25 -0
- de_shell/actions/__init__.py +0 -0
- de_shell/actions/context.py +62 -0
- de_shell/actions/figure_registry.py +53 -0
- de_shell/actions/lifecycle.py +295 -0
- de_shell/actions/registry.py +141 -0
- de_shell/actions/wizard.py +115 -0
- de_shell/app.py +170 -0
- de_shell/compute.py +103 -0
- de_shell/debug_flags.py +69 -0
- de_shell/ipc.py +236 -0
- de_shell/js/__init__.py +38 -0
- de_shell/js/__main__.py +4 -0
- de_shell/js/main/backendProcess.test.ts +70 -0
- de_shell/js/main/backendProcess.ts +330 -0
- de_shell/js/main/config.ts +53 -0
- de_shell/js/main/dialogs.ts +62 -0
- de_shell/js/main/envProgress.ts +126 -0
- de_shell/js/main/errorReport.ts +261 -0
- de_shell/js/main/index.ts +57 -0
- de_shell/js/main/problemLog.ts +53 -0
- de_shell/js/main/pythonEnv.test.ts +125 -0
- de_shell/js/main/pythonEnv.ts +442 -0
- de_shell/js/main/sentryEnvelope.test.ts +94 -0
- de_shell/js/main/sentryEnvelope.ts +100 -0
- de_shell/js/main/updater.ts +322 -0
- de_shell/js/main/updaterErrors.test.ts +111 -0
- de_shell/js/main/updaterErrors.ts +65 -0
- de_shell/js/main/window.ts +141 -0
- de_shell/js/package.json +5 -0
- de_shell/js/preload/index.ts +130 -0
- de_shell/js/renderer/FigureFrame.tsx +88 -0
- de_shell/js/renderer/figureBridge.react.ts +58 -0
- de_shell/js/renderer/figureBridge.test.ts +184 -0
- de_shell/js/renderer/figureBridge.ts +169 -0
- de_shell/js/renderer/index.ts +34 -0
- de_shell/js/renderer/protocol.ts +164 -0
- de_shell/js/renderer/shellState.test.ts +193 -0
- de_shell/js/renderer/shellState.ts +310 -0
- de_shell/js/testing/harness.cjs +244 -0
- de_shell/js/testing/harness.test.cjs +73 -0
- de_shell/log_stream.py +185 -0
- de_shell/plotting/__init__.py +0 -0
- de_shell/plotting/colormaps.py +27 -0
- de_shell/plotting/figure.py +601 -0
- de_shell/plotting/selectors/__init__.py +0 -0
- de_shell/plotting/selectors/utils.py +29 -0
- de_shell/plotting/stream.py +172 -0
- de_shell/process_guard.py +190 -0
- de_shell/session.py +211 -0
- de_shell/testing/__init__.py +0 -0
- de_shell/timing.py +28 -0
- de_shell-0.2.0.dist-info/METADATA +196 -0
- de_shell-0.2.0.dist-info/RECORD +57 -0
- de_shell-0.2.0.dist-info/WHEEL +5 -0
- de_shell-0.2.0.dist-info/licenses/LICENSE +21 -0
- de_shell-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pythonEnv.test.ts — node:test unit tests for uv resolution.
|
|
3
|
+
*
|
|
4
|
+
* The dev-mode backend command used to be a bare 'uv', resolved by the OS at
|
|
5
|
+
* spawn time — so a uv that is installed but not on the app's PATH (the winget
|
|
6
|
+
* per-user install under an npm-run environment, the recurring case) failed as
|
|
7
|
+
* an opaque `spawn uv ENOENT`. `findUv` resolves uv to an absolute path up
|
|
8
|
+
* front: the PATH itself first, then the standard per-user install locations.
|
|
9
|
+
*
|
|
10
|
+
* Run: `node --test src/pythonEnv.test.ts` (from packages/shell-main/), or via
|
|
11
|
+
* the `test:unit` npm script.
|
|
12
|
+
*/
|
|
13
|
+
import { test } from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'
|
|
16
|
+
import { join } from 'path'
|
|
17
|
+
import { tmpdir } from 'os'
|
|
18
|
+
import { configureShell } from './config.ts'
|
|
19
|
+
import { findUv, resolvePythonEnv } from './pythonEnv.ts'
|
|
20
|
+
|
|
21
|
+
configureShell({
|
|
22
|
+
appId: 'testapp',
|
|
23
|
+
appName: 'Test App',
|
|
24
|
+
pythonModule: 'testapp',
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/** A directory containing a uv stub under both spellings, so these tests do not
|
|
28
|
+
* fork on the host platform. */
|
|
29
|
+
function dirWithUv(): string {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), 'uv-stub-'))
|
|
31
|
+
writeFileSync(join(dir, 'uv'), '')
|
|
32
|
+
writeFileSync(join(dir, 'uv.exe'), '')
|
|
33
|
+
return dir
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function emptyDir(): string {
|
|
37
|
+
return mkdtempSync(join(tmpdir(), 'uv-none-'))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** An env whose PATH and every fallback root point somewhere we control. */
|
|
41
|
+
function envWith(overrides: Record<string, string>): Record<string, string> {
|
|
42
|
+
const nowhere = emptyDir()
|
|
43
|
+
return {
|
|
44
|
+
PATH: '',
|
|
45
|
+
HOME: nowhere,
|
|
46
|
+
USERPROFILE: nowhere,
|
|
47
|
+
LOCALAPPDATA: nowhere,
|
|
48
|
+
...overrides,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test('findUv resolves uv from a PATH entry', () => {
|
|
53
|
+
const dir = dirWithUv()
|
|
54
|
+
const found = findUv(envWith({ PATH: dir }))
|
|
55
|
+
assert.ok(found, 'uv not found on PATH')
|
|
56
|
+
assert.ok(found.startsWith(dir), `${found} is not under ${dir}`)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('findUv falls back to the standard per-user install dirs off PATH', () => {
|
|
60
|
+
const home = emptyDir()
|
|
61
|
+
mkdirSync(join(home, '.local', 'bin'), { recursive: true })
|
|
62
|
+
writeFileSync(join(home, '.local', 'bin', 'uv'), '')
|
|
63
|
+
writeFileSync(join(home, '.local', 'bin', 'uv.exe'), '')
|
|
64
|
+
const found = findUv(envWith({ HOME: home, USERPROFILE: home }))
|
|
65
|
+
assert.ok(found, 'uv not found in ~/.local/bin')
|
|
66
|
+
assert.ok(found.startsWith(join(home, '.local', 'bin')))
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('findUv finds a winget-linked uv via LOCALAPPDATA', () => {
|
|
70
|
+
const localAppData = emptyDir()
|
|
71
|
+
const links = join(localAppData, 'Microsoft', 'WinGet', 'Links')
|
|
72
|
+
mkdirSync(links, { recursive: true })
|
|
73
|
+
writeFileSync(join(links, 'uv'), '')
|
|
74
|
+
writeFileSync(join(links, 'uv.exe'), '')
|
|
75
|
+
const found = findUv(envWith({ LOCALAPPDATA: localAppData }))
|
|
76
|
+
assert.ok(found, 'uv not found in the winget Links dir')
|
|
77
|
+
assert.ok(found.startsWith(links))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('findUv returns null when uv is nowhere', () => {
|
|
81
|
+
assert.equal(findUv(envWith({})), null)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('dev-mode resolvePythonEnv spawns the resolved absolute uv', async () => {
|
|
85
|
+
const dir = dirWithUv()
|
|
86
|
+
const saved = { PATH: process.env.PATH, HOME: process.env.HOME,
|
|
87
|
+
USERPROFILE: process.env.USERPROFILE, LOCALAPPDATA: process.env.LOCALAPPDATA }
|
|
88
|
+
try {
|
|
89
|
+
Object.assign(process.env, envWith({ PATH: dir }))
|
|
90
|
+
const resolved = await resolvePythonEnv({
|
|
91
|
+
isPackaged: false,
|
|
92
|
+
resourcesPath: emptyDir(),
|
|
93
|
+
projectRoot: emptyDir(),
|
|
94
|
+
userData: emptyDir(),
|
|
95
|
+
})
|
|
96
|
+
assert.ok(resolved.cmd[0].startsWith(dir),
|
|
97
|
+
`expected an absolute uv under ${dir}, got ${resolved.cmd[0]}`)
|
|
98
|
+
assert.deepEqual(resolved.cmd.slice(1), ['run', 'python', '-m', 'testapp'])
|
|
99
|
+
} finally {
|
|
100
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
101
|
+
if (v === undefined) delete process.env[k]
|
|
102
|
+
else process.env[k] = v
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('dev-mode resolvePythonEnv leaves a bare uv when none is found (the spawn trap reports it)', async () => {
|
|
108
|
+
const saved = { PATH: process.env.PATH, HOME: process.env.HOME,
|
|
109
|
+
USERPROFILE: process.env.USERPROFILE, LOCALAPPDATA: process.env.LOCALAPPDATA }
|
|
110
|
+
try {
|
|
111
|
+
Object.assign(process.env, envWith({}))
|
|
112
|
+
const resolved = await resolvePythonEnv({
|
|
113
|
+
isPackaged: false,
|
|
114
|
+
resourcesPath: emptyDir(),
|
|
115
|
+
projectRoot: emptyDir(),
|
|
116
|
+
userData: emptyDir(),
|
|
117
|
+
})
|
|
118
|
+
assert.equal(resolved.cmd[0], 'uv')
|
|
119
|
+
} finally {
|
|
120
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
121
|
+
if (v === undefined) delete process.env[k]
|
|
122
|
+
else process.env[k] = v
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
})
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pythonEnv.ts — resolve (and on first run, create) the Python sidecar env.
|
|
3
|
+
*
|
|
4
|
+
* The uv-managed distribution model: the installer ships a tiny
|
|
5
|
+
* payload — the bundled `uv`, the project (`pyproject.toml` + `uv.lock`) and the
|
|
6
|
+
* app's Python source — under <resources>/python. On first launch we run
|
|
7
|
+
* `uv sync` into a venv in the user's WRITABLE data dir (the app bundle is
|
|
8
|
+
* read-only / code-signed), so the GPU-correct torch wheel is fetched per
|
|
9
|
+
* machine and updates are a cheap incremental `uv sync`.
|
|
10
|
+
*
|
|
11
|
+
* torch is resolved PER MACHINE (win32/linux): the lock pins torch to the cu124
|
|
12
|
+
* index (the dev box's backend), which would force the cu124 wheel onto every
|
|
13
|
+
* user. Instead the first-run install is two-step:
|
|
14
|
+
* 1. `uv sync --frozen --no-dev --no-install-package torch` (lock-exact for
|
|
15
|
+
* everything EXCEPT torch)
|
|
16
|
+
* 2. `uv pip install torch==<locked release> --torch-backend=auto
|
|
17
|
+
* --python <env python>` (uv probes the machine's driver and picks the
|
|
18
|
+
* matching CUDA / CPU wheel — verified: `--python` is required, uv pip
|
|
19
|
+
* IGNORES UV_PROJECT_ENVIRONMENT; needs uv >= 0.5, the staged uv is 0.10.x)
|
|
20
|
+
* Any step-2 failure falls back to the plain full `uv sync` (the old cu124
|
|
21
|
+
* path), so the working install path can never regress. macOS keeps the plain
|
|
22
|
+
* sync (no CUDA pin there; PyPI torch already carries MPS).
|
|
23
|
+
*
|
|
24
|
+
* In development (no bundled payload) we fall back to `uv run` from the repo
|
|
25
|
+
* root — exactly the previous behaviour.
|
|
26
|
+
*/
|
|
27
|
+
import { spawn } from 'child_process'
|
|
28
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
29
|
+
import { createHash } from 'crypto'
|
|
30
|
+
import { join } from 'path'
|
|
31
|
+
// Extension spelled out so node:test can load this module without a bundler
|
|
32
|
+
// (native type-stripping resolves relative imports literally).
|
|
33
|
+
import { shellConfig } from './config.ts'
|
|
34
|
+
|
|
35
|
+
export interface ResolvedPython {
|
|
36
|
+
cmd: string[] // argv to spawn, e.g. [pythonExe, '-m', 'spyde'] (module from ShellConfig)
|
|
37
|
+
cwd: string // working directory for the spawn
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EnsureOptions {
|
|
41
|
+
isPackaged: boolean
|
|
42
|
+
resourcesPath: string // process.resourcesPath (packaged) — holds <…>/python
|
|
43
|
+
projectRoot: string // repo root (dev) — holds pyproject.toml
|
|
44
|
+
userData: string // app.getPath('userData') — writable venv lives here
|
|
45
|
+
onProgress?: (line: string) => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isWin = process.platform === 'win32'
|
|
49
|
+
|
|
50
|
+
export function venvPython(envDir: string): string {
|
|
51
|
+
return isWin ? join(envDir, 'Scripts', 'python.exe') : join(envDir, 'bin', 'python')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function uvBinaryName(): string {
|
|
55
|
+
return isWin ? 'uv.exe' : 'uv'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve uv to an ABSOLUTE path: each PATH entry first, then the standard
|
|
60
|
+
* per-user install locations that a spawned app's PATH routinely misses —
|
|
61
|
+
* winget's Links dir (win32), `~/.local/bin` (uv's own installer) and
|
|
62
|
+
* `~/.cargo/bin` (cargo installs). Returns null when uv is nowhere; the caller
|
|
63
|
+
* keeps the bare 'uv' so the spawn-error trap reports the absence readably
|
|
64
|
+
* instead of this module deciding startup policy.
|
|
65
|
+
*
|
|
66
|
+
* `env` is injectable for tests; both binary spellings are tried on every
|
|
67
|
+
* platform so the resolution logic itself stays platform-agnostic.
|
|
68
|
+
*/
|
|
69
|
+
export function findUv(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
70
|
+
const names = ['uv.exe', 'uv']
|
|
71
|
+
const pathVar = env.PATH ?? env.Path ?? ''
|
|
72
|
+
const home = env.HOME ?? env.USERPROFILE ?? ''
|
|
73
|
+
const candidates = [
|
|
74
|
+
...pathVar.split(isWin ? ';' : ':').filter(Boolean),
|
|
75
|
+
...(env.LOCALAPPDATA ? [join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links')] : []),
|
|
76
|
+
...(home ? [join(home, '.local', 'bin'), join(home, '.cargo', 'bin')] : []),
|
|
77
|
+
]
|
|
78
|
+
for (const dir of candidates) {
|
|
79
|
+
for (const name of names) {
|
|
80
|
+
const p = join(dir, name)
|
|
81
|
+
if (existsSync(p)) return p
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Paths of the bundled project + managed env, and whether each exists — the
|
|
88
|
+
* single "is there a managed environment?" answer, shared by the first-run
|
|
89
|
+
* setup and the GPU-triage Fix handler (index.ts). In dev there is no bundled
|
|
90
|
+
* payload, so `bundled` is false and the triage UI goes report-only. */
|
|
91
|
+
export function managedEnvPaths(resourcesPath: string, userData: string): {
|
|
92
|
+
projectDir: string
|
|
93
|
+
envDir: string
|
|
94
|
+
pythonExe: string
|
|
95
|
+
bundled: boolean
|
|
96
|
+
envExists: boolean
|
|
97
|
+
} {
|
|
98
|
+
const projectDir = join(resourcesPath, 'python')
|
|
99
|
+
const envDir = join(userData, 'python-env')
|
|
100
|
+
const pythonExe = venvPython(envDir)
|
|
101
|
+
return {
|
|
102
|
+
projectDir,
|
|
103
|
+
envDir,
|
|
104
|
+
pythonExe,
|
|
105
|
+
bundled: existsSync(join(projectDir, 'uv.lock')),
|
|
106
|
+
envExists: existsSync(pythonExe),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The locked torch release for THIS platform, parsed from `<projectDir>/uv.lock`.
|
|
112
|
+
*
|
|
113
|
+
* The lock carries TWO torch entries (platform-conditional sources): the
|
|
114
|
+
* `download.pytorch.org/whl/cu124` one (win32 per pyproject's source marker,
|
|
115
|
+
* e.g. "2.6.0+cu124") and the PyPI one (everything else, e.g. "2.13.0"). Pick
|
|
116
|
+
* the entry whose source matches this platform and strip any `+cuXXX` local
|
|
117
|
+
* tag — `--torch-backend=auto` owns the backend variant; we only pin the
|
|
118
|
+
* release so the resolved wheel stays lock-adjacent. torch is the ONLY
|
|
119
|
+
* torch-family package in the lock (no torchvision/torchaudio), so one
|
|
120
|
+
* `--no-install-package torch` + one pip install covers the family. Returns
|
|
121
|
+
* null when the lock is missing/unparseable (caller uses the plain full sync).
|
|
122
|
+
*/
|
|
123
|
+
export function readLockedTorchVersion(projectDir: string): string | null {
|
|
124
|
+
const lock = readSafe(join(projectDir, 'uv.lock'))
|
|
125
|
+
if (!lock) return null
|
|
126
|
+
// Each entry: [[package]] \n name = "torch" \n version = "…" \n source = { registry = "…" }
|
|
127
|
+
const re = /\[\[package\]\]\s*\r?\nname = "torch"\s*\r?\nversion = "([^"]+)"\s*\r?\nsource = \{ registry = "([^"]+)" \}/g
|
|
128
|
+
const entries: Array<{ version: string; registry: string }> = []
|
|
129
|
+
for (let m = re.exec(lock); m; m = re.exec(lock)) {
|
|
130
|
+
entries.push({ version: m[1], registry: m[2] })
|
|
131
|
+
}
|
|
132
|
+
if (!entries.length) return null
|
|
133
|
+
const preferPytorchIndex = isWin // pyproject pins the cu124 index for win32 only
|
|
134
|
+
const pick =
|
|
135
|
+
entries.find((e) => preferPytorchIndex === e.registry.includes('download.pytorch.org')) ??
|
|
136
|
+
entries[0]
|
|
137
|
+
return pick.version.split('+')[0] // "2.6.0+cu124" → "2.6.0"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Resolve the command to launch the app's backend, creating the venv if needed. */
|
|
141
|
+
export async function resolvePythonEnv(opts: EnsureOptions): Promise<ResolvedPython> {
|
|
142
|
+
const cfg = shellConfig()
|
|
143
|
+
const bundledProject = join(opts.resourcesPath, 'python')
|
|
144
|
+
const bundledLock = join(bundledProject, 'uv.lock')
|
|
145
|
+
|
|
146
|
+
// Development (or any build without the staged payload): resolve uv up front
|
|
147
|
+
// — PATH, then the standard per-user install dirs — so a uv that is installed
|
|
148
|
+
// but off this process's PATH still works. When it is truly nowhere, keep the
|
|
149
|
+
// bare 'uv': the spawn-error trap turns that into a readable report.
|
|
150
|
+
if (!opts.isPackaged || !existsSync(bundledLock)) {
|
|
151
|
+
const uv = findUv() ?? 'uv'
|
|
152
|
+
return { cmd: [uv, 'run', 'python', '-m', cfg.pythonModule], cwd: opts.projectRoot }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const envDir = join(opts.userData, 'python-env')
|
|
156
|
+
const pythonExe = venvPython(envDir)
|
|
157
|
+
const stampFile = join(envDir, `.${cfg.appId}-lock-hash`)
|
|
158
|
+
const lockHash = createHash('sha256').update(readFileSync(bundledLock)).digest('hex')
|
|
159
|
+
|
|
160
|
+
// Skip the sync if the venv already matches the shipped lock.
|
|
161
|
+
const upToDate =
|
|
162
|
+
existsSync(pythonExe) &&
|
|
163
|
+
existsSync(stampFile) &&
|
|
164
|
+
readSafe(stampFile) === lockHash
|
|
165
|
+
|
|
166
|
+
if (!upToDate) {
|
|
167
|
+
await setupEnv(bundledProject, envDir, readAppVersion(bundledProject), opts.onProgress)
|
|
168
|
+
try {
|
|
169
|
+
mkdirSync(envDir, { recursive: true })
|
|
170
|
+
writeFileSync(stampFile, lockHash, 'utf8')
|
|
171
|
+
} catch { /* a missing stamp just forces a re-sync next launch */ }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { cmd: [pythonExe, '-m', cfg.pythonModule], cwd: bundledProject }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readSafe(p: string): string {
|
|
178
|
+
try { return readFileSync(p, 'utf8').trim() } catch { return '' }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The version bundle-python.mjs baked into `<projectDir>/.<appId>-version`
|
|
183
|
+
* (X.Y.Z, resolved at CI bundle time when git history exists). Empty string in
|
|
184
|
+
* dev (no marker) — the dev repo has `.git`, so setuptools_scm works normally.
|
|
185
|
+
*/
|
|
186
|
+
function readAppVersion(projectDir: string): string {
|
|
187
|
+
return readSafe(join(projectDir, `.${shellConfig().appId}-version`))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build the env for a uv invocation against the managed env.
|
|
192
|
+
*
|
|
193
|
+
* `appVersion` (from the .<appId>-version marker) is exported as
|
|
194
|
+
* SETUPTOOLS_SCM_PRETEND_VERSION_FOR_<DIST> so building the editable app package
|
|
195
|
+
* succeeds even though the staged tree has NO `.git` — without it
|
|
196
|
+
* setuptools_scm raises `LookupError: unable to detect version` and `uv sync`
|
|
197
|
+
* exits 1 (the first-launch crash). Absent in dev (marker not present) → the
|
|
198
|
+
* env is unchanged and setuptools_scm resolves from the repo's git history.
|
|
199
|
+
*/
|
|
200
|
+
function uvEnv(envDir: string, appVersion: string, projectDir?: string): NodeJS.ProcessEnv {
|
|
201
|
+
// Pretend-version env for setuptools_scm. The dist-name–scoped var is the
|
|
202
|
+
// precise one; the plain SETUPTOOLS_SCM_PRETEND_VERSION is a harmless
|
|
203
|
+
// belt-and-braces fallback in case the dist name ever changes.
|
|
204
|
+
//
|
|
205
|
+
// setuptools_scm normalises the dist name to the env-var suffix by
|
|
206
|
+
// uppercasing and replacing runs of non-alphanumerics with `_`
|
|
207
|
+
// ("de-shell" → "DE_SHELL", "spyde" → "SPYDE").
|
|
208
|
+
const scmSuffix = String(shellConfig().pythonDist)
|
|
209
|
+
.toUpperCase().replace(/[^A-Z0-9]+/g, '_')
|
|
210
|
+
const scmEnv: Record<string, string> = appVersion
|
|
211
|
+
? {
|
|
212
|
+
[`SETUPTOOLS_SCM_PRETEND_VERSION_FOR_${scmSuffix}`]: appVersion,
|
|
213
|
+
SETUPTOOLS_SCM_PRETEND_VERSION: appVersion,
|
|
214
|
+
}
|
|
215
|
+
: {}
|
|
216
|
+
// Put the vendored portable git (bundle-python.mjs staged it at
|
|
217
|
+
// <projectDir>/git) on PATH so `uv sync` can resolve `git+https://…` deps
|
|
218
|
+
// (the hyperspy fork) on machines with no system git. MinGit's launcher is in
|
|
219
|
+
// git/cmd on Windows; git/bin holds it on posix. Prepend so ours wins; fall
|
|
220
|
+
// through to any system git if the vendored one is absent (dev builds).
|
|
221
|
+
const gitDir = projectDir ? join(projectDir, 'git') : ''
|
|
222
|
+
const gitBins = gitDir
|
|
223
|
+
? [join(gitDir, 'cmd'), join(gitDir, 'bin')].filter((d) => existsSync(d))
|
|
224
|
+
: []
|
|
225
|
+
const pathKey = isWin ? 'Path' : 'PATH'
|
|
226
|
+
const basePath = process.env[pathKey] ?? process.env.PATH ?? ''
|
|
227
|
+
const mergedPath = gitBins.length
|
|
228
|
+
? [...gitBins, basePath].join(isWin ? ';' : ':')
|
|
229
|
+
: basePath
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
...process.env,
|
|
233
|
+
...scmEnv,
|
|
234
|
+
[pathKey]: mergedPath,
|
|
235
|
+
// UV_PROJECT_ENVIRONMENT redirects `uv sync`'s venv out of the read-only
|
|
236
|
+
// resources into the writable user dir. (Ignored by `uv pip install`,
|
|
237
|
+
// which targets via --python — verified; harmless to set for both.)
|
|
238
|
+
UV_PROJECT_ENVIRONMENT: envDir,
|
|
239
|
+
// Keep uv's own cache/tools next to the env so an air-gapped re-run is
|
|
240
|
+
// self-contained and we never write into the read-only bundle.
|
|
241
|
+
UV_CACHE_DIR: join(envDir, '..', 'uv-cache'),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Spawn the bundled (or PATH) uv with `args`, streaming output to onProgress. */
|
|
246
|
+
function runUv(
|
|
247
|
+
projectDir: string,
|
|
248
|
+
envDir: string,
|
|
249
|
+
args: string[],
|
|
250
|
+
appVersion: string,
|
|
251
|
+
onProgress?: (line: string) => void,
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
const uv = join(projectDir, uvBinaryName())
|
|
254
|
+
// Fall back to a resolved PATH/per-user uv if not bundled (dev builds).
|
|
255
|
+
const uvCmd = existsSync(uv) ? uv : (findUv() ?? 'uv')
|
|
256
|
+
return new Promise((resolve, reject) => {
|
|
257
|
+
const proc = spawn(uvCmd, args, {
|
|
258
|
+
cwd: projectDir,
|
|
259
|
+
env: uvEnv(envDir, appVersion, projectDir),
|
|
260
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
261
|
+
})
|
|
262
|
+
const relay = (b: Buffer) => onProgress?.(b.toString())
|
|
263
|
+
proc.stdout?.on('data', relay)
|
|
264
|
+
proc.stderr?.on('data', relay) // uv prints progress to stderr
|
|
265
|
+
proc.on('error', reject)
|
|
266
|
+
proc.on('close', (code) =>
|
|
267
|
+
code === 0 ? resolve() : reject(new Error(`uv ${args[0]} exited with code ${code}`)),
|
|
268
|
+
)
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Install the locked torch release into the managed env with the backend
|
|
274
|
+
* resolved for THIS machine (`--torch-backend=auto`: uv probes the NVIDIA
|
|
275
|
+
* driver and picks the matching cuXXX wheel, or the much smaller CPU wheel when
|
|
276
|
+
* no GPU is present). Shared by first-run setup (step 2) and the GPU-triage
|
|
277
|
+
* "Fix PyTorch install" handler. Throws on failure (caller decides fallback).
|
|
278
|
+
*/
|
|
279
|
+
export function installTorchPerMachine(
|
|
280
|
+
projectDir: string,
|
|
281
|
+
envDir: string,
|
|
282
|
+
onProgress?: (line: string) => void,
|
|
283
|
+
): Promise<void> {
|
|
284
|
+
const torchVersion = readLockedTorchVersion(projectDir)
|
|
285
|
+
if (!torchVersion) {
|
|
286
|
+
return Promise.reject(new Error('could not read the locked torch version from uv.lock'))
|
|
287
|
+
}
|
|
288
|
+
onProgress?.(`[env-setup] installing torch==${torchVersion} with --torch-backend=auto\n`)
|
|
289
|
+
return runUv(
|
|
290
|
+
projectDir, envDir,
|
|
291
|
+
['pip', 'install', `torch==${torchVersion}`, '--torch-backend=auto',
|
|
292
|
+
'--python', venvPython(envDir)],
|
|
293
|
+
'', // no scm pretend-version needed for a plain pip install
|
|
294
|
+
onProgress,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The pre-built app wheel staged by bundle-python.mjs at <projectDir>/wheels/
|
|
299
|
+
* (a single py3-none-any .whl). Null in dev (no staged wheel) → the caller
|
|
300
|
+
* installs the project from source the old way (dev tree is writable).
|
|
301
|
+
*
|
|
302
|
+
* Matched by the DIST name with its separators normalised: a wheel filename
|
|
303
|
+
* uses `_` where the dist name may use `-` ("de-shell" → "de_shell-0.1.0-…"). */
|
|
304
|
+
function stagedAppWheel(projectDir: string): string | null {
|
|
305
|
+
const dir = join(projectDir, 'wheels')
|
|
306
|
+
if (!existsSync(dir)) return null
|
|
307
|
+
const prefix = String(shellConfig().pythonDist).replace(/-/g, '_').toLowerCase()
|
|
308
|
+
try {
|
|
309
|
+
const whl = readdirSync(dir).find(
|
|
310
|
+
(f) => f.toLowerCase().replace(/-/g, '_').startsWith(prefix) && f.endsWith('.whl'),
|
|
311
|
+
)
|
|
312
|
+
return whl ? join(dir, whl) : null
|
|
313
|
+
} catch { return null }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Every wheel staged beside the app's own — the workspace MEMBERS.
|
|
318
|
+
*
|
|
319
|
+
* The app is one package in a uv workspace, and its siblings are path
|
|
320
|
+
* dependencies. They cannot be built on the user's machine for the same reason
|
|
321
|
+
* the app cannot (setuptools' egg_info writes into a read-only tree), so
|
|
322
|
+
* `bundle-python.mjs` builds them all with `uv build --all-packages` and they
|
|
323
|
+
* are installed from wheels here.
|
|
324
|
+
*/
|
|
325
|
+
function stagedMemberWheels(projectDir: string, appWheel: string | null): string[] {
|
|
326
|
+
const dir = join(projectDir, 'wheels')
|
|
327
|
+
if (!existsSync(dir)) return []
|
|
328
|
+
try {
|
|
329
|
+
return readdirSync(dir)
|
|
330
|
+
.filter((f) => f.endsWith('.whl'))
|
|
331
|
+
.map((f) => join(dir, f))
|
|
332
|
+
.filter((f) => f !== appWheel)
|
|
333
|
+
} catch { return []; }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The distribution name a wheel file installs, from its filename.
|
|
338
|
+
*
|
|
339
|
+
* Wheel names are `{name}-{version}-…`, and the name has `-` normalised to `_`.
|
|
340
|
+
* uv accepts either spelling for `--no-install-package`.
|
|
341
|
+
*/
|
|
342
|
+
function wheelPackageName(wheel: string): string {
|
|
343
|
+
return (wheel.split(/[\\/]/).pop() || '').split('-')[0]
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Install the PRE-BUILT app wheel into the managed env with no dependency
|
|
348
|
+
* resolution (`--no-deps` — the sync already installed every dependency). This
|
|
349
|
+
* is the whole reason the wheel exists: it avoids building the app from the
|
|
350
|
+
* read-only shipped source tree (setuptools' egg_info write → "Access is
|
|
351
|
+
* denied", the rc.2/rc.3 first-launch crash). Throws on failure.
|
|
352
|
+
*/
|
|
353
|
+
function installAppWheel(
|
|
354
|
+
projectDir: string,
|
|
355
|
+
envDir: string,
|
|
356
|
+
wheel: string,
|
|
357
|
+
onProgress?: (line: string) => void,
|
|
358
|
+
): Promise<void> {
|
|
359
|
+
onProgress?.(
|
|
360
|
+
`[env-setup] installing ${shellConfig().pythonDist} from wheel ` +
|
|
361
|
+
`${wheel.split(/[\\/]/).pop()}\n`,
|
|
362
|
+
)
|
|
363
|
+
return runUv(
|
|
364
|
+
projectDir, envDir,
|
|
365
|
+
['pip', 'install', '--no-deps', '--python', venvPython(envDir), wheel],
|
|
366
|
+
'', onProgress,
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Create/refresh the managed env. On win32/linux (the platforms where the lock
|
|
372
|
+
* pins CUDA torch) this is the two-step per-machine install; on failure — or on
|
|
373
|
+
* macOS, or when the lock has no readable torch pin — the plain full
|
|
374
|
+
* `uv sync --frozen --no-dev` (the original, known-good path) runs instead.
|
|
375
|
+
*
|
|
376
|
+
* When a pre-built app wheel is staged (the packaged app), the syncs use
|
|
377
|
+
* `--no-install-project` (resolve + install DEPS only, never build the app) and
|
|
378
|
+
* the app is installed from the wheel afterwards — so NOTHING is built from the
|
|
379
|
+
* read-only source tree. In dev (no staged wheel) the project installs from
|
|
380
|
+
* source as before. Logs which path ran to the progress stream.
|
|
381
|
+
*/
|
|
382
|
+
async function setupEnv(
|
|
383
|
+
projectDir: string,
|
|
384
|
+
envDir: string,
|
|
385
|
+
appVersion: string,
|
|
386
|
+
onProgress?: (line: string) => void,
|
|
387
|
+
): Promise<void> {
|
|
388
|
+
const wheel = stagedAppWheel(projectDir)
|
|
389
|
+
const memberWheels = stagedMemberWheels(projectDir, wheel)
|
|
390
|
+
// With a staged wheel, tell uv sync NOT to touch the project (the app builds
|
|
391
|
+
// from the read-only tree otherwise); we install the wheel separately.
|
|
392
|
+
//
|
|
393
|
+
// `--no-install-project` covers the ROOT project only, so every workspace
|
|
394
|
+
// MEMBER needs excluding by name as well. Without that, uv tries to build
|
|
395
|
+
// them from the payload — which carries their pyproject.toml for workspace
|
|
396
|
+
// discovery and nothing else — and first launch dies before installing
|
|
397
|
+
// anything ("Distribution not found at: file:///…/packages/de-shell").
|
|
398
|
+
const projectArgs = wheel ? ['--no-install-project'] : ['--no-editable']
|
|
399
|
+
for (const memberWheel of memberWheels) {
|
|
400
|
+
projectArgs.push('--no-install-package', wheelPackageName(memberWheel))
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const twoStep =
|
|
404
|
+
(process.platform === 'win32' || process.platform === 'linux') &&
|
|
405
|
+
readLockedTorchVersion(projectDir) !== null
|
|
406
|
+
|
|
407
|
+
if (twoStep) {
|
|
408
|
+
try {
|
|
409
|
+
onProgress?.('[env-setup] two-step install: uv sync (lock-exact, torch deferred) '
|
|
410
|
+
+ 'then torch via --torch-backend=auto\n')
|
|
411
|
+
// Step 1: every DEPENDENCY except torch, exactly as locked. torch is the
|
|
412
|
+
// only torch-family package in the lock, so one exclusion covers it.
|
|
413
|
+
await runUv(
|
|
414
|
+
projectDir, envDir,
|
|
415
|
+
['sync', '--frozen', '--no-dev', ...projectArgs, '--no-install-package', 'torch'],
|
|
416
|
+
appVersion, onProgress,
|
|
417
|
+
)
|
|
418
|
+
// Step 2: torch resolved for this machine.
|
|
419
|
+
await installTorchPerMachine(projectDir, envDir, onProgress)
|
|
420
|
+
// Step 3: the workspace members, then the app — all from pre-built
|
|
421
|
+
// wheels (packaged only). Members first: the app wheel depends on them
|
|
422
|
+
// and `--no-deps` means nothing else will pull them in.
|
|
423
|
+
for (const memberWheel of memberWheels) {
|
|
424
|
+
await installAppWheel(projectDir, envDir, memberWheel, onProgress)
|
|
425
|
+
}
|
|
426
|
+
if (wheel) await installAppWheel(projectDir, envDir, wheel, onProgress)
|
|
427
|
+
onProgress?.('[env-setup] per-machine torch install complete\n')
|
|
428
|
+
return
|
|
429
|
+
} catch (err) {
|
|
430
|
+
onProgress?.(`[env-setup] per-machine torch install failed (${(err as Error)?.message ?? err}); `
|
|
431
|
+
+ 'falling back to the full locked sync\n')
|
|
432
|
+
// fall through to the plain sync
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
onProgress?.('[env-setup] running full locked uv sync\n')
|
|
437
|
+
await runUv(projectDir, envDir, ['sync', '--frozen', '--no-dev', ...projectArgs], appVersion, onProgress)
|
|
438
|
+
for (const memberWheel of memberWheels) {
|
|
439
|
+
await installAppWheel(projectDir, envDir, memberWheel, onProgress)
|
|
440
|
+
}
|
|
441
|
+
if (wheel) await installAppWheel(projectDir, envDir, wheel, onProgress)
|
|
442
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sentryEnvelope.test.ts — node:test unit tests for the problem-report wire
|
|
3
|
+
* format.
|
|
4
|
+
*
|
|
5
|
+
* This file is hand-written against a protocol, so the tests stand in for the
|
|
6
|
+
* SDK that would otherwise be guaranteeing it. They pin the two things a
|
|
7
|
+
* malformed report would fail on silently — the envelope's byte-length framing
|
|
8
|
+
* and the auth header — and the DSN parse, whose whole job is to fail softly so
|
|
9
|
+
* a build with no DSN configured still writes reports to disk.
|
|
10
|
+
*
|
|
11
|
+
* Run: `node --test src/sentryEnvelope.test.ts`, or via the `test:unit` script.
|
|
12
|
+
*/
|
|
13
|
+
import { test } from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
import {
|
|
16
|
+
buildEnvelope, formatEventId, parseSentryDsn, sentryAuthHeader,
|
|
17
|
+
} from './sentryEnvelope.ts'
|
|
18
|
+
|
|
19
|
+
test('parses a modern DSN into endpoint, key and project', () => {
|
|
20
|
+
const target = parseSentryDsn('https://abc123@o44.ingest.sentry.io/1234567')
|
|
21
|
+
assert.equal(target?.endpoint, 'https://o44.ingest.sentry.io/api/1234567/envelope/')
|
|
22
|
+
assert.equal(target?.publicKey, 'abc123')
|
|
23
|
+
assert.equal(target?.dsn, 'https://abc123@o44.ingest.sentry.io/1234567')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('drops the secret from a legacy key:secret DSN', () => {
|
|
27
|
+
const target = parseSentryDsn('https://pub:secret@o44.ingest.sentry.io/9')
|
|
28
|
+
assert.equal(target?.publicKey, 'pub')
|
|
29
|
+
assert.ok(!target?.dsn.includes('secret'), 'the secret must not be echoed back')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('a self-hosted DSN keeps its port and host', () => {
|
|
33
|
+
const target = parseSentryDsn('https://key@sentry.example.org:8443/42')
|
|
34
|
+
assert.equal(target?.endpoint, 'https://sentry.example.org:8443/api/42/envelope/')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('an absent or unusable DSN yields null rather than throwing', () => {
|
|
38
|
+
// Each of these must leave the app in the "write to disk only" mode.
|
|
39
|
+
for (const dsn of [
|
|
40
|
+
undefined, null, '', ' ',
|
|
41
|
+
'not-a-url',
|
|
42
|
+
'https://o44.ingest.sentry.io/1234567', // no public key
|
|
43
|
+
'https://abc123@o44.ingest.sentry.io/', // no project id
|
|
44
|
+
'https://abc123@o44.ingest.sentry.io/notanumber',
|
|
45
|
+
'ftp://abc123@o44.ingest.sentry.io/1', // not HTTP
|
|
46
|
+
]) {
|
|
47
|
+
assert.equal(parseSentryDsn(dsn as string | undefined), null, `should reject: ${String(dsn)}`)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('auth header carries version, client and key', () => {
|
|
52
|
+
const target = parseSentryDsn('https://abc123@o44.ingest.sentry.io/1')!
|
|
53
|
+
const header = sentryAuthHeader(target, 'spyde-shell/1.0')
|
|
54
|
+
assert.match(header, /^Sentry sentry_version=7/)
|
|
55
|
+
assert.match(header, /sentry_client=spyde-shell\/1\.0/)
|
|
56
|
+
assert.match(header, /sentry_key=abc123/)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('event ids are 32 lowercase hex characters', () => {
|
|
60
|
+
const id = formatEventId(Uint8Array.from({ length: 16 }, (_, i) => i))
|
|
61
|
+
assert.match(id, /^[0-9a-f]{32}$/)
|
|
62
|
+
assert.equal(id, '000102030405060708090a0b0c0d0e0f')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('a short byte array is padded rather than producing a short id', () => {
|
|
66
|
+
assert.match(formatEventId(new Uint8Array([1, 2])), /^[0-9a-f]{32}$/)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('envelope framing is three newline-delimited JSON lines', () => {
|
|
70
|
+
const target = parseSentryDsn('https://abc123@o44.ingest.sentry.io/7')!
|
|
71
|
+
const event = { event_id: 'a'.repeat(32), message: 'boom' }
|
|
72
|
+
const lines = buildEnvelope(target, event, '2026-08-28T00:00:00.000Z').split('\n')
|
|
73
|
+
|
|
74
|
+
const envelopeHeader = JSON.parse(lines[0])
|
|
75
|
+
assert.equal(envelopeHeader.event_id, 'a'.repeat(32))
|
|
76
|
+
assert.equal(envelopeHeader.dsn, target.dsn)
|
|
77
|
+
|
|
78
|
+
const itemHeader = JSON.parse(lines[1])
|
|
79
|
+
assert.equal(itemHeader.type, 'event')
|
|
80
|
+
|
|
81
|
+
assert.deepEqual(JSON.parse(lines[2]), event)
|
|
82
|
+
assert.equal(lines[3], '', 'the envelope ends with a newline')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('the item length is BYTES, not characters', () => {
|
|
86
|
+
// A report naming a file with an accent, or a stack trace with a smart quote,
|
|
87
|
+
// is rejected outright if this counts characters — hence the explicit test.
|
|
88
|
+
const target = parseSentryDsn('https://abc123@o44.ingest.sentry.io/7')!
|
|
89
|
+
const event = { event_id: 'b'.repeat(32), message: 'Ångström — µm' }
|
|
90
|
+
const lines = buildEnvelope(target, event, '2026-08-28T00:00:00.000Z').split('\n')
|
|
91
|
+
const declared = JSON.parse(lines[1]).length
|
|
92
|
+
assert.equal(declared, Buffer.byteLength(lines[2], 'utf8'))
|
|
93
|
+
assert.ok(declared > lines[2].length, 'multi-byte content must declare more bytes than characters')
|
|
94
|
+
})
|