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
@@ -66,20 +66,19 @@ var __export = (target, all) => {
66
66
  var import_node_fs5 = require("node:fs");
67
67
 
68
68
  // src/provision.ts
69
- var import_node_crypto3 = require("node:crypto");
69
+ var import_node_crypto = require("node:crypto");
70
70
  var import_node_fs3 = require("node:fs");
71
- var import_promises2 = require("node:fs/promises");
71
+ var import_promises = require("node:fs/promises");
72
72
  var import_node_fs4 = __toESM(require("node:fs"), 1);
73
73
  var import_node_os = __toESM(require("node:os"), 1);
74
- var import_node_path7 = __toESM(require("node:path"), 1);
74
+ var import_node_path5 = __toESM(require("node:path"), 1);
75
75
  var import_node_stream = require("node:stream");
76
- var import_promises3 = require("node:stream/promises");
76
+ var import_promises2 = require("node:stream/promises");
77
77
  var import_extract_zip = __toESM(require("extract-zip"), 1);
78
78
  var tar = __toESM(require("tar"), 1);
79
79
  var import_undici = require("undici");
80
80
 
81
81
  // src/config.ts
82
- var import_node_crypto = require("node:crypto");
83
82
  var import_node_path2 = __toESM(require("node:path"), 1);
84
83
  var envPaths = __toESM(require("env-paths"), 1);
85
84
  var import_semver = __toESM(require("semver"), 1);
@@ -87,69 +86,13 @@ var import_semver = __toESM(require("semver"), 1);
87
86
  // src/project-root.ts
88
87
  var import_node_fs = __toESM(require("node:fs"), 1);
89
88
  var import_node_path = __toESM(require("node:path"), 1);
90
- var import_pacwich = require("pacwich");
91
- var PACKAGE_MANAGER_LOCKFILES = [
92
- "bun.lock",
93
- "bun.lockb",
94
- "npm-shrinkwrap.json",
95
- "package-lock.json",
96
- "pnpm-lock.yaml",
97
- "yarn.lock"
98
- ];
99
- var PYTHON_PROJECT_MARKERS = [
100
- "pyproject.toml",
101
- "uv.lock"
102
- ];
103
89
  function resolveProjectRoot(startDirectory = process.cwd()) {
104
90
  const start = import_node_path.default.resolve(startDirectory);
105
- const workspaceRoot = findWorkspaceProjectRoot(start);
106
- if (workspaceRoot) {
107
- try {
108
- return import_node_path.default.resolve(import_pacwich.createFileSystemProject({
109
- rootDirectory: start,
110
- disableExecutableConfigs: true
111
- }).rootDirectory);
112
- } catch {
113
- return workspaceRoot;
114
- }
115
- }
116
- return findConventionalProjectRoot(start);
117
- }
118
- function findWorkspaceProjectRoot(startDirectory) {
119
- for (const directory of ancestors(startDirectory)) {
120
- const packageJson = import_node_path.default.join(directory, "package.json");
121
- if (!import_node_fs.default.existsSync(packageJson))
122
- continue;
123
- if (import_node_fs.default.existsSync(import_node_path.default.join(directory, "pnpm-workspace.yaml")))
124
- return directory;
125
- try {
126
- const manifest = JSON.parse(import_node_fs.default.readFileSync(packageJson, "utf8"));
127
- if (hasWorkspaceDeclaration(manifest.workspaces))
128
- return directory;
129
- } catch {}
130
- }
131
- return null;
132
- }
133
- function findConventionalProjectRoot(startDirectory) {
134
- let nearestPackageDirectory = null;
135
- let nearestPythonDirectory = null;
136
- for (const directory of ancestors(startDirectory)) {
137
- const hasPackage = import_node_fs.default.existsSync(import_node_path.default.join(directory, "package.json"));
138
- if (hasPackage && !nearestPackageDirectory)
139
- nearestPackageDirectory = directory;
140
- if (!nearestPythonDirectory && PYTHON_PROJECT_MARKERS.some((file) => import_node_fs.default.existsSync(import_node_path.default.join(directory, file)))) {
141
- nearestPythonDirectory = directory;
142
- }
143
- if (hasPackage && PACKAGE_MANAGER_LOCKFILES.some((file) => import_node_fs.default.existsSync(import_node_path.default.join(directory, file)))) {
144
- return directory;
145
- }
91
+ for (const directory of ancestors(start)) {
146
92
  if (import_node_fs.default.existsSync(import_node_path.default.join(directory, ".git")))
147
93
  return directory;
148
94
  }
149
- return nearestPackageDirectory ?? nearestPythonDirectory ?? startDirectory;
150
- }
151
- function hasWorkspaceDeclaration(value) {
152
- return Array.isArray(value) || value !== null && typeof value === "object";
95
+ return start;
153
96
  }
154
97
  function* ancestors(startDirectory) {
155
98
  let directory = startDirectory;
@@ -164,14 +107,12 @@ function* ancestors(startDirectory) {
164
107
 
165
108
  // src/config.ts
166
109
  var DEFAULT_PYTHON_RANGE = ">=3.10.0 <4.0.0";
167
- var DEFAULT_PIP_RANGE = ">=20.3.0";
168
110
  var DEFAULT_MANAGED_PYTHON = "3.12";
169
111
  var DEFAULT_UV_VERSION = "0.13.6";
170
- var DEFAULT_UV_RANGE = `>=${DEFAULT_UV_VERSION} <1.0.0`;
112
+ var DEFAULT_UV_RANGE = ">=0.10.0 <1.0.0";
171
113
  var DEFAULT_PROBE_TIMEOUT_MS = 1e4;
172
114
  var DEFAULT_INSTALL_TIMEOUT_MS = 10 * 60000;
173
115
  var DEFAULT_DOWNLOAD_TIMEOUT_MS = 5 * 60000;
174
- var DEFAULT_CACHE_TTL_MS = 30000;
175
116
  var envPathsFunction = (() => {
176
117
  const imported = envPaths.default;
177
118
  const implementation = typeof imported === "function" ? imported : imported.default;
@@ -182,10 +123,8 @@ var envPathsFunction = (() => {
182
123
  function resolveOptions(options = {}) {
183
124
  const env = { ...options.env ?? process.env };
184
125
  const pythonRange = options.pythonRange ?? env["PYFIG_PYTHON_RANGE"] ?? DEFAULT_PYTHON_RANGE;
185
- const pipRange = options.pipRange ?? env["PYFIG_PIP_RANGE"] ?? DEFAULT_PIP_RANGE;
186
126
  const uvRange = options.uvRange ?? env["PYFIG_UV_RANGE"] ?? DEFAULT_UV_RANGE;
187
127
  assertRange("Python", pythonRange);
188
- assertRange("pip", pipRange);
189
128
  assertRange("uv", uvRange);
190
129
  const managedPython = options.managedPython ?? env["PYFIG_MANAGED_PYTHON"] ?? DEFAULT_MANAGED_PYTHON;
191
130
  const uvVersion = options.uvVersion ?? env["PYFIG_UV_VERSION"] ?? DEFAULT_UV_VERSION;
@@ -195,16 +134,14 @@ function resolveOptions(options = {}) {
195
134
  const installTimeoutMs = resolveDuration("installTimeoutMs", options.installTimeoutMs, env["PYFIG_INSTALL_TIMEOUT_MS"], DEFAULT_INSTALL_TIMEOUT_MS);
196
135
  const downloadTimeoutMs = resolveDuration("downloadTimeoutMs", options.downloadTimeoutMs, env["PYFIG_DOWNLOAD_TIMEOUT_MS"], DEFAULT_DOWNLOAD_TIMEOUT_MS);
197
136
  const coordinationTimeoutMs = resolveDuration("coordinationTimeoutMs", options.coordinationTimeoutMs, env["PYFIG_COORDINATION_TIMEOUT_MS"], installTimeoutMs + downloadTimeoutMs + 60000);
198
- const cacheTtlMs = resolveDuration("cacheTtlMs", options.cacheTtlMs, env["PYFIG_CACHE_TTL_MS"], DEFAULT_CACHE_TTL_MS, true);
199
137
  const configuredHome = options.cacheDir ?? env["PYFIG_HOME"];
200
138
  const cacheDir = configuredHome ? import_node_path2.default.resolve(configuredHome) : envPathsFunction("pyfig", { suffix: "" }).cache;
201
139
  const configuredProjectRoot = options.projectRoot ?? env["PYFIG_PROJECT_ROOT"];
202
- const projectRoot = configuredProjectRoot ? import_node_path2.default.resolve(configuredProjectRoot) : resolveProjectRoot();
140
+ const projectRoot = configuredProjectRoot ? import_node_path2.default.resolve(configuredProjectRoot) : resolveProjectRoot(env["INIT_CWD"] ?? process.cwd());
203
141
  const autoInstall = options.autoInstall ?? parseBoolean(env["PYFIG_AUTO_INSTALL"], true);
204
142
  return {
205
143
  python: options.python ?? env["PYFIG_PYTHON"] ?? "",
206
144
  pythonRange,
207
- pipRange,
208
145
  managedPython,
209
146
  uvVersion,
210
147
  uvRange,
@@ -220,40 +157,16 @@ function resolveOptions(options = {}) {
220
157
  installTimeoutMs,
221
158
  downloadTimeoutMs,
222
159
  coordinationTimeoutMs,
223
- cacheTtlMs,
224
160
  env
225
161
  };
226
162
  }
227
- function configurationFingerprint(options) {
228
- const policy = [
229
- normalizedPath(options.projectRoot),
230
- normalizedPath(options.cacheDir),
231
- options.python,
232
- options.pythonRange,
233
- options.pipRange,
234
- options.managedPython,
235
- options.uvVersion,
236
- options.uvRange,
237
- options.requireEmbedding,
238
- options.requireGil,
239
- options.autoInstall,
240
- options.installPython,
241
- options.installUv,
242
- options.createVirtualEnvironment,
243
- options.probeTimeoutMs,
244
- options.installTimeoutMs,
245
- options.downloadTimeoutMs,
246
- options.coordinationTimeoutMs,
247
- options.cacheTtlMs,
248
- environmentFingerprint(options.env)
249
- ];
250
- return import_node_crypto.createHash("sha256").update(JSON.stringify(policy)).digest("hex");
251
- }
252
163
  function uvEnvironment(options) {
253
164
  return {
254
165
  ...options.env,
255
166
  UV_PYTHON_INSTALL_DIR: import_node_path2.default.join(options.cacheDir, "python"),
256
- UV_CACHE_DIR: import_node_path2.default.join(options.cacheDir, "uv-cache")
167
+ UV_CACHE_DIR: import_node_path2.default.join(options.cacheDir, "uv-cache"),
168
+ UV_PYTHON_PREFERENCE: "system",
169
+ UV_NO_CONFIG: "1"
257
170
  };
258
171
  }
259
172
  function assertRange(tool, range) {
@@ -297,34 +210,161 @@ function parseBoolean(value, fallback) {
297
210
  return false;
298
211
  throw new TypeError(`Invalid boolean value: ${value}`);
299
212
  }
300
- function environmentFingerprint(env) {
301
- const entries = Object.entries(env).filter((entry) => entry[1] !== undefined).map(([key, value]) => [
302
- process.platform === "win32" ? key.toUpperCase() : key,
303
- value
304
- ]).sort(([left], [right]) => left.localeCompare(right));
305
- return import_node_crypto.createHash("sha256").update(JSON.stringify(entries)).digest("hex");
213
+
214
+ // src/provision-lock.ts
215
+ var import_loqkit = require("loqkit");
216
+ async function withProvisioningLock(options) {
217
+ let existing;
218
+ const result = await import_loqkit.withLock({ resource: options.key }, options.mutate, {
219
+ key: options.key,
220
+ name: "Pyfig provisioning",
221
+ namespace: "pyfig",
222
+ timeout: options.timeoutMs,
223
+ predicate: async () => {
224
+ const value = await options.check();
225
+ if (value === null)
226
+ return true;
227
+ existing = { value };
228
+ return false;
229
+ }
230
+ });
231
+ if (result.context.state === "executed")
232
+ return result.value;
233
+ if (existing)
234
+ return existing.value;
235
+ throw new Error(`Provisioning check for ${options.key} did not produce a resource.`);
236
+ }
237
+
238
+ // src/virtual-environment.ts
239
+ var import_node_fs2 = __toESM(require("node:fs"), 1);
240
+ var import_node_path3 = __toESM(require("node:path"), 1);
241
+ var VIRTUAL_ENV_DIRECTORY_NAME = import_node_path3.default.join("node_modules", ".pyfig", "venv");
242
+ function virtualEnvironmentDirectory(projectRoot) {
243
+ return import_node_path3.default.join(projectRoot, VIRTUAL_ENV_DIRECTORY_NAME);
244
+ }
245
+ function externalVirtualEnvironmentDirectories(projectRoot, env) {
246
+ const candidates = [env["VIRTUAL_ENV"], import_node_path3.default.join(projectRoot, ".venv")].filter((value) => Boolean(value)).map((value) => import_node_path3.default.resolve(value));
247
+ return [...new Map(candidates.map((value) => [canonicalPath(value), value])).values()];
306
248
  }
307
- function normalizedPath(value) {
308
- const resolved = import_node_path2.default.resolve(value);
309
- return process.platform === "win32" ? resolved.toLowerCase() : resolved;
249
+ function virtualEnvironmentPythonExecutable(virtualEnvironment) {
250
+ return import_node_path3.default.join(virtualEnvironment, process.platform === "win32" ? "Scripts" : "bin", process.platform === "win32" ? "python.exe" : "python");
251
+ }
252
+ function activateVirtualEnvironment(env, virtualEnvironment, pythonExecutable) {
253
+ const activated = { ...env };
254
+ const pathEntry = Object.keys(activated).find((key) => key.toUpperCase() === "PATH");
255
+ const existingPath = pathEntry ? activated[pathEntry] : undefined;
256
+ for (const key of Object.keys(activated)) {
257
+ const normalized = key.toUpperCase();
258
+ if (normalized === "PATH" || normalized === "PYTHONHOME" || normalized === "VIRTUAL_ENV") {
259
+ delete activated[key];
260
+ }
261
+ }
262
+ activated["PATH"] = [import_node_path3.default.dirname(pythonExecutable), existingPath].filter((value) => Boolean(value)).join(import_node_path3.default.delimiter);
263
+ activated["VIRTUAL_ENV"] = virtualEnvironment;
264
+ return activated;
265
+ }
266
+ function pathsEqual(left, right) {
267
+ return canonicalPath(left) === canonicalPath(right);
268
+ }
269
+ function canonicalPath(value) {
270
+ const resolved = import_node_path3.default.resolve(value);
271
+ let canonical = resolved;
272
+ try {
273
+ canonical = import_node_fs2.default.realpathSync.native(resolved);
274
+ } catch {}
275
+ return process.platform === "win32" ? canonical.toLowerCase() : canonical;
310
276
  }
311
277
 
312
- // src/coordination.ts
313
- var import_node_crypto2 = require("node:crypto");
314
- var import_node_child_process2 = require("node:child_process");
315
- var import_promises = require("node:fs/promises");
316
- var import_node_path4 = __toESM(require("node:path"), 1);
317
- var workerThreads = __toESM(require("node:worker_threads"), 1);
318
- var import_proper_lockfile = __toESM(require("proper-lockfile"), 1);
278
+ // src/package-target.ts
279
+ var TARGET_OPTIONS = new Set([
280
+ "--active",
281
+ "--config-file",
282
+ "--directory",
283
+ "--isolated",
284
+ "--prefix",
285
+ "--project",
286
+ "--python",
287
+ "--root",
288
+ "--system",
289
+ "--target",
290
+ "--user"
291
+ ]);
292
+ var TARGET_ENVIRONMENT_VARIABLES = new Set([
293
+ "CONDA_PREFIX",
294
+ "PIP_PREFIX",
295
+ "PIP_PYTHON",
296
+ "PIP_ROOT",
297
+ "PIP_TARGET",
298
+ "PIP_USER",
299
+ "PYTHONHOME",
300
+ "PYTHONUSERBASE",
301
+ "UV_CONFIG_FILE",
302
+ "UV_PROJECT",
303
+ "UV_PROJECT_ENVIRONMENT",
304
+ "UV_PYTHON",
305
+ "UV_PREFIX",
306
+ "UV_ROOT",
307
+ "UV_SYSTEM_PYTHON",
308
+ "UV_TARGET"
309
+ ]);
310
+ function uvCommandNeedsEnvironment(args) {
311
+ return args[0] === "pip" || args[0] === "run";
312
+ }
313
+ function targetUvArguments(args, python) {
314
+ if (!python.virtualEnvironment) {
315
+ throw new Error("Targeted uv commands require a virtual environment.");
316
+ }
317
+ assertNoTargetOverride(args);
318
+ if (args[0] === "run") {
319
+ return ["run", "--no-project", "--python", python.executable, ...args.slice(1)];
320
+ }
321
+ if (args[0] === "pip" && args[1]) {
322
+ return ["pip", args[1], "--python", python.executable, ...args.slice(2)];
323
+ }
324
+ return [...args];
325
+ }
326
+ function uvCommandEnvironment(env, python) {
327
+ const sanitized = sanitizeUvEnvironment(env);
328
+ if (!python?.virtualEnvironment)
329
+ return sanitized;
330
+ return {
331
+ ...activateVirtualEnvironment(sanitized, python.virtualEnvironment, python.executable),
332
+ UV_PYTHON: python.executable
333
+ };
334
+ }
335
+ function sanitizeUvEnvironment(env) {
336
+ const sanitized = { ...env };
337
+ for (const key of Object.keys(sanitized)) {
338
+ if (TARGET_ENVIRONMENT_VARIABLES.has(key.toUpperCase()))
339
+ delete sanitized[key];
340
+ }
341
+ return sanitized;
342
+ }
343
+ function assertNoTargetOverride(args) {
344
+ for (const argument of args.slice(1)) {
345
+ if (argument === "--")
346
+ return;
347
+ const [name] = argument.split("=", 1);
348
+ if (name && TARGET_OPTIONS.has(name)) {
349
+ throw new Error(`${name} cannot override the environment selected by Pyfig.`);
350
+ }
351
+ if (argument === "-p" || argument.startsWith("-p=") || /^-p[^-]/u.test(argument)) {
352
+ throw new Error("-p cannot override the environment selected by Pyfig.");
353
+ }
354
+ }
355
+ }
356
+
357
+ // src/probes.ts
358
+ var import_semver2 = __toESM(require("semver"), 1);
319
359
 
320
360
  // src/process.ts
321
361
  var import_node_child_process = require("node:child_process");
322
- var import_node_path3 = __toESM(require("node:path"), 1);
362
+ var import_node_path4 = __toESM(require("node:path"), 1);
323
363
  var import_which = __toESM(require("which"), 1);
324
364
  async function resolveExecutable(value, env) {
325
365
  if (!value)
326
366
  return null;
327
- if (import_node_path3.default.isAbsolute(value)) {
367
+ if (import_node_path4.default.isAbsolute(value)) {
328
368
  return await import_which.default(value, { nothrow: true, path: env["PATH"], pathExt: env["PATHEXT"] });
329
369
  }
330
370
  if (!/^[A-Za-z0-9_.-]+$/u.test(value))
@@ -341,14 +381,15 @@ function run(executable, args, env, timeoutMs) {
341
381
  });
342
382
  if (result.status !== 0 || result.error) {
343
383
  const detail = result.stderr?.trim() || result.error?.message || `exit ${result.status}`;
344
- throw new Error(`${import_node_path3.default.basename(executable)} ${args.slice(0, 3).join(" ")} failed: ${detail}`);
384
+ throw new Error(`${import_node_path4.default.basename(executable)} ${args.slice(0, 3).join(" ")} failed: ${detail}`);
345
385
  }
346
386
  return { stdout: result.stdout, stderr: result.stderr };
347
387
  }
348
- async function runAsync(executable, args, env, timeoutMs, signal) {
388
+ async function runAsync(executable, args, env, timeoutMs, signal, cwd) {
349
389
  return new Promise((resolve, reject) => {
350
390
  const child = import_node_child_process.spawn(executable, args, {
351
391
  env,
392
+ cwd,
352
393
  stdio: ["ignore", "pipe", "pipe"],
353
394
  windowsHide: true
354
395
  });
@@ -369,11 +410,11 @@ async function runAsync(executable, args, env, timeoutMs, signal) {
369
410
  const abort = () => {
370
411
  child.kill();
371
412
  const reason = signal?.reason;
372
- finish(reason instanceof Error ? reason : new Error(`${import_node_path3.default.basename(executable)} was aborted.`));
413
+ finish(reason instanceof Error ? reason : new Error(`${import_node_path4.default.basename(executable)} was aborted.`));
373
414
  };
374
415
  const timer = setTimeout(() => {
375
416
  child.kill();
376
- finish(new Error(`${import_node_path3.default.basename(executable)} timed out after ${timeoutMs}ms.`));
417
+ finish(new Error(`${import_node_path4.default.basename(executable)} timed out after ${timeoutMs}ms.`));
377
418
  }, timeoutMs);
378
419
  child.stdout.setEncoding("utf8");
379
420
  child.stderr.setEncoding("utf8");
@@ -381,21 +422,21 @@ async function runAsync(executable, args, env, timeoutMs, signal) {
381
422
  stdout += chunk;
382
423
  if (stdout.length > 4 * 1024 * 1024) {
383
424
  child.kill();
384
- finish(new Error(`${import_node_path3.default.basename(executable)} exceeded the output limit.`));
425
+ finish(new Error(`${import_node_path4.default.basename(executable)} exceeded the output limit.`));
385
426
  }
386
427
  });
387
428
  child.stderr.on("data", (chunk) => {
388
429
  stderr += chunk;
389
430
  if (stderr.length > 4 * 1024 * 1024) {
390
431
  child.kill();
391
- finish(new Error(`${import_node_path3.default.basename(executable)} exceeded the error-output limit.`));
432
+ finish(new Error(`${import_node_path4.default.basename(executable)} exceeded the error-output limit.`));
392
433
  }
393
434
  });
394
435
  child.once("error", (error) => finish(error));
395
436
  child.once("close", (code, childSignal) => {
396
437
  if (code !== 0) {
397
438
  const detail = stderr.trim() || (childSignal ? `signal ${childSignal}` : `exit ${code}`);
398
- finish(new Error(`${import_node_path3.default.basename(executable)} ${args.slice(0, 3).join(" ")} failed: ${detail}`));
439
+ finish(new Error(`${import_node_path4.default.basename(executable)} ${args.slice(0, 3).join(" ")} failed: ${detail}`));
399
440
  return;
400
441
  }
401
442
  finish(undefined, { stdout, stderr });
@@ -407,213 +448,6 @@ async function runAsync(executable, args, env, timeoutMs, signal) {
407
448
  });
408
449
  }
409
450
 
410
- // src/coordination.ts
411
- var realmMutexTails = new Map;
412
- var flockLookups = new Map;
413
- var FLOCK_READY = "PYFIG_FLOCK_READY";
414
- var FLOCK_HOLDER = [
415
- `process.stdout.write('${FLOCK_READY}\\n')`,
416
- "process.stdin.resume()",
417
- 'process.stdin.once("end",()=>process.exit(0))'
418
- ].join(";");
419
- async function withProvisioningLock(options) {
420
- const existing = await options.check();
421
- if (existing !== null)
422
- return existing;
423
- return withResourceLock(options, async (signal) => {
424
- const rechecked = await options.check();
425
- return rechecked ?? options.mutate(signal);
426
- });
427
- }
428
- async function withResourceLock(options, operation) {
429
- await import_promises.mkdir(options.target, { recursive: true });
430
- return withRealmMutex(options.key, () => withWorkerLock(options, () => withNativeFlock(options, () => withPortableLease(options, operation))));
431
- }
432
- async function withRealmMutex(key, operation) {
433
- const previous = realmMutexTails.get(key) ?? Promise.resolve();
434
- let releaseGate;
435
- const gate = new Promise((resolve) => {
436
- releaseGate = resolve;
437
- });
438
- const tail = previous.catch(() => {
439
- return;
440
- }).then(() => gate);
441
- realmMutexTails.set(key, tail);
442
- await previous.catch(() => {
443
- return;
444
- });
445
- try {
446
- return await operation();
447
- } finally {
448
- releaseGate();
449
- if (realmMutexTails.get(key) === tail)
450
- realmMutexTails.delete(key);
451
- }
452
- }
453
- async function withWorkerLock(options, operation) {
454
- const manager = workerThreads.locks;
455
- if (!manager?.request)
456
- return operation();
457
- const controller = new AbortController;
458
- const timeout = setTimeout(() => controller.abort(new Error(`Timed out waiting for worker lock ${options.key}.`)), options.timeoutMs);
459
- let entered = false;
460
- try {
461
- return await manager.request(workerLockName(options.key), { mode: "exclusive", signal: controller.signal }, async () => {
462
- entered = true;
463
- return operation();
464
- });
465
- } catch (error) {
466
- if (entered)
467
- throw error;
468
- return operation();
469
- } finally {
470
- clearTimeout(timeout);
471
- }
472
- }
473
- async function withNativeFlock(options, operation) {
474
- if (process.platform === "win32")
475
- return operation();
476
- const executable = await findFlock(process.env);
477
- if (!executable)
478
- return operation();
479
- const child = await acquireFlock(executable, `${options.target}.flock`, options.timeoutMs);
480
- if (!child)
481
- return operation();
482
- try {
483
- return await operation();
484
- } finally {
485
- await releaseFlock(child);
486
- }
487
- }
488
- async function findFlock(env) {
489
- const lookupKey = `${env["PATH"] ?? ""}\x00${env["PATHEXT"] ?? ""}`;
490
- let lookup = flockLookups.get(lookupKey);
491
- if (!lookup) {
492
- lookup = resolveExecutable("flock", env);
493
- flockLookups.set(lookupKey, lookup);
494
- }
495
- return lookup;
496
- }
497
- async function acquireFlock(executable, lockPath, timeoutMs) {
498
- const waitSeconds = Math.max(1, Math.ceil(Math.min(timeoutMs, 30000) / 1000));
499
- const child = import_node_child_process2.spawn(executable, ["-x", "-w", String(waitSeconds), lockPath, process.execPath, "-e", FLOCK_HOLDER], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
500
- child.stderr.resume();
501
- let output = "";
502
- const acquired = await new Promise((resolve) => {
503
- let settled = false;
504
- const finish = (value) => {
505
- if (settled)
506
- return;
507
- settled = true;
508
- clearTimeout(timer);
509
- resolve(value);
510
- };
511
- const timer = setTimeout(() => finish(false), Math.min(timeoutMs, 30000) + 5000);
512
- child.once("error", () => finish(false));
513
- child.once("exit", () => finish(false));
514
- child.stdout.setEncoding("utf8");
515
- child.stdout.on("data", (chunk) => {
516
- output += chunk;
517
- if (output.includes(FLOCK_READY))
518
- finish(true);
519
- });
520
- });
521
- if (acquired)
522
- return child;
523
- child.kill();
524
- return null;
525
- }
526
- async function releaseFlock(child) {
527
- if (child.exitCode !== null)
528
- return;
529
- child.stdin.end();
530
- await new Promise((resolve) => {
531
- const timer = setTimeout(() => {
532
- child.kill();
533
- resolve();
534
- }, 5000);
535
- child.once("exit", () => {
536
- clearTimeout(timer);
537
- resolve();
538
- });
539
- });
540
- }
541
- async function withPortableLease(options, operation) {
542
- const controller = new AbortController;
543
- let compromised = null;
544
- const retryIntervalMs = 500;
545
- const release = await import_proper_lockfile.default.lock(options.target, {
546
- realpath: true,
547
- stale: 120000,
548
- update: 15000,
549
- retries: {
550
- retries: Math.ceil(options.timeoutMs / retryIntervalMs),
551
- factor: 1,
552
- minTimeout: retryIntervalMs,
553
- maxTimeout: retryIntervalMs
554
- },
555
- onCompromised: (error) => {
556
- compromised = error;
557
- controller.abort(error);
558
- }
559
- });
560
- try {
561
- const result = await operation(controller.signal);
562
- if (compromised)
563
- throw compromised;
564
- return result;
565
- } finally {
566
- controller.abort();
567
- await release();
568
- }
569
- }
570
- function workerLockName(key) {
571
- return `pyfig:${import_node_crypto2.createHash("sha256").update(key).digest("hex")}`;
572
- }
573
- function cacheLockTarget(cacheDirectory, name) {
574
- return import_node_path4.default.join(cacheDirectory, "locks", name);
575
- }
576
-
577
- // src/probes.ts
578
- var import_node_path6 = __toESM(require("node:path"), 1);
579
- var import_semver2 = __toESM(require("semver"), 1);
580
-
581
- // src/virtual-environment.ts
582
- var import_node_fs2 = __toESM(require("node:fs"), 1);
583
- var import_node_path5 = __toESM(require("node:path"), 1);
584
- var VIRTUAL_ENV_DIRECTORY_NAME = ".venv";
585
- function virtualEnvironmentDirectory(projectRoot) {
586
- return import_node_path5.default.join(projectRoot, VIRTUAL_ENV_DIRECTORY_NAME);
587
- }
588
- function virtualEnvironmentPythonExecutable(virtualEnvironment) {
589
- return import_node_path5.default.join(virtualEnvironment, process.platform === "win32" ? "Scripts" : "bin", process.platform === "win32" ? "python.exe" : "python");
590
- }
591
- function activateVirtualEnvironment(env, virtualEnvironment, pythonExecutable) {
592
- const activated = { ...env };
593
- const pathEntry = Object.keys(activated).find((key) => key.toUpperCase() === "PATH");
594
- const existingPath = pathEntry ? activated[pathEntry] : undefined;
595
- for (const key of Object.keys(activated)) {
596
- const normalized = key.toUpperCase();
597
- if (normalized === "PATH" || normalized === "PYTHONHOME" || normalized === "VIRTUAL_ENV") {
598
- delete activated[key];
599
- }
600
- }
601
- activated["PATH"] = [import_node_path5.default.dirname(pythonExecutable), existingPath].filter((value) => Boolean(value)).join(import_node_path5.default.delimiter);
602
- activated["VIRTUAL_ENV"] = virtualEnvironment;
603
- return activated;
604
- }
605
- function pathsEqual(left, right) {
606
- return canonicalPath(left) === canonicalPath(right);
607
- }
608
- function canonicalPath(value) {
609
- const resolved = import_node_path5.default.resolve(value);
610
- let canonical = resolved;
611
- try {
612
- canonical = import_node_fs2.default.realpathSync.native(resolved);
613
- } catch {}
614
- return process.platform === "win32" ? canonical.toLowerCase() : canonical;
615
- }
616
-
617
451
  // src/probes.ts
618
452
  var PYTHON_PROBE = `
619
453
  import json
@@ -683,22 +517,6 @@ print(json.dumps({
683
517
  "embedding": embedding,
684
518
  }))
685
519
  `;
686
- async function findPython(options) {
687
- const candidates = [];
688
- if (options.python)
689
- candidates.push({ executable: options.python, args: [] });
690
- if (process.platform === "win32") {
691
- candidates.push({ executable: "python", args: [] }, { executable: "python3", args: [] }, { executable: "py", args: ["-3"] });
692
- } else {
693
- candidates.push(...["python3.15", "python3.14", "python3.13", "python3.12", "python3.11", "python3.10"].map((executable) => ({ executable, args: [] })), { executable: "python3", args: [] }, { executable: "python", args: [] });
694
- }
695
- for (const candidate of candidates) {
696
- const info = await probePython(candidate, options);
697
- if (info && pythonSatisfiesRequirements(info, options))
698
- return info;
699
- }
700
- return null;
701
- }
702
520
  function pythonSatisfiesRequirements(python, options) {
703
521
  return import_semver2.default.satisfies(python.version, options.pythonRange) && (!options.requireEmbedding || python.implementation === "cpython" && python.embedding !== null && pythonArchitectureMatchesRuntime(python)) && (!options.requireGil || !python.gilDisabled);
704
522
  }
@@ -714,50 +532,6 @@ async function probePython(candidate, options) {
714
532
  return null;
715
533
  }
716
534
  }
717
- async function findPip(python, options) {
718
- if (!python.virtualEnvironment) {
719
- throw new Error("pip discovery requires a virtual-environment Python interpreter.");
720
- }
721
- const activeEnv = activateVirtualEnvironment(options.env, python.virtualEnvironment, python.executable);
722
- try {
723
- const { stdout } = run(python.executable, ["-m", "pip", "--version"], activeEnv, options.probeTimeoutMs);
724
- const details = parsePipVersionOutput(stdout);
725
- const version = normalizeVersion(details?.version);
726
- if (!version || !import_semver2.default.satisfies(version, options.pipRange))
727
- return null;
728
- if (!details || !pipBelongsToPython(details, python))
729
- return null;
730
- return {
731
- kind: "pip",
732
- executable: python.executable,
733
- prefixArgs: ["-m", "pip"],
734
- version,
735
- pythonExecutable: python.executable,
736
- virtualEnvironment: python.virtualEnvironment
737
- };
738
- } catch {
739
- return null;
740
- }
741
- }
742
- function parsePipVersionOutput(output) {
743
- const match = output.trim().match(/^pip\s+(\S+)\s+from\s+(.+?)\s+\(python\s+(\d+)\.(\d+)(?:\.\d+)?\)$/iu);
744
- if (!match?.[1] || !match[2] || !match[3] || !match[4])
745
- return null;
746
- return {
747
- version: match[1],
748
- location: match[2],
749
- pythonMajor: Number.parseInt(match[3], 10),
750
- pythonMinor: Number.parseInt(match[4], 10)
751
- };
752
- }
753
- function pipBelongsToPython(pip, python) {
754
- if (pip.pythonMajor !== python.major || pip.pythonMinor !== python.minor)
755
- return false;
756
- const prefix = normalizePathForComparison(import_node_path6.default.resolve(python.prefix));
757
- const location = normalizePathForComparison(import_node_path6.default.resolve(pip.location));
758
- const relative = import_node_path6.default.relative(prefix, location);
759
- return relative === "" || relative !== ".." && !relative.startsWith(`..${import_node_path6.default.sep}`) && !import_node_path6.default.isAbsolute(relative);
760
- }
761
535
  async function findUv(options) {
762
536
  const configured = options.env["PYFIG_UV"] ?? "uv";
763
537
  const executable = await resolveExecutable(configured, options.env);
@@ -778,9 +552,6 @@ function normalizeVersion(value) {
778
552
  return null;
779
553
  return import_semver2.default.valid(value) ?? import_semver2.default.coerce(value)?.version ?? null;
780
554
  }
781
- function normalizePathForComparison(value) {
782
- return process.platform === "win32" ? value.toLowerCase() : value;
783
- }
784
555
  function pythonArchitectureMatchesRuntime(python) {
785
556
  const architecture = python.architecture.toLowerCase();
786
557
  if (python.pointerBits !== (process.arch === "ia32" ? 32 : 64))
@@ -795,228 +566,223 @@ function pythonArchitectureMatchesRuntime(python) {
795
566
  return architecture === process.arch;
796
567
  }
797
568
 
798
- // src/state.ts
799
- var completed = new Map;
800
- var inFlight = new Map;
801
- var cacheGeneration = 0;
802
- async function memoizeAsync(namespace, key, ttlMs, validate, load) {
803
- const cacheKey = `${namespace}\x00${key}`;
804
- const cached = completed.get(cacheKey);
805
- if (cached && cached.expiresAt >= Date.now()) {
806
- try {
807
- if (await validate(cached.value))
808
- return cached.value;
809
- } catch {}
810
- completed.delete(cacheKey);
811
- }
812
- const pending = inFlight.get(cacheKey);
813
- if (pending)
814
- return pending;
815
- const generation = cacheGeneration;
816
- const loading = load().then((value) => {
817
- if (ttlMs > 0 && generation === cacheGeneration) {
818
- completed.set(cacheKey, { expiresAt: Date.now() + ttlMs, value });
819
- }
820
- return value;
821
- }).finally(() => {
822
- if (inFlight.get(cacheKey) === loading)
823
- inFlight.delete(cacheKey);
824
- });
825
- inFlight.set(cacheKey, loading);
826
- return loading;
827
- }
828
- function memoizeSync(namespace, key, ttlMs, validate, load) {
829
- const cacheKey = `${namespace}\x00${key}`;
830
- const cached = completed.get(cacheKey);
831
- if (cached && cached.expiresAt >= Date.now()) {
832
- try {
833
- if (validate(cached.value))
834
- return cached.value;
835
- } catch {}
836
- completed.delete(cacheKey);
837
- }
838
- const value = load();
839
- if (ttlMs > 0)
840
- completed.set(cacheKey, { expiresAt: Date.now() + ttlMs, value });
841
- return value;
842
- }
843
- function clearPyfigCaches() {
844
- cacheGeneration += 1;
845
- completed.clear();
846
- inFlight.clear();
847
- }
848
-
849
569
  // src/provision.ts
850
- async function ensurePython(options = {}) {
851
- const resolved = resolveOptions(options);
852
- return memoizeAsync("python", configurationFingerprint(resolved), resolved.cacheTtlMs, pythonIsPresent, () => ensurePythonResolved(resolved));
853
- }
854
- async function ensureVirtualEnvironment(options = {}) {
855
- const resolved = resolveOptions(options);
856
- return memoizeAsync("venv", configurationFingerprint(resolved), resolved.cacheTtlMs, (python) => pythonIsPresent(python) && Boolean(python.virtualEnvironment) && pathsEqual(python.virtualEnvironment, virtualEnvironmentDirectory(resolved.projectRoot)), () => ensureVirtualEnvironmentResolved(resolved));
857
- }
858
570
  async function ensureUv(options = {}) {
859
- const resolved = resolveOptions(options);
860
- return memoizeAsync("uv", configurationFingerprint(resolved), resolved.cacheTtlMs, (uv) => import_node_fs4.default.existsSync(uv.executable), () => ensureUvResolved(resolved));
571
+ return ensureUvResolved(resolveOptions(options));
861
572
  }
862
- async function ensureEnvironment(options = {}) {
573
+ async function ensureEnvironment(options = {}, requirements = [], dependencyOptions = {}) {
863
574
  const resolved = resolveOptions(options);
864
- return memoizeAsync("environment", configurationFingerprint(resolved), resolved.cacheTtlMs, environmentIsPresent, async () => {
865
- const python = await ensureVirtualEnvironmentResolved(resolved);
866
- const packageManager = await ensurePipResolved(python, resolved);
867
- return { python, packageManager };
868
- });
575
+ const normalized = normalizeRequirements(requirements);
576
+ const selections = normalizeDependencyOptions(dependencyOptions);
577
+ const uv = await ensureUvResolved(resolved);
578
+ const python = await ensureVirtualEnvironmentResolved(resolved, uv);
579
+ if (normalized.length > 0 || selections.extras.length > 0 || selections.groups.length > 0 || selections.allExtras) {
580
+ await installRuntimeRequirements(uv, python, normalized, selections, resolved);
581
+ }
582
+ return {
583
+ python,
584
+ uv,
585
+ managed: isManagedEnvironment(python, resolved)
586
+ };
869
587
  }
870
- async function ensureVirtualEnvironmentResolved(options) {
588
+ async function ensureVirtualEnvironmentResolved(options, uv) {
589
+ const existing = await findReusableEnvironment(options);
590
+ if (existing)
591
+ return existing;
871
592
  const directory = virtualEnvironmentDirectory(options.projectRoot);
872
593
  const check = async () => {
873
- const existing = await inspectVirtualEnvironment(directory, options);
874
- if (!existing)
875
- return null;
876
- if (pythonSatisfiesRequirements(existing, options) && await virtualEnvironmentMatchesConfiguredPython(existing, options)) {
877
- return existing;
878
- }
879
- throw incompatibleVirtualEnvironmentError(directory, existing, options);
594
+ const candidate = await inspectVirtualEnvironment(directory, options);
595
+ return candidate && pythonSatisfiesRequirements(candidate, options) && await virtualEnvironmentMatchesConfiguredPython(candidate, options) ? candidate : null;
880
596
  };
881
- const existing = await check();
882
- if (existing)
883
- return existing;
884
597
  if (!options.createVirtualEnvironment) {
885
- throw new Error(`No virtual environment at ${directory} satisfies the Python requirements; ` + "virtual-environment creation is disabled.");
598
+ throw new Error(`No compatible environment is available and creation is disabled. ` + `Pyfig would create ${directory}.`);
886
599
  }
887
- const basePython = await ensurePythonResolved(options);
888
- const preferredUv = await findAvailableUv(options) ?? (options.installUv ? await ensureUvResolved(options) : null);
600
+ const basePython = await ensureBasePythonResolved(uv, options);
889
601
  return withProvisioningLock({
890
602
  key: `venv:${canonicalPath(directory)}`,
891
- target: directory,
892
603
  timeoutMs: options.coordinationTimeoutMs,
893
- env: options.env,
894
604
  check,
895
- mutate: async (signal) => {
896
- const entries = await import_promises2.readdir(directory);
897
- const hasVenvConfiguration = import_node_fs4.default.existsSync(import_node_path7.default.join(directory, "pyvenv.cfg"));
898
- if (entries.length > 0 && !hasVenvConfiguration) {
899
- throw new Error(`Refusing to replace non-virtual-environment contents at ${directory}.`);
900
- }
901
- await createVirtualEnvironment(directory, basePython, preferredUv, options, signal);
902
- const installed = await inspectVirtualEnvironment(directory, options);
903
- if (!installed || !pythonSatisfiesRequirements(installed, options)) {
904
- throw new Error(`Python created ${directory}, but it does not satisfy the requested runtime capabilities.`);
605
+ mutate: async () => {
606
+ await import_promises.rm(directory, { recursive: true, force: true });
607
+ await import_promises.mkdir(import_node_path5.default.dirname(directory), { recursive: true });
608
+ await runAsync(uv.executable, [
609
+ "venv",
610
+ "--no-project",
611
+ "--no-config",
612
+ "--no-python-downloads",
613
+ "--python",
614
+ basePython.executable,
615
+ directory
616
+ ], isolatedUvEnvironment(options), options.installTimeoutMs);
617
+ const installed = await check();
618
+ if (!installed) {
619
+ throw new Error(`uv created ${directory}, but its Python does not satisfy the requested capabilities.`);
905
620
  }
906
621
  return installed;
907
622
  }
908
623
  });
909
624
  }
910
- async function createVirtualEnvironment(directory, basePython, preferredUv, options, signal) {
911
- if (preferredUv) {
912
- try {
913
- await createVirtualEnvironmentWithUv(preferredUv, directory, basePython, options, signal);
914
- return;
915
- } catch {}
625
+ async function findReusableEnvironment(options) {
626
+ if (options.python) {
627
+ const configured = await probePython({ executable: options.python, args: [] }, options);
628
+ if (!configured || !pythonSatisfiesRequirements(configured, options)) {
629
+ throw incompatibleConfiguredPython(options);
630
+ }
631
+ if (configured.virtualEnvironment)
632
+ return configured;
916
633
  }
917
- try {
918
- await runAsync(basePython.executable, ["-m", "venv", directory], withoutActiveVirtualEnvironment(options.env), options.installTimeoutMs, signal);
919
- return;
920
- } catch (systemVenvError) {
921
- if (preferredUv || !options.installUv)
922
- throw systemVenvError;
634
+ for (const directory of [
635
+ ...externalVirtualEnvironmentDirectories(options.projectRoot, options.env),
636
+ virtualEnvironmentDirectory(options.projectRoot)
637
+ ]) {
638
+ const info = await inspectVirtualEnvironment(directory, options);
639
+ if (info && pythonSatisfiesRequirements(info, options) && await virtualEnvironmentMatchesConfiguredPython(info, options)) {
640
+ return info;
641
+ }
923
642
  }
924
- throw new Error("Python could not create a virtual environment and uv installation is disabled.");
925
- }
926
- async function createVirtualEnvironmentWithUv(uv, directory, basePython, options, signal) {
927
- await runAsync(uv.executable, [
928
- "venv",
929
- "--no-project",
930
- "--no-config",
931
- "--no-python-downloads",
932
- "--allow-existing",
933
- "--python",
934
- basePython.executable,
935
- directory
936
- ], uvEnvironment(options), options.installTimeoutMs, signal);
937
- }
938
- async function inspectVirtualEnvironment(directory, options) {
939
- const executable = virtualEnvironmentPythonExecutable(directory);
940
- const info = await probePython({ executable, args: [] }, options);
941
- return info?.virtualEnvironment && pathsEqual(info.virtualEnvironment, directory) ? info : null;
643
+ return null;
942
644
  }
943
- async function ensurePythonResolved(options) {
944
- const existing = await discoverPython(options);
945
- if (existing)
946
- return existing;
645
+ async function ensureBasePythonResolved(uv, options) {
646
+ if (options.python) {
647
+ const configured = await probePython({ executable: options.python, args: [] }, options);
648
+ if (!configured || !pythonSatisfiesRequirements(configured, options)) {
649
+ throw incompatibleConfiguredPython(options);
650
+ }
651
+ if (!configured.virtualEnvironment)
652
+ return configured;
653
+ const base = await probePython({ executable: configured.baseExecutable, args: [] }, options);
654
+ if (base && pythonSatisfiesRequirements(base, options))
655
+ return base;
656
+ throw incompatibleConfiguredPython(options);
657
+ }
658
+ const system = await findPythonWithUv(uv, options, "system");
659
+ if (system)
660
+ return system;
661
+ const cachedManaged = await findPythonWithUv(uv, options, "managed");
662
+ if (cachedManaged)
663
+ return cachedManaged;
947
664
  if (!options.installPython) {
948
- const capability = options.requireEmbedding ? " with compatible CPython development headers, architecture, and a linkable library" : "";
949
- throw new Error(`No Python interpreter satisfies ${options.pythonRange}${capability}; ` + "managed Python installation is disabled.");
665
+ throw new Error(missingPythonMessage(options, "managed Python installation is disabled"));
950
666
  }
951
- const lockTarget = cacheLockTarget(options.cacheDir, "python");
952
667
  return withProvisioningLock({
953
- key: `python:${canonicalPath(lockTarget)}`,
954
- target: lockTarget,
668
+ key: `python:${canonicalPath(import_node_path5.default.join(options.cacheDir, "python"))}`,
955
669
  timeoutMs: options.coordinationTimeoutMs,
956
- env: options.env,
957
- check: () => discoverPython(options),
958
- mutate: async (signal) => {
959
- const uv = await ensureUvResolved(options);
960
- const uvEnv = uvEnvironment(options);
961
- await runAsync(uv.executable, ["python", "install", "--no-config", options.managedPython], uvEnv, options.installTimeoutMs, signal);
962
- const installed = await findPythonWithUv(uv, options, signal);
670
+ check: () => findPythonWithUv(uv, options, "managed"),
671
+ mutate: async () => {
672
+ await runAsync(uv.executable, ["python", "install", "--no-config", options.managedPython], isolatedUvEnvironment(options), options.installTimeoutMs);
673
+ const installed = await findPythonWithUv(uv, options, "managed");
963
674
  if (!installed) {
964
- throw new Error(`uv installed Python ${options.managedPython}, but it does not satisfy the requested ` + "runtime capabilities.");
675
+ throw new Error(missingPythonMessage(options, `uv installed ${options.managedPython}, but it remains incompatible`));
965
676
  }
966
677
  return installed;
967
678
  }
968
679
  });
969
680
  }
970
- async function discoverPython(options) {
971
- if (options.python) {
972
- const configured = await probePython({ executable: options.python, args: [] }, options);
973
- if (configured && pythonSatisfiesRequirements(configured, options))
974
- return configured;
975
- throw new Error(`Configured Python ${options.python} is unavailable or does not satisfy ${options.pythonRange}.`);
976
- }
977
- const existingUv = await findAvailableUv(options);
978
- if (existingUv) {
979
- const uvPython = await findPythonWithUv(existingUv, options);
980
- if (uvPython)
981
- return uvPython;
982
- }
983
- return findPython({ ...options, python: "" });
984
- }
985
- async function findPythonWithUv(uv, options, signal) {
986
- const requests = [undefined, options.managedPython];
987
- for (const requestedPython of requests) {
681
+ async function findPythonWithUv(uv, options, source) {
682
+ for (const request of ["", options.managedPython]) {
988
683
  const args = [
989
684
  "python",
990
685
  "find",
991
686
  "--no-project",
992
687
  "--no-config",
993
- "--managed-python",
994
- "--no-python-downloads"
688
+ "--no-python-downloads",
689
+ source === "system" ? "--system" : "--managed-python"
995
690
  ];
996
- if (requestedPython)
997
- args.push(requestedPython);
691
+ if (request)
692
+ args.push(request);
998
693
  try {
999
- const { stdout } = await runAsync(uv.executable, args, uvEnvironment(options), options.probeTimeoutMs, signal);
694
+ const { stdout } = await runAsync(uv.executable, args, explicitPythonSourceEnvironment(options), options.probeTimeoutMs);
1000
695
  const info = await probePython({ executable: stdout.trim(), args: [] }, options);
1001
696
  if (info && pythonSatisfiesRequirements(info, options))
1002
697
  return info;
1003
- } catch (error) {
1004
- if (signal?.aborted)
1005
- throw signal.reason ?? error;
1006
- }
698
+ } catch {}
1007
699
  }
700
+ try {
701
+ const { stdout } = await runAsync(uv.executable, [
702
+ "python",
703
+ "list",
704
+ "--no-config",
705
+ "--no-python-downloads",
706
+ "--only-installed",
707
+ "--output-format",
708
+ "json",
709
+ source === "system" ? "--no-managed-python" : "--managed-python"
710
+ ], explicitPythonSourceEnvironment(options), options.probeTimeoutMs);
711
+ const candidates = installedPythonPaths(stdout);
712
+ for (const executable of candidates) {
713
+ const info = await probePython({ executable, args: [] }, options);
714
+ if (info && !info.virtualEnvironment && pythonSatisfiesRequirements(info, options)) {
715
+ return info;
716
+ }
717
+ }
718
+ } catch {}
1008
719
  return null;
1009
720
  }
1010
- async function ensurePipResolved(python, options) {
1011
- const existingUv = await findAvailableUv(options);
1012
- if (existingUv)
1013
- return uvPackageManager(existingUv, python);
1014
- if (options.installUv)
1015
- return uvPackageManager(await ensureUvResolved(options), python);
1016
- const existingPip = await findPip(python, options);
1017
- if (existingPip)
1018
- return existingPip;
1019
- throw new Error(`The virtual environment's python -m pip does not satisfy ${options.pipRange}; ` + "uv installation is disabled.");
721
+ function installedPythonPaths(output) {
722
+ const parsed = JSON.parse(output);
723
+ if (!Array.isArray(parsed))
724
+ return [];
725
+ const paths = parsed.flatMap((entry) => {
726
+ if (!entry || typeof entry !== "object" || !("path" in entry))
727
+ return [];
728
+ const candidate = entry.path;
729
+ return typeof candidate === "string" && candidate ? [candidate] : [];
730
+ });
731
+ return [...new Map(paths.map((candidate) => [canonicalPath(candidate), candidate])).values()];
732
+ }
733
+ async function inspectVirtualEnvironment(directory, options) {
734
+ const executable = virtualEnvironmentPythonExecutable(directory);
735
+ const info = await probePython({ executable, args: [] }, options);
736
+ return info?.virtualEnvironment && pathsEqual(info.virtualEnvironment, directory) ? info : null;
737
+ }
738
+ async function installRuntimeRequirements(uv, python, requirements, dependencyOptions, options) {
739
+ if (!python.virtualEnvironment) {
740
+ throw new Error("Runtime dependency installation requires a virtual environment.");
741
+ }
742
+ await withProvisioningLock({
743
+ key: `dependencies:${canonicalPath(python.virtualEnvironment)}`,
744
+ timeoutMs: options.coordinationTimeoutMs,
745
+ check: async () => null,
746
+ mutate: async () => {
747
+ const temporary = await import_promises.mkdtemp(import_node_path5.default.join(import_node_os.default.tmpdir(), "pyfig-requirements-"));
748
+ try {
749
+ const additions = import_node_path5.default.join(temporary, "runtime-requirements.txt");
750
+ const resolved = import_node_path5.default.join(temporary, "resolved-requirements.txt");
751
+ const pyproject = import_node_path5.default.join(options.projectRoot, "pyproject.toml");
752
+ const hasPyproject = import_node_fs4.default.existsSync(pyproject);
753
+ if (!hasPyproject && (dependencyOptions.extras.length > 0 || dependencyOptions.groups.length > 0 || dependencyOptions.allExtras)) {
754
+ throw new Error("Runtime extras and groups require an existing pyproject.toml.");
755
+ }
756
+ const sources = hasPyproject ? [pyproject] : [];
757
+ if (requirements.length > 0) {
758
+ await import_promises.writeFile(additions, `${requirements.join(`
759
+ `)}
760
+ `, "utf8");
761
+ sources.push(additions);
762
+ }
763
+ const selectionArgs = [
764
+ ...dependencyOptions.extras.flatMap((extra) => ["--extra", extra]),
765
+ ...dependencyOptions.groups.flatMap((group) => ["--group", group]),
766
+ ...dependencyOptions.allExtras ? ["--all-extras"] : []
767
+ ];
768
+ await runAsync(uv.executable, [
769
+ "pip",
770
+ "compile",
771
+ "--no-config",
772
+ "--python",
773
+ python.executable,
774
+ ...selectionArgs,
775
+ ...sources,
776
+ "--output-file",
777
+ resolved
778
+ ], targetUvEnvironment(options, python), options.installTimeoutMs, undefined, options.projectRoot);
779
+ await runAsync(uv.executable, ["pip", "install", "--no-config", "--python", python.executable, "-r", resolved], targetUvEnvironment(options, python), options.installTimeoutMs, undefined, options.projectRoot);
780
+ } finally {
781
+ await import_promises.rm(temporary, { recursive: true, force: true });
782
+ }
783
+ return true;
784
+ }
785
+ });
1020
786
  }
1021
787
  async function ensureUvResolved(options) {
1022
788
  const existing = await findAvailableUv(options);
@@ -1027,15 +793,12 @@ async function ensureUvResolved(options) {
1027
793
  }
1028
794
  const installDir = uvInstallDirectory(options);
1029
795
  const executable = uvExecutable(options);
1030
- const lockTarget = cacheLockTarget(options.cacheDir, "uv");
1031
796
  return withProvisioningLock({
1032
- key: `uv:${canonicalPath(lockTarget)}`,
1033
- target: lockTarget,
797
+ key: `uv:${canonicalPath(installDir)}`,
1034
798
  timeoutMs: options.coordinationTimeoutMs,
1035
- env: options.env,
1036
799
  check: () => findAvailableUv(options),
1037
- mutate: async (signal) => {
1038
- await installUv(installDir, options, signal);
800
+ mutate: async () => {
801
+ await installUv(installDir, options);
1039
802
  const result = probeUv(executable, options);
1040
803
  if (!result || result.version !== options.uvVersion) {
1041
804
  throw new Error(`Expected managed uv ${options.uvVersion}, but the installed executable did not match.`);
@@ -1045,88 +808,122 @@ async function ensureUvResolved(options) {
1045
808
  });
1046
809
  }
1047
810
  async function findAvailableUv(options) {
1048
- const system = await findUv(options);
1049
- if (system)
1050
- return system;
811
+ const external = await findUv(options);
812
+ if (external)
813
+ return external;
1051
814
  const managed = probeUv(uvExecutable(options), options);
1052
815
  return managed?.version === options.uvVersion ? managed : null;
1053
816
  }
1054
- function uvInstallDirectory(options) {
1055
- return import_node_path7.default.join(options.cacheDir, "tools", "uv", options.uvVersion);
1056
- }
1057
- function uvExecutable(options) {
1058
- return import_node_path7.default.join(uvInstallDirectory(options), process.platform === "win32" ? "uv.exe" : "uv");
817
+ function normalizeRequirements(requirements) {
818
+ const normalized = requirements.map((requirement) => requirement.trim());
819
+ for (const requirement of normalized) {
820
+ if (!requirement || /[\0\r\n]/u.test(requirement)) {
821
+ throw new TypeError(`Invalid runtime requirement: ${JSON.stringify(requirement)}`);
822
+ }
823
+ }
824
+ return [...new Set(normalized)];
1059
825
  }
1060
- function uvPackageManager(uv, python) {
1061
- if (!python.virtualEnvironment) {
1062
- throw new Error("uv pip commands require a virtual-environment Python interpreter.");
826
+ function normalizeDependencyOptions(options) {
827
+ const extras = normalizeNames("extra", options.extras ?? []);
828
+ const groups = normalizeNames("dependency group", options.groups ?? []);
829
+ if (options.allExtras && extras.length > 0) {
830
+ throw new TypeError("allExtras cannot be combined with explicit extras.");
1063
831
  }
1064
- return {
1065
- kind: "uv",
1066
- executable: uv.executable,
1067
- prefixArgs: ["pip"],
1068
- version: uv.version,
1069
- pythonExecutable: python.executable,
1070
- virtualEnvironment: python.virtualEnvironment
1071
- };
832
+ return { extras, groups, allExtras: options.allExtras ?? false };
1072
833
  }
1073
- function withoutActiveVirtualEnvironment(env) {
1074
- const result = { ...env };
1075
- for (const key of Object.keys(result)) {
1076
- const normalized = key.toUpperCase();
1077
- if (normalized === "CONDA_PREFIX" || normalized === "VIRTUAL_ENV" || normalized === "PYTHONHOME") {
1078
- delete result[key];
834
+ function normalizeNames(kind, names) {
835
+ const normalized = names.map((name) => name.trim());
836
+ for (const name of normalized) {
837
+ if (!name || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(name)) {
838
+ throw new TypeError(`Invalid ${kind}: ${JSON.stringify(name)}`);
1079
839
  }
1080
840
  }
1081
- return result;
841
+ return [...new Set(normalized)];
842
+ }
843
+ function isManagedEnvironment(python, options) {
844
+ return Boolean(python.virtualEnvironment) && pathsEqual(python.virtualEnvironment, virtualEnvironmentDirectory(options.projectRoot));
845
+ }
846
+ function isolatedUvEnvironment(options) {
847
+ return sanitizeUvEnvironment(uvEnvironment(options));
848
+ }
849
+ function explicitPythonSourceEnvironment(options) {
850
+ const env = isolatedUvEnvironment(options);
851
+ delete env["UV_PYTHON_PREFERENCE"];
852
+ return env;
853
+ }
854
+ function targetUvEnvironment(options, python) {
855
+ return uvCommandEnvironment(uvEnvironment(options), python);
856
+ }
857
+ function incompatibleConfiguredPython(options) {
858
+ return new Error(`Configured Python ${options.python} is unavailable or does not satisfy ` + `${options.pythonRange}${options.requireEmbedding ? " with CPython embedding files" : ""}.`);
859
+ }
860
+ function missingPythonMessage(options, reason) {
861
+ const embedding = options.requireEmbedding ? " with compatible CPython headers, library, architecture, and GIL ABI" : "";
862
+ return `No Python satisfies ${options.pythonRange}${embedding}; ${reason}.`;
863
+ }
864
+ async function virtualEnvironmentMatchesConfiguredPython(virtualEnvironment, options) {
865
+ if (!options.python)
866
+ return true;
867
+ const configured = await probePython({ executable: options.python, args: [] }, options);
868
+ if (!configured || !pythonSatisfiesRequirements(configured, options))
869
+ return false;
870
+ if (configured.virtualEnvironment && pathsEqual(configured.executable, virtualEnvironment.executable)) {
871
+ return true;
872
+ }
873
+ const configuredBase = configured.virtualEnvironment ? configured.baseExecutable : configured.executable;
874
+ return pathsEqual(configuredBase, virtualEnvironment.baseExecutable);
1082
875
  }
1083
- async function installUv(installDir, options, signal) {
876
+ function uvInstallDirectory(options) {
877
+ return import_node_path5.default.join(options.cacheDir, "tools", "uv", options.uvVersion);
878
+ }
879
+ function uvExecutable(options) {
880
+ return import_node_path5.default.join(uvInstallDirectory(options), process.platform === "win32" ? "uv.exe" : "uv");
881
+ }
882
+ async function installUv(installDir, options) {
1084
883
  const asset = uvAsset();
1085
884
  const baseUrl = `https://github.com/astral-sh/uv/releases/download/${options.uvVersion}`;
1086
- const temporary = await import_promises2.mkdtemp(import_node_path7.default.join(import_node_os.default.tmpdir(), "pyfig-uv-"));
885
+ const temporary = await import_promises.mkdtemp(import_node_path5.default.join(import_node_os.default.tmpdir(), "pyfig-uv-"));
1087
886
  const dispatcher = proxyDispatcher(options.env);
1088
887
  try {
1089
- const archive = import_node_path7.default.join(temporary, asset);
1090
- await download(`${baseUrl}/${asset}`, archive, options.downloadTimeoutMs, signal, dispatcher);
1091
- const expected = (await downloadText(`${baseUrl}/${asset}.sha256`, options.downloadTimeoutMs, signal, dispatcher)).trim().split(/\s+/u)[0];
888
+ const archive = import_node_path5.default.join(temporary, asset);
889
+ await download(`${baseUrl}/${asset}`, archive, options.downloadTimeoutMs, dispatcher);
890
+ const expected = (await downloadText(`${baseUrl}/${asset}.sha256`, options.downloadTimeoutMs, dispatcher)).trim().split(/\s+/u)[0];
1092
891
  const actual = await sha256File(archive);
1093
892
  if (!expected || expected.toLowerCase() !== actual.toLowerCase()) {
1094
893
  throw new Error(`uv archive checksum mismatch for ${asset}`);
1095
894
  }
1096
- const extracted = import_node_path7.default.join(temporary, "extracted");
1097
- await import_promises2.mkdir(extracted, { recursive: true });
1098
- signal.throwIfAborted();
895
+ const extracted = import_node_path5.default.join(temporary, "extracted");
896
+ await import_promises.mkdir(extracted, { recursive: true });
1099
897
  if (asset.endsWith(".zip"))
1100
898
  await import_extract_zip.default(archive, { dir: extracted });
1101
899
  else
1102
900
  await tar.x({ file: archive, cwd: extracted, strict: true });
1103
- signal.throwIfAborted();
1104
901
  const source = await findFile(extracted, process.platform === "win32" ? "uv.exe" : "uv");
1105
902
  if (!source)
1106
903
  throw new Error(`uv archive did not contain the expected executable: ${asset}`);
1107
904
  await publishUvAtomically(source, installDir, options);
1108
905
  } finally {
1109
906
  await dispatcher?.close();
1110
- await import_promises2.rm(temporary, { recursive: true, force: true });
907
+ await import_promises.rm(temporary, { recursive: true, force: true });
1111
908
  }
1112
909
  }
1113
910
  async function publishUvAtomically(source, installDir, options) {
1114
- await import_promises2.mkdir(installDir, { recursive: true });
911
+ await import_promises.mkdir(installDir, { recursive: true });
1115
912
  const executable = uvExecutable(options);
1116
- const staged = import_node_path7.default.join(installDir, `.${import_node_path7.default.basename(executable)}.${process.pid}.${import_node_crypto3.randomUUID()}.tmp`);
913
+ const staged = import_node_path5.default.join(installDir, `.${import_node_path5.default.basename(executable)}.${process.pid}.${import_node_crypto.randomUUID()}.tmp`);
1117
914
  try {
1118
- await import_promises2.copyFile(source, staged);
915
+ await import_promises.copyFile(source, staged);
1119
916
  if (process.platform !== "win32")
1120
- await import_promises2.chmod(staged, 493);
917
+ await import_promises.chmod(staged, 493);
1121
918
  const stagedUv = probeUv(staged, options);
1122
919
  if (!stagedUv || stagedUv.version !== options.uvVersion) {
1123
920
  throw new Error(`Downloaded uv executable is not version ${options.uvVersion}.`);
1124
921
  }
1125
922
  if (process.platform === "win32")
1126
- await import_promises2.rm(executable, { force: true });
1127
- await import_promises2.rename(staged, executable);
923
+ await import_promises.rm(executable, { force: true });
924
+ await import_promises.rename(staged, executable);
1128
925
  } finally {
1129
- await import_promises2.rm(staged, { force: true });
926
+ await import_promises.rm(staged, { force: true });
1130
927
  }
1131
928
  }
1132
929
  function uvAsset() {
@@ -1141,23 +938,23 @@ function uvAsset() {
1141
938
  return `uv-${architecture}-unknown-linux-musl.tar.gz`;
1142
939
  throw new Error(`uv bootstrap does not support platform ${process.platform}.`);
1143
940
  }
1144
- async function download(url, destination, timeoutMs, externalSignal, dispatcher) {
941
+ async function download(url, destination, timeoutMs, dispatcher) {
1145
942
  const response = await import_undici.fetch(url, {
1146
943
  dispatcher,
1147
944
  redirect: "follow",
1148
- signal: combinedSignal(timeoutMs, externalSignal)
945
+ signal: AbortSignal.timeout(timeoutMs)
1149
946
  });
1150
947
  if (!response.ok || !response.body) {
1151
948
  await response.body?.cancel();
1152
949
  throw new Error(`Could not download ${url}: HTTP ${response.status}`);
1153
950
  }
1154
- await import_promises3.pipeline(import_node_stream.Readable.fromWeb(response.body), import_node_fs3.createWriteStream(destination, { flags: "wx" }));
951
+ await import_promises2.pipeline(import_node_stream.Readable.fromWeb(response.body), import_node_fs3.createWriteStream(destination, { flags: "wx" }));
1155
952
  }
1156
- async function downloadText(url, timeoutMs, externalSignal, dispatcher) {
953
+ async function downloadText(url, timeoutMs, dispatcher) {
1157
954
  const response = await import_undici.fetch(url, {
1158
955
  dispatcher,
1159
956
  redirect: "follow",
1160
- signal: combinedSignal(timeoutMs, externalSignal)
957
+ signal: AbortSignal.timeout(timeoutMs)
1161
958
  });
1162
959
  if (!response.ok) {
1163
960
  await response.body?.cancel();
@@ -1179,18 +976,15 @@ function proxyDispatcher(env) {
1179
976
  function environmentValue(env, lowercase, uppercase) {
1180
977
  return env[lowercase] ?? env[uppercase];
1181
978
  }
1182
- function combinedSignal(timeoutMs, externalSignal) {
1183
- return AbortSignal.any([externalSignal, AbortSignal.timeout(timeoutMs)]);
1184
- }
1185
979
  async function sha256File(file) {
1186
- const hash = import_node_crypto3.createHash("sha256");
980
+ const hash = import_node_crypto.createHash("sha256");
1187
981
  for await (const chunk of import_node_fs3.createReadStream(file))
1188
982
  hash.update(chunk);
1189
983
  return hash.digest("hex");
1190
984
  }
1191
985
  async function findFile(directory, name) {
1192
- for (const entry of await import_promises2.readdir(directory, { withFileTypes: true })) {
1193
- const target = import_node_path7.default.join(directory, entry.name);
986
+ for (const entry of await import_promises.readdir(directory, { withFileTypes: true })) {
987
+ const target = import_node_path5.default.join(directory, entry.name);
1194
988
  if (entry.isFile() && entry.name === name)
1195
989
  return target;
1196
990
  if (entry.isDirectory()) {
@@ -1201,36 +995,11 @@ async function findFile(directory, name) {
1201
995
  }
1202
996
  return null;
1203
997
  }
1204
- function incompatibleVirtualEnvironmentError(directory, python, options) {
1205
- const capabilities = options.requireEmbedding ? " or lacks compatible CPython embedding headers, library, or architecture" : "";
1206
- return new Error(`The existing virtual environment at ${directory} uses Python ${python.version}, ` + `which does not satisfy ${options.pythonRange}${capabilities}.`);
1207
- }
1208
- async function virtualEnvironmentMatchesConfiguredPython(virtualEnvironment, options) {
1209
- if (!options.python)
1210
- return true;
1211
- const configured = await probePython({ executable: options.python, args: [] }, options);
1212
- if (!configured || !pythonSatisfiesRequirements(configured, options))
1213
- return false;
1214
- if (configured.virtualEnvironment && pathsEqual(configured.executable, virtualEnvironment.executable)) {
1215
- return true;
1216
- }
1217
- const configuredBase = configured.virtualEnvironment ? configured.baseExecutable : configured.executable;
1218
- return pathsEqual(configuredBase, virtualEnvironment.baseExecutable);
1219
- }
1220
- function pythonIsPresent(python) {
1221
- return import_node_fs4.default.existsSync(python.executable);
1222
- }
1223
- function environmentIsPresent(environment) {
1224
- return pythonIsPresent(environment.python) && import_node_fs4.default.existsSync(environment.packageManager.executable);
1225
- }
1226
998
 
1227
999
  // src/sync-resolver.ts
1228
1000
  async function main() {
1229
1001
  const request = JSON.parse(import_node_fs5.readFileSync(0, "utf8"));
1230
- const result = request.target === "python" ? await ensurePython(request.options) : request.target === "venv" ? await ensureVirtualEnvironment(request.options) : request.target === "uv" ? await ensureUv(request.options) : request.target === "uv-pip" ? {
1231
- uv: await ensureUv(request.options),
1232
- python: await ensureVirtualEnvironment(request.options)
1233
- } : await ensureEnvironment(request.options);
1002
+ const result = request.target === "uv" ? await ensureUv(request.options) : await ensureEnvironment(request.options);
1234
1003
  process.stdout.write(JSON.stringify(result));
1235
1004
  }
1236
1005
  main().catch((error) => {
@@ -1239,4 +1008,4 @@ main().catch((error) => {
1239
1008
  process.exitCode = 1;
1240
1009
  });
1241
1010
 
1242
- //# debugId=37511558FADA2E3664756E2164756E21
1011
+ //# debugId=450BD20294AA111B64756E2164756E21