pyfig 0.3.0 → 0.4.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 (63) hide show
  1. package/README.md +112 -121
  2. package/SECURITY.md +1 -1
  3. package/dist/cjs/index.js +469 -871
  4. package/dist/cjs/index.js.map +14 -15
  5. package/dist/cjs/sync-resolver.js +426 -657
  6. package/dist/cjs/sync-resolver.js.map +12 -12
  7. package/dist/cli.js +5 -8
  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 +4 -13
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +7 -51
  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 +0 -18
  26. package/dist/probes.d.ts.map +1 -1
  27. package/dist/probes.js +0 -70
  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 +273 -228
  44. package/dist/provision.js.map +1 -1
  45. package/dist/runners.d.ts +3 -7
  46. package/dist/runners.d.ts.map +1 -1
  47. package/dist/runners.js +35 -115
  48. package/dist/runners.js.map +1 -1
  49. package/dist/sync-resolver.js +4 -13
  50. package/dist/sync-resolver.js.map +1 -1
  51. package/dist/virtual-environment.d.ts +3 -1
  52. package/dist/virtual-environment.d.ts.map +1 -1
  53. package/dist/virtual-environment.js +8 -1
  54. package/dist/virtual-environment.js.map +1 -1
  55. package/package.json +6 -8
  56. package/dist/coordination.d.ts +0 -25
  57. package/dist/coordination.d.ts.map +0 -1
  58. package/dist/coordination.js +0 -187
  59. package/dist/coordination.js.map +0 -1
  60. package/dist/state.d.ts +0 -10
  61. package/dist/state.d.ts.map +0 -1
  62. package/dist/state.js +0 -62
  63. package/dist/state.js.map +0 -1
package/dist/provision.js CHANGED
@@ -1,6 +1,6 @@
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, 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';
@@ -9,209 +9,249 @@ import { pipeline } from 'node:stream/promises';
9
9
  import extractZip from 'extract-zip';
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, pythonSatisfiesRequirements, } 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 requirements.
24
+ * Project dependency files are inputs only and are never changed.
25
+ */
26
+ export async function ensureEnvironment(options = {}, requirements = [], 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 = normalizeRequirements(requirements);
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 installRuntimeRequirements(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 && pythonSatisfiesRequirements(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 || !pythonSatisfiesRequirements(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 && pythonSatisfiesRequirements(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 || !pythonSatisfiesRequirements(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 && pythonSatisfiesRequirements(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
159
  if (info && pythonSatisfiesRequirements(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 && pythonSatisfiesRequirements(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 installRuntimeRequirements(uv, python, requirements, 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-requirements-'));
221
+ try {
222
+ const additions = path.join(temporary, 'runtime-requirements.txt');
223
+ const resolved = path.join(temporary, 'resolved-requirements.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 (requirements.length > 0) {
233
+ await writeFile(additions, `${requirements.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,63 +277,102 @@ 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 normalizeRequirements(requirements) {
287
+ const normalized = requirements.map((requirement) => requirement.trim());
288
+ for (const requirement of normalized) {
289
+ if (!requirement || /[\0\r\n]/u.test(requirement)) {
290
+ throw new TypeError(`Invalid runtime requirement: ${JSON.stringify(requirement)}`);
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
+ `${options.pythonRange}${options.requireEmbedding ? ' with CPython embedding files' : ''}.`);
330
+ }
331
+ function missingPythonMessage(options, reason) {
332
+ const embedding = options.requireEmbedding
333
+ ? ' with compatible CPython headers, library, architecture, and GIL ABI'
334
+ : '';
335
+ return `No Python satisfies ${options.pythonRange}${embedding}; ${reason}.`;
278
336
  }
279
- async function installUv(installDir, options, signal) {
337
+ async function virtualEnvironmentMatchesConfiguredPython(virtualEnvironment, options) {
338
+ if (!options.python)
339
+ return true;
340
+ const configured = await probePython({ executable: options.python, args: [] }, options);
341
+ if (!configured || !pythonSatisfiesRequirements(configured, options))
342
+ return false;
343
+ if (configured.virtualEnvironment && pathsEqual(configured.executable, virtualEnvironment.executable)) {
344
+ return true;
345
+ }
346
+ const configuredBase = configured.virtualEnvironment
347
+ ? configured.baseExecutable
348
+ : configured.executable;
349
+ return pathsEqual(configuredBase, virtualEnvironment.baseExecutable);
350
+ }
351
+ function uvInstallDirectory(options) {
352
+ return path.join(options.cacheDir, 'tools', 'uv', options.uvVersion);
353
+ }
354
+ function uvExecutable(options) {
355
+ return path.join(uvInstallDirectory(options), process.platform === 'win32' ? 'uv.exe' : 'uv');
356
+ }
357
+ async function installUv(installDir, options) {
280
358
  const asset = uvAsset();
281
359
  const baseUrl = `https://github.com/astral-sh/uv/releases/download/${options.uvVersion}`;
282
360
  const temporary = await mkdtemp(path.join(os.tmpdir(), 'pyfig-uv-'));
283
361
  const dispatcher = proxyDispatcher(options.env);
284
362
  try {
285
363
  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];
364
+ await download(`${baseUrl}/${asset}`, archive, options.downloadTimeoutMs, dispatcher);
365
+ const expected = (await downloadText(`${baseUrl}/${asset}.sha256`, options.downloadTimeoutMs, dispatcher)).trim().split(/\s+/u)[0];
288
366
  const actual = await sha256File(archive);
289
367
  if (!expected || expected.toLowerCase() !== actual.toLowerCase()) {
290
368
  throw new Error(`uv archive checksum mismatch for ${asset}`);
291
369
  }
292
370
  const extracted = path.join(temporary, 'extracted');
293
371
  await mkdir(extracted, { recursive: true });
294
- signal.throwIfAborted();
295
372
  if (asset.endsWith('.zip'))
296
373
  await extractZip(archive, { dir: extracted });
297
374
  else
298
375
  await tar.x({ file: archive, cwd: extracted, strict: true });
299
- signal.throwIfAborted();
300
376
  const source = await findFile(extracted, process.platform === 'win32' ? 'uv.exe' : 'uv');
301
377
  if (!source)
302
378
  throw new Error(`uv archive did not contain the expected executable: ${asset}`);
@@ -339,16 +415,15 @@ function uvAsset() {
339
415
  return `uv-${architecture}-pc-windows-msvc.zip`;
340
416
  if (process.platform === 'darwin')
341
417
  return `uv-${architecture}-apple-darwin.tar.gz`;
342
- // Astral's musl artifacts are fully static and run on both musl and glibc Linux.
343
418
  if (process.platform === 'linux')
344
419
  return `uv-${architecture}-unknown-linux-musl.tar.gz`;
345
420
  throw new Error(`uv bootstrap does not support platform ${process.platform}.`);
346
421
  }
347
- async function download(url, destination, timeoutMs, externalSignal, dispatcher) {
422
+ async function download(url, destination, timeoutMs, dispatcher) {
348
423
  const response = await fetch(url, {
349
424
  dispatcher,
350
425
  redirect: 'follow',
351
- signal: combinedSignal(timeoutMs, externalSignal),
426
+ signal: AbortSignal.timeout(timeoutMs),
352
427
  });
353
428
  if (!response.ok || !response.body) {
354
429
  await response.body?.cancel();
@@ -356,11 +431,11 @@ async function download(url, destination, timeoutMs, externalSignal, dispatcher)
356
431
  }
357
432
  await pipeline(Readable.fromWeb(response.body), createWriteStream(destination, { flags: 'wx' }));
358
433
  }
359
- async function downloadText(url, timeoutMs, externalSignal, dispatcher) {
434
+ async function downloadText(url, timeoutMs, dispatcher) {
360
435
  const response = await fetch(url, {
361
436
  dispatcher,
362
437
  redirect: 'follow',
363
- signal: combinedSignal(timeoutMs, externalSignal),
438
+ signal: AbortSignal.timeout(timeoutMs),
364
439
  });
365
440
  if (!response.ok) {
366
441
  await response.body?.cancel();
@@ -382,9 +457,6 @@ function proxyDispatcher(env) {
382
457
  function environmentValue(env, lowercase, uppercase) {
383
458
  return env[lowercase] ?? env[uppercase];
384
459
  }
385
- function combinedSignal(timeoutMs, externalSignal) {
386
- return AbortSignal.any([externalSignal, AbortSignal.timeout(timeoutMs)]);
387
- }
388
460
  async function sha256File(file) {
389
461
  const hash = createHash('sha256');
390
462
  for await (const chunk of createReadStream(file))
@@ -404,31 +476,4 @@ async function findFile(directory, name) {
404
476
  }
405
477
  return null;
406
478
  }
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
479
  //# sourceMappingURL=provision.js.map