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.
Files changed (67) hide show
  1. package/README.md +116 -123
  2. package/SECURITY.md +2 -2
  3. package/dist/cjs/index.js +525 -888
  4. package/dist/cjs/index.js.map +15 -15
  5. package/dist/cjs/sync-resolver.js +482 -676
  6. package/dist/cjs/sync-resolver.js.map +13 -12
  7. package/dist/cli.js +7 -10
  8. package/dist/cli.js.map +1 -1
  9. package/dist/client.d.ts +3 -8
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +13 -11
  12. package/dist/client.js.map +1 -1
  13. package/dist/config.d.ts +9 -18
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +22 -59
  16. package/dist/config.js.map +1 -1
  17. package/dist/index.d.ts +5 -6
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -4
  20. package/dist/index.js.map +1 -1
  21. package/dist/package-target.d.ts +7 -11
  22. package/dist/package-target.d.ts.map +1 -1
  23. package/dist/package-target.js +41 -57
  24. package/dist/package-target.js.map +1 -1
  25. package/dist/probes.d.ts +1 -19
  26. package/dist/probes.d.ts.map +1 -1
  27. package/dist/probes.js +13 -74
  28. package/dist/probes.js.map +1 -1
  29. package/dist/process.d.ts +1 -1
  30. package/dist/process.d.ts.map +1 -1
  31. package/dist/process.js +2 -1
  32. package/dist/process.js.map +1 -1
  33. package/dist/project-root.d.ts +1 -1
  34. package/dist/project-root.d.ts.map +1 -1
  35. package/dist/project-root.js +3 -64
  36. package/dist/project-root.js.map +1 -1
  37. package/dist/provision-lock.d.ts +10 -0
  38. package/dist/provision-lock.d.ts.map +1 -0
  39. package/dist/provision-lock.js +24 -0
  40. package/dist/provision-lock.js.map +1 -0
  41. package/dist/provision.d.ts +18 -6
  42. package/dist/provision.d.ts.map +1 -1
  43. package/dist/provision.js +297 -235
  44. package/dist/provision.js.map +1 -1
  45. package/dist/python-version.d.ts +10 -0
  46. package/dist/python-version.d.ts.map +1 -0
  47. package/dist/python-version.js +17 -0
  48. package/dist/python-version.js.map +1 -0
  49. package/dist/runners.d.ts +3 -7
  50. package/dist/runners.d.ts.map +1 -1
  51. package/dist/runners.js +35 -115
  52. package/dist/runners.js.map +1 -1
  53. package/dist/sync-resolver.js +4 -13
  54. package/dist/sync-resolver.js.map +1 -1
  55. package/dist/virtual-environment.d.ts +3 -1
  56. package/dist/virtual-environment.d.ts.map +1 -1
  57. package/dist/virtual-environment.js +8 -1
  58. package/dist/virtual-environment.js.map +1 -1
  59. package/package.json +9 -10
  60. package/dist/coordination.d.ts +0 -25
  61. package/dist/coordination.d.ts.map +0 -1
  62. package/dist/coordination.js +0 -187
  63. package/dist/coordination.js.map +0 -1
  64. package/dist/state.d.ts +0 -10
  65. package/dist/state.d.ts.map +0 -1
  66. package/dist/state.js +0 -62
  67. 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 extractZip from 'extract-zip';
9
+ import { unzipSync } from 'fflate';
10
10
  import * as tar from 'tar';
11
11
  import { EnvHttpProxyAgent, fetch } from 'undici';
12
- import { configurationFingerprint, resolveOptions, uvEnvironment, } from './config.js';
13
- import { cacheLockTarget, withProvisioningLock } from './coordination.js';
14
- import { findPip, findPython, findUv, probePython, probeUv, pythonSatisfiesRequirements, } from './probes.js';
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 { memoizeAsync } from './state.js';
17
- import { canonicalPath, pathsEqual, virtualEnvironmentDirectory, virtualEnvironmentPythonExecutable, } from './virtual-environment.js';
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
- const resolved = resolveOptions(options);
37
- return memoizeAsync('uv', configurationFingerprint(resolved), resolved.cacheTtlMs, (uv) => fs.existsSync(uv.executable), () => ensureUvResolved(resolved));
20
+ return ensureUvResolved(resolveOptions(options));
38
21
  }
39
- export async function ensureEnvironment(options = {}) {
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
- return memoizeAsync('environment', configurationFingerprint(resolved), resolved.cacheTtlMs, environmentIsPresent, async () => {
42
- const python = await ensureVirtualEnvironmentResolved(resolved);
43
- const packageManager = await ensurePipResolved(python, resolved);
44
- return { python, packageManager };
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 existing = await inspectVirtualEnvironment(directory, options);
51
- if (!existing)
52
- return null;
53
- if (pythonSatisfiesRequirements(existing, options) &&
54
- await virtualEnvironmentMatchesConfiguredPython(existing, options)) {
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 virtual environment at ${directory} satisfies the Python requirements; ` +
64
- 'virtual-environment creation is disabled.');
55
+ throw new Error(`No compatible environment is available and creation is disabled. ` +
56
+ `Pyfig would create ${directory}.`);
65
57
  }
66
- // Base discovery happens before taking the venv lock, keeping the global lock
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 (signal) => {
78
- const entries = await readdir(directory);
79
- const hasVenvConfiguration = fs.existsSync(path.join(directory, 'pyvenv.cfg'));
80
- if (entries.length > 0 && !hasVenvConfiguration) {
81
- throw new Error(`Refusing to replace non-virtual-environment contents at ${directory}.`);
82
- }
83
- await createVirtualEnvironment(directory, basePython, preferredUv, options, signal);
84
- const installed = await inspectVirtualEnvironment(directory, options);
85
- if (!installed || !pythonSatisfiesRequirements(installed, options)) {
86
- throw new Error(`Python created ${directory}, but it does not satisfy the requested runtime capabilities.`);
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 createVirtualEnvironment(directory, basePython, preferredUv, options, signal) {
93
- if (preferredUv) {
94
- try {
95
- await createVirtualEnvironmentWithUv(preferredUv, directory, basePython, options, signal);
96
- return;
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
- try {
103
- await runAsync(basePython.executable, ['-m', 'venv', directory], withoutActiveVirtualEnvironment(options.env), options.installTimeoutMs, signal);
104
- return;
105
- }
106
- catch (systemVenvError) {
107
- if (preferredUv || !options.installUv)
108
- throw systemVenvError;
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
- throw new Error('Python could not create a virtual environment and uv installation is disabled.');
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 ensurePythonResolved(options) {
130
- const existing = await discoverPython(options);
131
- if (existing)
132
- return existing;
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
- const capability = options.requireEmbedding
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(lockTarget)}`,
143
- target: lockTarget,
131
+ key: `python:${canonicalPath(path.join(options.cacheDir, 'python'))}`,
144
132
  timeoutMs: options.coordinationTimeoutMs,
145
- env: options.env,
146
- check: () => discoverPython(options),
147
- mutate: async (signal) => {
148
- const uv = await ensureUvResolved(options);
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 Python ${options.managedPython}, but it does not satisfy the requested ` +
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 discoverPython(options) {
161
- if (options.python) {
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 (requestedPython)
187
- args.push(requestedPython);
154
+ if (request)
155
+ args.push(request);
188
156
  try {
189
- const { stdout } = await runAsync(uv.executable, args, uvEnvironment(options), options.probeTimeoutMs, signal);
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 && pythonSatisfiesRequirements(info, options))
159
+ if (info && pythonSatisfiesPolicy(info, options))
192
160
  return info;
193
161
  }
194
- catch (error) {
195
- if (signal?.aborted)
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
- async function ensurePipResolved(python, options) {
203
- const existingUv = await findAvailableUv(options);
204
- if (existingUv)
205
- return uvPackageManager(existingUv, python);
206
- // Default package operations always use uv, which supplies its own robust
207
- // target-venv lock. python -m pip remains a read-only fallback for check-only setups.
208
- if (options.installUv)
209
- return uvPackageManager(await ensureUvResolved(options), python);
210
- const existingPip = await findPip(python, options);
211
- if (existingPip)
212
- return existingPip;
213
- throw new Error(`The virtual environment's python -m pip does not satisfy ${options.pipRange}; ` +
214
- 'uv installation is disabled.');
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(lockTarget)}`,
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 (signal) => {
233
- await installUv(installDir, options, signal);
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 system = await findUv(options);
244
- if (system)
245
- return system;
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 uvInstallDirectory(options) {
250
- return path.join(options.cacheDir, 'tools', 'uv', options.uvVersion);
251
- }
252
- function uvExecutable(options) {
253
- return path.join(uvInstallDirectory(options), process.platform === 'win32' ? 'uv.exe' : 'uv');
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 uvPackageManager(uv, python) {
256
- if (!python.virtualEnvironment) {
257
- throw new Error('uv pip commands require a virtual-environment Python interpreter.');
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 withoutActiveVirtualEnvironment(env) {
269
- const result = { ...env };
270
- for (const key of Object.keys(result)) {
271
- const normalized = key.toUpperCase();
272
- if (normalized === 'CONDA_PREFIX' || normalized === 'VIRTUAL_ENV' ||
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 result;
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
- async function installUv(installDir, options, signal) {
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, signal, dispatcher);
287
- const expected = (await downloadText(`${baseUrl}/${asset}.sha256`, options.downloadTimeoutMs, signal, dispatcher)).trim().split(/\s+/u)[0];
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
- signal.throwIfAborted();
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, externalSignal, dispatcher) {
439
+ async function download(url, destination, timeoutMs, dispatcher) {
348
440
  const response = await fetch(url, {
349
441
  dispatcher,
350
442
  redirect: 'follow',
351
- signal: combinedSignal(timeoutMs, externalSignal),
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, externalSignal, dispatcher) {
451
+ async function downloadText(url, timeoutMs, dispatcher) {
360
452
  const response = await fetch(url, {
361
453
  dispatcher,
362
454
  redirect: 'follow',
363
- signal: combinedSignal(timeoutMs, externalSignal),
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