pyfig 0.3.1 → 0.5.0
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.
- package/README.md +116 -123
- package/SECURITY.md +2 -2
- package/dist/cjs/index.js +525 -888
- package/dist/cjs/index.js.map +15 -15
- package/dist/cjs/sync-resolver.js +482 -676
- package/dist/cjs/sync-resolver.js.map +13 -12
- package/dist/cli.js +7 -10
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +3 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +13 -11
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +9 -18
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +22 -59
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +5 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -4
- package/dist/index.js.map +1 -1
- package/dist/package-target.d.ts +7 -11
- package/dist/package-target.d.ts.map +1 -1
- package/dist/package-target.js +41 -57
- package/dist/package-target.js.map +1 -1
- package/dist/probes.d.ts +1 -19
- package/dist/probes.d.ts.map +1 -1
- package/dist/probes.js +13 -74
- package/dist/probes.js.map +1 -1
- package/dist/process.d.ts +1 -1
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +2 -1
- package/dist/process.js.map +1 -1
- package/dist/project-root.d.ts +1 -1
- package/dist/project-root.d.ts.map +1 -1
- package/dist/project-root.js +3 -64
- package/dist/project-root.js.map +1 -1
- package/dist/provision-lock.d.ts +10 -0
- package/dist/provision-lock.d.ts.map +1 -0
- package/dist/provision-lock.js +24 -0
- package/dist/provision-lock.js.map +1 -0
- package/dist/provision.d.ts +18 -6
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +297 -235
- package/dist/provision.js.map +1 -1
- package/dist/python-version.d.ts +10 -0
- package/dist/python-version.d.ts.map +1 -0
- package/dist/python-version.js +17 -0
- package/dist/python-version.js.map +1 -0
- package/dist/runners.d.ts +3 -7
- package/dist/runners.d.ts.map +1 -1
- package/dist/runners.js +35 -115
- package/dist/runners.js.map +1 -1
- package/dist/sync-resolver.js +4 -13
- package/dist/sync-resolver.js.map +1 -1
- package/dist/virtual-environment.d.ts +3 -1
- package/dist/virtual-environment.d.ts.map +1 -1
- package/dist/virtual-environment.js +8 -1
- package/dist/virtual-environment.js.map +1 -1
- package/package.json +9 -10
- package/dist/coordination.d.ts +0 -25
- package/dist/coordination.d.ts.map +0 -1
- package/dist/coordination.js +0 -187
- package/dist/coordination.js.map +0 -1
- package/dist/state.d.ts +0 -10
- package/dist/state.d.ts.map +0 -1
- package/dist/state.js +0 -62
- package/dist/state.js.map +0 -1
package/dist/provision.js
CHANGED
|
@@ -1,217 +1,257 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { createReadStream, createWriteStream } from 'node:fs';
|
|
3
|
-
import { chmod, copyFile, mkdir, mkdtemp, readdir, rename, rm, } from 'node:fs/promises';
|
|
3
|
+
import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile, } from 'node:fs/promises';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { Readable } from 'node:stream';
|
|
8
8
|
import { pipeline } from 'node:stream/promises';
|
|
9
|
-
import
|
|
9
|
+
import { unzipSync } from 'fflate';
|
|
10
10
|
import * as tar from 'tar';
|
|
11
11
|
import { EnvHttpProxyAgent, fetch } from 'undici';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
12
|
+
import { resolveOptions, uvEnvironment, } from './config.js';
|
|
13
|
+
import { withProvisioningLock } from './provision-lock.js';
|
|
14
|
+
import { sanitizeUvEnvironment, uvCommandEnvironment } from './package-target.js';
|
|
15
|
+
import { findUv, probePython, probeUv, pythonSatisfiesPolicy, } from './probes.js';
|
|
15
16
|
import { runAsync } from './process.js';
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
export async function ensurePython(options = {}) {
|
|
19
|
-
const resolved = resolveOptions(options);
|
|
20
|
-
return memoizeAsync('python', configurationFingerprint(resolved), resolved.cacheTtlMs, pythonIsPresent, () => ensurePythonResolved(resolved));
|
|
21
|
-
}
|
|
22
|
-
export async function ensurePip(options = {}, python) {
|
|
23
|
-
const resolved = resolveOptions(options);
|
|
24
|
-
const selectedPython = python?.virtualEnvironment
|
|
25
|
-
? python
|
|
26
|
-
: await ensureVirtualEnvironmentResolved(resolved);
|
|
27
|
-
return ensurePipResolved(selectedPython, resolved);
|
|
28
|
-
}
|
|
29
|
-
export async function ensureVirtualEnvironment(options = {}) {
|
|
30
|
-
const resolved = resolveOptions(options);
|
|
31
|
-
return memoizeAsync('venv', configurationFingerprint(resolved), resolved.cacheTtlMs, (python) => pythonIsPresent(python) &&
|
|
32
|
-
Boolean(python.virtualEnvironment) &&
|
|
33
|
-
pathsEqual(python.virtualEnvironment, virtualEnvironmentDirectory(resolved.projectRoot)), () => ensureVirtualEnvironmentResolved(resolved));
|
|
34
|
-
}
|
|
17
|
+
import { canonicalPath, externalVirtualEnvironmentDirectories, pathsEqual, virtualEnvironmentDirectory, virtualEnvironmentPythonExecutable, } from './virtual-environment.js';
|
|
18
|
+
/** Ensure a compatible uv executable, preferring an existing installation. */
|
|
35
19
|
export async function ensureUv(options = {}) {
|
|
36
|
-
|
|
37
|
-
return memoizeAsync('uv', configurationFingerprint(resolved), resolved.cacheTtlMs, (uv) => fs.existsSync(uv.executable), () => ensureUvResolved(resolved));
|
|
20
|
+
return ensureUvResolved(resolveOptions(options));
|
|
38
21
|
}
|
|
39
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Select a compatible environment and optionally add runtime dependencies.
|
|
24
|
+
* Project dependency files are inputs only and are never changed.
|
|
25
|
+
*/
|
|
26
|
+
export async function ensureEnvironment(options = {}, dependencies = [], dependencyOptions = {}) {
|
|
40
27
|
const resolved = resolveOptions(options);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
28
|
+
const normalized = normalizeDependencies(dependencies);
|
|
29
|
+
const selections = normalizeDependencyOptions(dependencyOptions);
|
|
30
|
+
const uv = await ensureUvResolved(resolved);
|
|
31
|
+
const python = await ensureVirtualEnvironmentResolved(resolved, uv);
|
|
32
|
+
if (normalized.length > 0 || selections.extras.length > 0 ||
|
|
33
|
+
selections.groups.length > 0 || selections.allExtras) {
|
|
34
|
+
await installRuntimeDependencies(uv, python, normalized, selections, resolved);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
python,
|
|
38
|
+
uv,
|
|
39
|
+
managed: isManagedEnvironment(python, resolved),
|
|
40
|
+
};
|
|
46
41
|
}
|
|
47
|
-
async function ensureVirtualEnvironmentResolved(options) {
|
|
42
|
+
async function ensureVirtualEnvironmentResolved(options, uv) {
|
|
43
|
+
const existing = await findReusableEnvironment(options);
|
|
44
|
+
if (existing)
|
|
45
|
+
return existing;
|
|
48
46
|
const directory = virtualEnvironmentDirectory(options.projectRoot);
|
|
49
47
|
const check = async () => {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return existing;
|
|
56
|
-
}
|
|
57
|
-
throw incompatibleVirtualEnvironmentError(directory, existing, options);
|
|
48
|
+
const candidate = await inspectVirtualEnvironment(directory, options);
|
|
49
|
+
return candidate && pythonSatisfiesPolicy(candidate, options) &&
|
|
50
|
+
await virtualEnvironmentMatchesConfiguredPython(candidate, options)
|
|
51
|
+
? candidate
|
|
52
|
+
: null;
|
|
58
53
|
};
|
|
59
|
-
const existing = await check();
|
|
60
|
-
if (existing)
|
|
61
|
-
return existing;
|
|
62
54
|
if (!options.createVirtualEnvironment) {
|
|
63
|
-
throw new Error(`No
|
|
64
|
-
|
|
55
|
+
throw new Error(`No compatible environment is available and creation is disabled. ` +
|
|
56
|
+
`Pyfig would create ${directory}.`);
|
|
65
57
|
}
|
|
66
|
-
|
|
67
|
-
// order Python -> uv -> venv consistent across callers.
|
|
68
|
-
const basePython = await ensurePythonResolved(options);
|
|
69
|
-
const preferredUv = await findAvailableUv(options) ??
|
|
70
|
-
(options.installUv ? await ensureUvResolved(options) : null);
|
|
58
|
+
const basePython = await ensureBasePythonResolved(uv, options);
|
|
71
59
|
return withProvisioningLock({
|
|
72
60
|
key: `venv:${canonicalPath(directory)}`,
|
|
73
|
-
target: directory,
|
|
74
61
|
timeoutMs: options.coordinationTimeoutMs,
|
|
75
|
-
env: options.env,
|
|
76
62
|
check,
|
|
77
|
-
mutate: async (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
63
|
+
mutate: async () => {
|
|
64
|
+
// Only Pyfig's private path is replaceable. Active and repository-root
|
|
65
|
+
// environments are preserved when incompatible.
|
|
66
|
+
await rm(directory, { recursive: true, force: true });
|
|
67
|
+
await mkdir(path.dirname(directory), { recursive: true });
|
|
68
|
+
await runAsync(uv.executable, [
|
|
69
|
+
'venv',
|
|
70
|
+
'--no-project',
|
|
71
|
+
'--no-config',
|
|
72
|
+
'--no-python-downloads',
|
|
73
|
+
'--python',
|
|
74
|
+
basePython.executable,
|
|
75
|
+
directory,
|
|
76
|
+
], isolatedUvEnvironment(options), options.installTimeoutMs);
|
|
77
|
+
const installed = await check();
|
|
78
|
+
if (!installed) {
|
|
79
|
+
throw new Error(`uv created ${directory}, but its Python does not satisfy the requested capabilities.`);
|
|
87
80
|
}
|
|
88
81
|
return installed;
|
|
89
82
|
},
|
|
90
83
|
});
|
|
91
84
|
}
|
|
92
|
-
async function
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
// A usable Python may still provide its standard-library venv module.
|
|
85
|
+
async function findReusableEnvironment(options) {
|
|
86
|
+
if (options.python) {
|
|
87
|
+
const configured = await probePython({ executable: options.python, args: [] }, options);
|
|
88
|
+
if (!configured || !pythonSatisfiesPolicy(configured, options)) {
|
|
89
|
+
throw incompatibleConfiguredPython(options);
|
|
100
90
|
}
|
|
91
|
+
if (configured.virtualEnvironment)
|
|
92
|
+
return configured;
|
|
101
93
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
|
|
94
|
+
for (const directory of [
|
|
95
|
+
...externalVirtualEnvironmentDirectories(options.projectRoot, options.env),
|
|
96
|
+
virtualEnvironmentDirectory(options.projectRoot),
|
|
97
|
+
]) {
|
|
98
|
+
const info = await inspectVirtualEnvironment(directory, options);
|
|
99
|
+
if (info && pythonSatisfiesPolicy(info, options) &&
|
|
100
|
+
await virtualEnvironmentMatchesConfiguredPython(info, options)) {
|
|
101
|
+
return info;
|
|
102
|
+
}
|
|
109
103
|
}
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
async function createVirtualEnvironmentWithUv(uv, directory, basePython, options, signal) {
|
|
113
|
-
await runAsync(uv.executable, [
|
|
114
|
-
'venv',
|
|
115
|
-
'--no-project',
|
|
116
|
-
'--no-config',
|
|
117
|
-
'--no-python-downloads',
|
|
118
|
-
'--allow-existing',
|
|
119
|
-
'--python',
|
|
120
|
-
basePython.executable,
|
|
121
|
-
directory,
|
|
122
|
-
], uvEnvironment(options), options.installTimeoutMs, signal);
|
|
123
|
-
}
|
|
124
|
-
async function inspectVirtualEnvironment(directory, options) {
|
|
125
|
-
const executable = virtualEnvironmentPythonExecutable(directory);
|
|
126
|
-
const info = await probePython({ executable, args: [] }, options);
|
|
127
|
-
return info?.virtualEnvironment && pathsEqual(info.virtualEnvironment, directory) ? info : null;
|
|
104
|
+
return null;
|
|
128
105
|
}
|
|
129
|
-
async function
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
106
|
+
async function ensureBasePythonResolved(uv, options) {
|
|
107
|
+
if (options.python) {
|
|
108
|
+
const configured = await probePython({ executable: options.python, args: [] }, options);
|
|
109
|
+
if (!configured || !pythonSatisfiesPolicy(configured, options)) {
|
|
110
|
+
throw incompatibleConfiguredPython(options);
|
|
111
|
+
}
|
|
112
|
+
if (!configured.virtualEnvironment)
|
|
113
|
+
return configured;
|
|
114
|
+
const base = await probePython({ executable: configured.baseExecutable, args: [] }, options);
|
|
115
|
+
if (base && pythonSatisfiesPolicy(base, options))
|
|
116
|
+
return base;
|
|
117
|
+
throw incompatibleConfiguredPython(options);
|
|
118
|
+
}
|
|
119
|
+
// Corporate and offline hosts get a strict system-only pass before Pyfig
|
|
120
|
+
// considers any already-managed interpreter or network installation.
|
|
121
|
+
const system = await findPythonWithUv(uv, options, 'system');
|
|
122
|
+
if (system)
|
|
123
|
+
return system;
|
|
124
|
+
const cachedManaged = await findPythonWithUv(uv, options, 'managed');
|
|
125
|
+
if (cachedManaged)
|
|
126
|
+
return cachedManaged;
|
|
133
127
|
if (!options.installPython) {
|
|
134
|
-
|
|
135
|
-
? ' with compatible CPython development headers, architecture, and a linkable library'
|
|
136
|
-
: '';
|
|
137
|
-
throw new Error(`No Python interpreter satisfies ${options.pythonRange}${capability}; ` +
|
|
138
|
-
'managed Python installation is disabled.');
|
|
128
|
+
throw new Error(missingPythonMessage(options, 'managed Python installation is disabled'));
|
|
139
129
|
}
|
|
140
|
-
const lockTarget = cacheLockTarget(options.cacheDir, 'python');
|
|
141
130
|
return withProvisioningLock({
|
|
142
|
-
key: `python:${canonicalPath(
|
|
143
|
-
target: lockTarget,
|
|
131
|
+
key: `python:${canonicalPath(path.join(options.cacheDir, 'python'))}`,
|
|
144
132
|
timeoutMs: options.coordinationTimeoutMs,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
const uvEnv = uvEnvironment(options);
|
|
150
|
-
await runAsync(uv.executable, ['python', 'install', '--no-config', options.managedPython], uvEnv, options.installTimeoutMs, signal);
|
|
151
|
-
const installed = await findPythonWithUv(uv, options, signal);
|
|
133
|
+
check: () => findPythonWithUv(uv, options, 'managed'),
|
|
134
|
+
mutate: async () => {
|
|
135
|
+
await runAsync(uv.executable, ['python', 'install', '--no-config', options.managedPython], isolatedUvEnvironment(options), options.installTimeoutMs);
|
|
136
|
+
const installed = await findPythonWithUv(uv, options, 'managed');
|
|
152
137
|
if (!installed) {
|
|
153
|
-
throw new Error(`uv installed
|
|
154
|
-
'runtime capabilities.');
|
|
138
|
+
throw new Error(missingPythonMessage(options, `uv installed ${options.managedPython}, but it remains incompatible`));
|
|
155
139
|
}
|
|
156
140
|
return installed;
|
|
157
141
|
},
|
|
158
142
|
});
|
|
159
143
|
}
|
|
160
|
-
async function
|
|
161
|
-
|
|
162
|
-
const configured = await probePython({ executable: options.python, args: [] }, options);
|
|
163
|
-
if (configured && pythonSatisfiesRequirements(configured, options))
|
|
164
|
-
return configured;
|
|
165
|
-
throw new Error(`Configured Python ${options.python} is unavailable or does not satisfy ${options.pythonRange}.`);
|
|
166
|
-
}
|
|
167
|
-
const existingUv = await findAvailableUv(options);
|
|
168
|
-
if (existingUv) {
|
|
169
|
-
const uvPython = await findPythonWithUv(existingUv, options);
|
|
170
|
-
if (uvPython)
|
|
171
|
-
return uvPython;
|
|
172
|
-
}
|
|
173
|
-
return findPython({ ...options, python: '' });
|
|
174
|
-
}
|
|
175
|
-
async function findPythonWithUv(uv, options, signal) {
|
|
176
|
-
const requests = [undefined, options.managedPython];
|
|
177
|
-
for (const requestedPython of requests) {
|
|
144
|
+
async function findPythonWithUv(uv, options, source) {
|
|
145
|
+
for (const request of ['', options.managedPython]) {
|
|
178
146
|
const args = [
|
|
179
147
|
'python',
|
|
180
148
|
'find',
|
|
181
149
|
'--no-project',
|
|
182
150
|
'--no-config',
|
|
183
|
-
'--managed-python',
|
|
184
151
|
'--no-python-downloads',
|
|
152
|
+
source === 'system' ? '--system' : '--managed-python',
|
|
185
153
|
];
|
|
186
|
-
if (
|
|
187
|
-
args.push(
|
|
154
|
+
if (request)
|
|
155
|
+
args.push(request);
|
|
188
156
|
try {
|
|
189
|
-
const { stdout } = await runAsync(uv.executable, args,
|
|
157
|
+
const { stdout } = await runAsync(uv.executable, args, explicitPythonSourceEnvironment(options), options.probeTimeoutMs);
|
|
190
158
|
const info = await probePython({ executable: stdout.trim(), args: [] }, options);
|
|
191
|
-
if (info &&
|
|
159
|
+
if (info && pythonSatisfiesPolicy(info, options))
|
|
192
160
|
return info;
|
|
193
161
|
}
|
|
194
|
-
catch
|
|
195
|
-
|
|
196
|
-
throw signal.reason ?? error;
|
|
197
|
-
// Continue through the configured request and then normal system discovery.
|
|
162
|
+
catch {
|
|
163
|
+
// Continue through the generic and configured version requests.
|
|
198
164
|
}
|
|
199
165
|
}
|
|
166
|
+
// `uv python find` returns one match. That match can lack development
|
|
167
|
+
// headers even when another installed interpreter satisfies every requested
|
|
168
|
+
// capability, so inspect uv's complete installed-only inventory before
|
|
169
|
+
// moving from system Python to managed Python or from cache to download.
|
|
170
|
+
try {
|
|
171
|
+
const { stdout } = await runAsync(uv.executable, [
|
|
172
|
+
'python',
|
|
173
|
+
'list',
|
|
174
|
+
'--no-config',
|
|
175
|
+
'--no-python-downloads',
|
|
176
|
+
'--only-installed',
|
|
177
|
+
'--output-format',
|
|
178
|
+
'json',
|
|
179
|
+
source === 'system' ? '--no-managed-python' : '--managed-python',
|
|
180
|
+
], explicitPythonSourceEnvironment(options), options.probeTimeoutMs);
|
|
181
|
+
const candidates = installedPythonPaths(stdout);
|
|
182
|
+
for (const executable of candidates) {
|
|
183
|
+
const info = await probePython({ executable, args: [] }, options);
|
|
184
|
+
if (info && !info.virtualEnvironment && pythonSatisfiesPolicy(info, options)) {
|
|
185
|
+
return info;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Older compatible uv releases can still use the find-based fallback.
|
|
191
|
+
}
|
|
200
192
|
return null;
|
|
201
193
|
}
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
205
|
-
return
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
194
|
+
function installedPythonPaths(output) {
|
|
195
|
+
const parsed = JSON.parse(output);
|
|
196
|
+
if (!Array.isArray(parsed))
|
|
197
|
+
return [];
|
|
198
|
+
const paths = parsed.flatMap((entry) => {
|
|
199
|
+
if (!entry || typeof entry !== 'object' || !('path' in entry))
|
|
200
|
+
return [];
|
|
201
|
+
const candidate = entry.path;
|
|
202
|
+
return typeof candidate === 'string' && candidate ? [candidate] : [];
|
|
203
|
+
});
|
|
204
|
+
return [...new Map(paths.map((candidate) => [canonicalPath(candidate), candidate])).values()];
|
|
205
|
+
}
|
|
206
|
+
async function inspectVirtualEnvironment(directory, options) {
|
|
207
|
+
const executable = virtualEnvironmentPythonExecutable(directory);
|
|
208
|
+
const info = await probePython({ executable, args: [] }, options);
|
|
209
|
+
return info?.virtualEnvironment && pathsEqual(info.virtualEnvironment, directory) ? info : null;
|
|
210
|
+
}
|
|
211
|
+
async function installRuntimeDependencies(uv, python, dependencies, dependencyOptions, options) {
|
|
212
|
+
if (!python.virtualEnvironment) {
|
|
213
|
+
throw new Error('Runtime dependency installation requires a virtual environment.');
|
|
214
|
+
}
|
|
215
|
+
await withProvisioningLock({
|
|
216
|
+
key: `dependencies:${canonicalPath(python.virtualEnvironment)}`,
|
|
217
|
+
timeoutMs: options.coordinationTimeoutMs,
|
|
218
|
+
check: async () => null,
|
|
219
|
+
mutate: async () => {
|
|
220
|
+
const temporary = await mkdtemp(path.join(os.tmpdir(), 'pyfig-dependencies-'));
|
|
221
|
+
try {
|
|
222
|
+
const additions = path.join(temporary, 'runtime-dependencies.txt');
|
|
223
|
+
const resolved = path.join(temporary, 'resolved-dependencies.txt');
|
|
224
|
+
const pyproject = path.join(options.projectRoot, 'pyproject.toml');
|
|
225
|
+
const hasPyproject = fs.existsSync(pyproject);
|
|
226
|
+
if (!hasPyproject && (dependencyOptions.extras.length > 0 ||
|
|
227
|
+
dependencyOptions.groups.length > 0 ||
|
|
228
|
+
dependencyOptions.allExtras)) {
|
|
229
|
+
throw new Error('Runtime extras and groups require an existing pyproject.toml.');
|
|
230
|
+
}
|
|
231
|
+
const sources = hasPyproject ? [pyproject] : [];
|
|
232
|
+
if (dependencies.length > 0) {
|
|
233
|
+
await writeFile(additions, `${dependencies.join('\n')}\n`, 'utf8');
|
|
234
|
+
sources.push(additions);
|
|
235
|
+
}
|
|
236
|
+
const selectionArgs = [
|
|
237
|
+
...dependencyOptions.extras.flatMap((extra) => ['--extra', extra]),
|
|
238
|
+
...dependencyOptions.groups.flatMap((group) => ['--group', group]),
|
|
239
|
+
...(dependencyOptions.allExtras ? ['--all-extras'] : []),
|
|
240
|
+
];
|
|
241
|
+
await runAsync(uv.executable, [
|
|
242
|
+
'pip', 'compile', '--no-config', '--python', python.executable, ...selectionArgs,
|
|
243
|
+
...sources, '--output-file', resolved,
|
|
244
|
+
], targetUvEnvironment(options, python), options.installTimeoutMs, undefined, options.projectRoot);
|
|
245
|
+
// install preserves unrelated packages; sync would remove anything not
|
|
246
|
+
// represented in this temporary resolution.
|
|
247
|
+
await runAsync(uv.executable, ['pip', 'install', '--no-config', '--python', python.executable, '-r', resolved], targetUvEnvironment(options, python), options.installTimeoutMs, undefined, options.projectRoot);
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
await rm(temporary, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
return true;
|
|
253
|
+
},
|
|
254
|
+
});
|
|
215
255
|
}
|
|
216
256
|
async function ensureUvResolved(options) {
|
|
217
257
|
const existing = await findAvailableUv(options);
|
|
@@ -222,15 +262,12 @@ async function ensureUvResolved(options) {
|
|
|
222
262
|
}
|
|
223
263
|
const installDir = uvInstallDirectory(options);
|
|
224
264
|
const executable = uvExecutable(options);
|
|
225
|
-
const lockTarget = cacheLockTarget(options.cacheDir, 'uv');
|
|
226
265
|
return withProvisioningLock({
|
|
227
|
-
key: `uv:${canonicalPath(
|
|
228
|
-
target: lockTarget,
|
|
266
|
+
key: `uv:${canonicalPath(installDir)}`,
|
|
229
267
|
timeoutMs: options.coordinationTimeoutMs,
|
|
230
|
-
env: options.env,
|
|
231
268
|
check: () => findAvailableUv(options),
|
|
232
|
-
mutate: async (
|
|
233
|
-
await installUv(installDir, options
|
|
269
|
+
mutate: async () => {
|
|
270
|
+
await installUv(installDir, options);
|
|
234
271
|
const result = probeUv(executable, options);
|
|
235
272
|
if (!result || result.version !== options.uvVersion) {
|
|
236
273
|
throw new Error(`Expected managed uv ${options.uvVersion}, but the installed executable did not match.`);
|
|
@@ -240,64 +277,100 @@ async function ensureUvResolved(options) {
|
|
|
240
277
|
});
|
|
241
278
|
}
|
|
242
279
|
async function findAvailableUv(options) {
|
|
243
|
-
const
|
|
244
|
-
if (
|
|
245
|
-
return
|
|
280
|
+
const external = await findUv(options);
|
|
281
|
+
if (external)
|
|
282
|
+
return external;
|
|
246
283
|
const managed = probeUv(uvExecutable(options), options);
|
|
247
284
|
return managed?.version === options.uvVersion ? managed : null;
|
|
248
285
|
}
|
|
249
|
-
function
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
286
|
+
function normalizeDependencies(dependencies) {
|
|
287
|
+
const normalized = dependencies.map((dependency) => dependency.trim());
|
|
288
|
+
for (const dependency of normalized) {
|
|
289
|
+
if (!dependency || /[\0\r\n]/u.test(dependency)) {
|
|
290
|
+
throw new TypeError(`Invalid runtime dependency: ${JSON.stringify(dependency)}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return [...new Set(normalized)];
|
|
254
294
|
}
|
|
255
|
-
function
|
|
256
|
-
|
|
257
|
-
|
|
295
|
+
function normalizeDependencyOptions(options) {
|
|
296
|
+
const extras = normalizeNames('extra', options.extras ?? []);
|
|
297
|
+
const groups = normalizeNames('dependency group', options.groups ?? []);
|
|
298
|
+
if (options.allExtras && extras.length > 0) {
|
|
299
|
+
throw new TypeError('allExtras cannot be combined with explicit extras.');
|
|
258
300
|
}
|
|
259
|
-
return {
|
|
260
|
-
kind: 'uv',
|
|
261
|
-
executable: uv.executable,
|
|
262
|
-
prefixArgs: ['pip'],
|
|
263
|
-
version: uv.version,
|
|
264
|
-
pythonExecutable: python.executable,
|
|
265
|
-
virtualEnvironment: python.virtualEnvironment,
|
|
266
|
-
};
|
|
301
|
+
return { extras, groups, allExtras: options.allExtras ?? false };
|
|
267
302
|
}
|
|
268
|
-
function
|
|
269
|
-
const
|
|
270
|
-
for (const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
normalized === 'PYTHONHOME') {
|
|
274
|
-
delete result[key];
|
|
303
|
+
function normalizeNames(kind, names) {
|
|
304
|
+
const normalized = names.map((name) => name.trim());
|
|
305
|
+
for (const name of normalized) {
|
|
306
|
+
if (!name || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(name)) {
|
|
307
|
+
throw new TypeError(`Invalid ${kind}: ${JSON.stringify(name)}`);
|
|
275
308
|
}
|
|
276
309
|
}
|
|
277
|
-
return
|
|
310
|
+
return [...new Set(normalized)];
|
|
311
|
+
}
|
|
312
|
+
function isManagedEnvironment(python, options) {
|
|
313
|
+
return Boolean(python.virtualEnvironment) && pathsEqual(python.virtualEnvironment, virtualEnvironmentDirectory(options.projectRoot));
|
|
314
|
+
}
|
|
315
|
+
function isolatedUvEnvironment(options) {
|
|
316
|
+
return sanitizeUvEnvironment(uvEnvironment(options));
|
|
317
|
+
}
|
|
318
|
+
/** Explicit --system/--managed-python selectors cannot be combined with uv's preference setting. */
|
|
319
|
+
function explicitPythonSourceEnvironment(options) {
|
|
320
|
+
const env = isolatedUvEnvironment(options);
|
|
321
|
+
delete env['UV_PYTHON_PREFERENCE'];
|
|
322
|
+
return env;
|
|
323
|
+
}
|
|
324
|
+
function targetUvEnvironment(options, python) {
|
|
325
|
+
return uvCommandEnvironment(uvEnvironment(options), python);
|
|
326
|
+
}
|
|
327
|
+
function incompatibleConfiguredPython(options) {
|
|
328
|
+
return new Error(`Configured Python ${options.python} is unavailable or does not satisfy ` +
|
|
329
|
+
`requires-python ${options.requiresPython}` +
|
|
330
|
+
`${options.requireEmbedding ? ' with CPython embedding files' : ''}.`);
|
|
331
|
+
}
|
|
332
|
+
function missingPythonMessage(options, reason) {
|
|
333
|
+
const embedding = options.requireEmbedding
|
|
334
|
+
? ' with compatible CPython headers, library, architecture, and GIL ABI'
|
|
335
|
+
: '';
|
|
336
|
+
return `No Python satisfies requires-python ${options.requiresPython}${embedding}; ${reason}.`;
|
|
337
|
+
}
|
|
338
|
+
async function virtualEnvironmentMatchesConfiguredPython(virtualEnvironment, options) {
|
|
339
|
+
if (!options.python)
|
|
340
|
+
return true;
|
|
341
|
+
const configured = await probePython({ executable: options.python, args: [] }, options);
|
|
342
|
+
if (!configured || !pythonSatisfiesPolicy(configured, options))
|
|
343
|
+
return false;
|
|
344
|
+
if (configured.virtualEnvironment && pathsEqual(configured.executable, virtualEnvironment.executable)) {
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
const configuredBase = configured.virtualEnvironment
|
|
348
|
+
? configured.baseExecutable
|
|
349
|
+
: configured.executable;
|
|
350
|
+
return pathsEqual(configuredBase, virtualEnvironment.baseExecutable);
|
|
351
|
+
}
|
|
352
|
+
function uvInstallDirectory(options) {
|
|
353
|
+
return path.join(options.cacheDir, 'tools', 'uv', options.uvVersion);
|
|
278
354
|
}
|
|
279
|
-
|
|
355
|
+
function uvExecutable(options) {
|
|
356
|
+
return path.join(uvInstallDirectory(options), process.platform === 'win32' ? 'uv.exe' : 'uv');
|
|
357
|
+
}
|
|
358
|
+
async function installUv(installDir, options) {
|
|
280
359
|
const asset = uvAsset();
|
|
281
360
|
const baseUrl = `https://github.com/astral-sh/uv/releases/download/${options.uvVersion}`;
|
|
282
361
|
const temporary = await mkdtemp(path.join(os.tmpdir(), 'pyfig-uv-'));
|
|
283
362
|
const dispatcher = proxyDispatcher(options.env);
|
|
284
363
|
try {
|
|
285
364
|
const archive = path.join(temporary, asset);
|
|
286
|
-
await download(`${baseUrl}/${asset}`, archive, options.downloadTimeoutMs,
|
|
287
|
-
const expected = (await downloadText(`${baseUrl}/${asset}.sha256`, options.downloadTimeoutMs,
|
|
365
|
+
await download(`${baseUrl}/${asset}`, archive, options.downloadTimeoutMs, dispatcher);
|
|
366
|
+
const expected = (await downloadText(`${baseUrl}/${asset}.sha256`, options.downloadTimeoutMs, dispatcher)).trim().split(/\s+/u)[0];
|
|
288
367
|
const actual = await sha256File(archive);
|
|
289
368
|
if (!expected || expected.toLowerCase() !== actual.toLowerCase()) {
|
|
290
369
|
throw new Error(`uv archive checksum mismatch for ${asset}`);
|
|
291
370
|
}
|
|
292
371
|
const extracted = path.join(temporary, 'extracted');
|
|
293
372
|
await mkdir(extracted, { recursive: true });
|
|
294
|
-
|
|
295
|
-
if (asset.endsWith('.zip'))
|
|
296
|
-
await extractZip(archive, { dir: extracted });
|
|
297
|
-
else
|
|
298
|
-
await tar.x({ file: archive, cwd: extracted, strict: true });
|
|
299
|
-
signal.throwIfAborted();
|
|
300
|
-
const source = await findFile(extracted, process.platform === 'win32' ? 'uv.exe' : 'uv');
|
|
373
|
+
const source = await extractUvExecutable(archive, asset, extracted);
|
|
301
374
|
if (!source)
|
|
302
375
|
throw new Error(`uv archive did not contain the expected executable: ${asset}`);
|
|
303
376
|
await publishUvAtomically(source, installDir, options);
|
|
@@ -307,6 +380,26 @@ async function installUv(installDir, options, signal) {
|
|
|
307
380
|
await rm(temporary, { recursive: true, force: true });
|
|
308
381
|
}
|
|
309
382
|
}
|
|
383
|
+
async function extractUvExecutable(archive, asset, destination) {
|
|
384
|
+
const executableName = process.platform === 'win32' ? 'uv.exe' : 'uv';
|
|
385
|
+
if (!asset.endsWith('.zip')) {
|
|
386
|
+
await tar.x({ file: archive, cwd: destination, strict: true });
|
|
387
|
+
return findFile(destination, executableName);
|
|
388
|
+
}
|
|
389
|
+
// ZIP entry paths are never written to disk. Extract only uv's bytes and
|
|
390
|
+
// publish them at a path controlled by Pyfig, which prevents Zip Slip and
|
|
391
|
+
// symlink entries from redirecting writes outside the temporary directory.
|
|
392
|
+
const entries = unzipSync(await readFile(archive), {
|
|
393
|
+
filter: ({ name }) => path.posix.basename(name.replaceAll('\\', '/')) === executableName,
|
|
394
|
+
});
|
|
395
|
+
const matches = Object.entries(entries)
|
|
396
|
+
.filter(([name]) => path.posix.basename(name.replaceAll('\\', '/')) === executableName);
|
|
397
|
+
if (matches.length !== 1 || !matches[0])
|
|
398
|
+
return null;
|
|
399
|
+
const source = path.join(destination, executableName);
|
|
400
|
+
await writeFile(source, matches[0][1], { flag: 'wx' });
|
|
401
|
+
return source;
|
|
402
|
+
}
|
|
310
403
|
async function publishUvAtomically(source, installDir, options) {
|
|
311
404
|
await mkdir(installDir, { recursive: true });
|
|
312
405
|
const executable = uvExecutable(options);
|
|
@@ -339,16 +432,15 @@ function uvAsset() {
|
|
|
339
432
|
return `uv-${architecture}-pc-windows-msvc.zip`;
|
|
340
433
|
if (process.platform === 'darwin')
|
|
341
434
|
return `uv-${architecture}-apple-darwin.tar.gz`;
|
|
342
|
-
// Astral's musl artifacts are fully static and run on both musl and glibc Linux.
|
|
343
435
|
if (process.platform === 'linux')
|
|
344
436
|
return `uv-${architecture}-unknown-linux-musl.tar.gz`;
|
|
345
437
|
throw new Error(`uv bootstrap does not support platform ${process.platform}.`);
|
|
346
438
|
}
|
|
347
|
-
async function download(url, destination, timeoutMs,
|
|
439
|
+
async function download(url, destination, timeoutMs, dispatcher) {
|
|
348
440
|
const response = await fetch(url, {
|
|
349
441
|
dispatcher,
|
|
350
442
|
redirect: 'follow',
|
|
351
|
-
signal:
|
|
443
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
352
444
|
});
|
|
353
445
|
if (!response.ok || !response.body) {
|
|
354
446
|
await response.body?.cancel();
|
|
@@ -356,11 +448,11 @@ async function download(url, destination, timeoutMs, externalSignal, dispatcher)
|
|
|
356
448
|
}
|
|
357
449
|
await pipeline(Readable.fromWeb(response.body), createWriteStream(destination, { flags: 'wx' }));
|
|
358
450
|
}
|
|
359
|
-
async function downloadText(url, timeoutMs,
|
|
451
|
+
async function downloadText(url, timeoutMs, dispatcher) {
|
|
360
452
|
const response = await fetch(url, {
|
|
361
453
|
dispatcher,
|
|
362
454
|
redirect: 'follow',
|
|
363
|
-
signal:
|
|
455
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
364
456
|
});
|
|
365
457
|
if (!response.ok) {
|
|
366
458
|
await response.body?.cancel();
|
|
@@ -382,9 +474,6 @@ function proxyDispatcher(env) {
|
|
|
382
474
|
function environmentValue(env, lowercase, uppercase) {
|
|
383
475
|
return env[lowercase] ?? env[uppercase];
|
|
384
476
|
}
|
|
385
|
-
function combinedSignal(timeoutMs, externalSignal) {
|
|
386
|
-
return AbortSignal.any([externalSignal, AbortSignal.timeout(timeoutMs)]);
|
|
387
|
-
}
|
|
388
477
|
async function sha256File(file) {
|
|
389
478
|
const hash = createHash('sha256');
|
|
390
479
|
for await (const chunk of createReadStream(file))
|
|
@@ -404,31 +493,4 @@ async function findFile(directory, name) {
|
|
|
404
493
|
}
|
|
405
494
|
return null;
|
|
406
495
|
}
|
|
407
|
-
function incompatibleVirtualEnvironmentError(directory, python, options) {
|
|
408
|
-
const capabilities = options.requireEmbedding
|
|
409
|
-
? ' or lacks compatible CPython embedding headers, library, or architecture'
|
|
410
|
-
: '';
|
|
411
|
-
return new Error(`The existing virtual environment at ${directory} uses Python ${python.version}, ` +
|
|
412
|
-
`which does not satisfy ${options.pythonRange}${capabilities}.`);
|
|
413
|
-
}
|
|
414
|
-
async function virtualEnvironmentMatchesConfiguredPython(virtualEnvironment, options) {
|
|
415
|
-
if (!options.python)
|
|
416
|
-
return true;
|
|
417
|
-
const configured = await probePython({ executable: options.python, args: [] }, options);
|
|
418
|
-
if (!configured || !pythonSatisfiesRequirements(configured, options))
|
|
419
|
-
return false;
|
|
420
|
-
if (configured.virtualEnvironment && pathsEqual(configured.executable, virtualEnvironment.executable)) {
|
|
421
|
-
return true;
|
|
422
|
-
}
|
|
423
|
-
const configuredBase = configured.virtualEnvironment
|
|
424
|
-
? configured.baseExecutable
|
|
425
|
-
: configured.executable;
|
|
426
|
-
return pathsEqual(configuredBase, virtualEnvironment.baseExecutable);
|
|
427
|
-
}
|
|
428
|
-
function pythonIsPresent(python) {
|
|
429
|
-
return fs.existsSync(python.executable);
|
|
430
|
-
}
|
|
431
|
-
function environmentIsPresent(environment) {
|
|
432
|
-
return pythonIsPresent(environment.python) && fs.existsSync(environment.packageManager.executable);
|
|
433
|
-
}
|
|
434
496
|
//# sourceMappingURL=provision.js.map
|