-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfrom-pip-venv.ts
More file actions
164 lines (151 loc) · 5.69 KB
/
Copy pathfrom-pip-venv.ts
File metadata and controls
164 lines (151 loc) · 5.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
/**
* @file Generic "venv-install tier" for external-tools resolvers. Parallel to
* `from-download.ts` — that one handles single-binary tools downloaded as
* GitHub release assets; this one handles Python packages installed into a
* single-purpose venv. Per-tool resolvers (skillspector, future Python CLIs)
* compose their fourth tier on top of these helpers. Two helpers:
*
* - `createPipVenv` — `python -m venv <cacheDir>` + `pip install <spec>`.
* Idempotent: hits the existing venv when its entry-point already exists.
* Stops at "venv created, entry-point present."
* - `findPython` — locates `python3` (or `python` on Windows) on PATH. Returns
* the absolute path or `undefined` when no Python is available. What this
* does NOT do:
* - Decide where the venv lives. The caller picks the cache dir (typically
* under `getSocketDlxDir()`); we just create the venv there.
* - Verify package integrity beyond pip's own wheel-hash mechanism. Pinning is
* the caller's responsibility: pass exact-version `<pkg>==<ver>` or git-SHA
* `git+...@<sha>`.
* - Re-resolve when the cache is stale. The cache key is derived by the caller
* from the install spec; a re-pin produces a new dir.
*/
import { existsSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { which } from '../bin/which'
import { safeMkdir } from '../fs/safe'
import { spawn } from '../process/spawn/child'
export interface CreatePipVenvOptions {
/**
* Absolute path to the venv directory. Created if missing; reused if the
* entry-point already exists inside it.
*/
readonly cacheDir: string
/**
* Name of the entry-point executable. Matches the package's
* `[project.scripts]` key (or the package name when it's the same).
*/
readonly entryPoint: string
/**
* Pip-install argument — either `<pkg>==<version>` (PyPI exact pin) or
* `git+<https-url>@<sha>` (git-SHA pin). Anything else is rejected.
*/
readonly installSpec: string
/**
* Optional override for the Python interpreter. Defaults to
* {@link findPython}.
*/
readonly python?: string | undefined
}
export interface CreatePipVenvResult {
/**
* Absolute path to the entry-point binary inside the venv. Always set when
* `created` or the cache was already populated.
*/
readonly entryPointPath: string
/**
* `true` when this call created the venv (and ran pip install); `false` when
* the existing cache was reused.
*/
readonly created: boolean
}
/**
* Create (or reuse) a venv at `cacheDir` and pip-install `installSpec` into it.
* Returns the entry-point path + a `created` flag. Throws when:
*
* - No Python interpreter is on PATH (and none was passed via `python`).
* - `python -m venv` fails.
* - `pip install` fails.
* - The install succeeded but the entry-point was not created (caller passed a
* wrong `entryPoint` name, or the package has no console script).
*/
export async function createPipVenv(
options: CreatePipVenvOptions,
): Promise<CreatePipVenvResult> {
options = { __proto__: null, ...options } as typeof options
const { cacheDir, entryPoint, installSpec } = {
__proto__: null,
...options,
} as typeof options
const entryBin = pipVenvEntryPointPath(cacheDir, entryPoint)
// Cache hit: existing venv, existing entry-point. Skip install.
if (existsSync(entryBin)) {
return { entryPointPath: entryBin, created: false }
}
const python = options.python ?? (await findPython())
if (!python) {
throw new Error(
'createPipVenv: no Python interpreter on PATH (looked for python3, python)',
)
}
await safeMkdir(path.dirname(cacheDir), { recursive: true })
// Create the venv. `--clear` makes the call idempotent — if the dir
// exists but is stale (partial install from a previous crash), it
// wipes and recreates.
await spawn(python, ['-m', 'venv', '--clear', cacheDir], { stdio: 'pipe' })
// pip-install inside the venv. Use the venv's own pip so the host
// site-packages is untouched. `--no-input` prevents interactive
// prompts in non-TTY contexts (CI / scripts).
const venvPython = pipVenvEntryPointPath(cacheDir, 'python')
if (!existsSync(venvPython)) {
throw new Error(
`createPipVenv: venv created at ${cacheDir} but ${venvPython} is missing`,
)
}
await spawn(
venvPython,
[
'-m',
'pip',
'install',
'--no-input',
'--disable-pip-version-check',
installSpec,
],
{ stdio: 'pipe' },
)
if (!existsSync(entryBin)) {
throw new Error(
`createPipVenv: pip install ${installSpec} succeeded but entry-point ${entryPoint} was not created at ${entryBin}`,
)
}
return { entryPointPath: entryBin, created: true }
}
/**
* Locate a Python interpreter. Prefer `python3` on macOS/Linux, fall back to
* `python` (the Windows convention). Returns `undefined` when neither is on
* PATH.
*/
export async function findPython(): Promise<string | undefined> {
const candidates =
process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python']
for (let i = 0, { length } = candidates; i < length; i += 1) {
// eslint-disable-next-line no-await-in-loop -- short-circuit on first hit.
const found = await which(candidates[i]!, { nothrow: true })
if (typeof found === 'string') {
return found
}
}
return undefined
}
// Per-platform venv layout. On Windows the entry-point lives under
// `Scripts/<name>.exe`; on Unix it's `bin/<name>`.
export function pipVenvEntryPointPath(
venvDir: string,
entryPoint: string,
): string {
if (process.platform === 'win32') {
return path.join(venvDir, 'Scripts', `${entryPoint}.exe`)
}
return path.join(venvDir, 'bin', entryPoint)
}