From 88f9c13660f848eede4296c3d3836cf236fc2844 Mon Sep 17 00:00:00 2001 From: Mohit Yadav Date: Sat, 25 Jul 2026 20:31:08 +0530 Subject: [PATCH 1/5] fix: hide Python activity bar icon in non-Python workspaces Fixes microsoft/vscode-python#26015 Added context key python-envs.workspaceHasPython via findFiles + FileSystemWatcher. ANDed into when clauses of the activitybar container and both views. Signed-off-by: Mohit Yadav --- package.json | 8 +++---- src/extension.ts | 4 +++- src/features/views/workspacePythonContext.ts | 24 ++++++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 src/features/views/workspacePythonContext.ts diff --git a/package.json b/package.json index 5830cc3e..a8878394 100644 --- a/package.json +++ b/package.json @@ -669,7 +669,7 @@ "id": "python", "title": "Python", "icon": "files/logo.svg", - "when": "config.python.useEnvironmentsExtension != false" + "when": "config.python.useEnvironmentsExtension != false && python-envs.workspaceHasPython" } ] }, @@ -680,14 +680,14 @@ "name": "Python Projects", "icon": "files/logo.svg", "contextualTitle": "Python Projects", - "when": "config.python.useEnvironmentsExtension != false" + "when": "config.python.useEnvironmentsExtension != false && python-envs.workspaceHasPython" }, { "id": "env-managers", "name": "Environment Managers", "icon": "files/logo.svg", "contextualTitle": "Environment Managers", - "when": "config.python.useEnvironmentsExtension != false" + "when": "config.python.useEnvironmentsExtension != false && python-envs.workspaceHasPython" } ] }, @@ -751,4 +751,4 @@ "overrides": { "serialize-javascript": "^7.0.3" } -} +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index e574eeab..d24f8532 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -95,10 +95,11 @@ import { PythonStatusBarImpl } from './features/views/pythonStatusBar'; import { updateViewsAndStatus } from './features/views/revealHandler'; import { TemporaryStateManager } from './features/views/temporaryStateManager'; import { ProjectItem, PythonEnvTreeItem } from './features/views/treeViewItems'; +import { registerWorkspacePythonContext } from './features/views/workspacePythonContext'; import { collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, runPetInTerminalImpl } from './helpers'; import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from './internal.api'; -import { registerSystemPythonFeatures } from './managers/builtin/main'; import { registerInlineScriptFeatures } from './managers/builtin/inlineScriptMain'; +import { registerSystemPythonFeatures } from './managers/builtin/main'; import { SysPythonManager } from './managers/builtin/sysPythonManager'; import { createNativePythonFinder, @@ -113,6 +114,7 @@ import { registerPoetryFeatures } from './managers/poetry/main'; import { registerPyenvFeatures } from './managers/pyenv/main'; export async function activate(context: ExtensionContext): Promise { + registerWorkspacePythonContext(context.subscriptions); // Only skip activation if user explicitly set useEnvironmentsExtension to false. // When disabled, the main Python extension handles environments instead (legacy mode). const config = getConfiguration('python'); diff --git a/src/features/views/workspacePythonContext.ts b/src/features/views/workspacePythonContext.ts new file mode 100644 index 00000000..6d5bd656 --- /dev/null +++ b/src/features/views/workspacePythonContext.ts @@ -0,0 +1,24 @@ +import { Disposable } from 'vscode'; +import { executeCommand } from '../../common/command.api'; +import { createFileSystemWatcher, findFiles, onDidChangeWorkspaceFolders } from '../../common/workspace.apis'; + +export const PYTHON_WORKSPACE_KEY = 'python-envs.workspaceHasPython'; + +const MARKER_GLOB = '**/{*.py,pyproject.toml,setup.py,requirements.txt,Pipfile,manage.py,app.py,.venv,.conda,mspythonconfig.json}'; +const EXCLUDE = '**/{node_modules,.git,site-packages}/**'; + +async function refresh(): Promise { + const hits = await findFiles(MARKER_GLOB, EXCLUDE, 1); + await executeCommand('setContext', PYTHON_WORKSPACE_KEY, hits.length > 0); +} + +export function registerWorkspacePythonContext(disposables: Disposable[]): void { + const watcher = createFileSystemWatcher(MARKER_GLOB, false, true, false); + disposables.push( + watcher, + watcher.onDidCreate(() => void refresh()), + watcher.onDidDelete(() => void refresh()), + onDidChangeWorkspaceFolders(() => void refresh()), + ); + void refresh(); +} \ No newline at end of file From 96e681464f368a411d4cc1b66b3bea593940fc4f Mon Sep 17 00:00:00 2001 From: Stella Huang <100439259+StellaHuang95@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:21:38 -0700 Subject: [PATCH 2/5] Add generic environment-creation utilities (PEP 723 PR 5a/16) (#1651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > Part of #1602 (PEP 723 inline script env support). Design doc: #1601. > **Split for review (3 PRs).** Reviewers flagged the original PR 5 as too large, so it is split into three stacked PRs grouped by dependency layer: > - **5a — generic env-creation utilities — this PR (#1651).** Based on `main`; independent; merges first. > - **5b — inline-script cache + interpreter utilities — #1655.** Stacked on 5a. > - **5c — `create()` happy path (manager + wiring) — #1656.** Stacked on 5b. > > Applied together the three PRs are byte-for-byte identical to the original single change. **Merge order: 5a → 5b → 5c.** ### Roadmap context This is the first slice of **PR 5 of 16** in the PEP 723 inline-script roadmap. The full plan lives in #1602. | Phase | PR | Status | |---|---|---| | **Phase 1: Foundation** | PR 1: cache key hash utility | merged (#1634) | | | PR 2: cache layout + `meta.json` sidecar | merged (#1635) | | | PR 3: `requires-python` to interpreter selection | merged (#1636) | | **Phase 2: Manager** | PR 4: `InlineScriptEnvManager` skeleton | merged (#1610) | | | **PR 5a: generic env-creation utilities** | **this PR (#1651)** | | | **PR 5b: inline-script cache + interpreter utilities** | **#1655** | | | **PR 5c: `create()` happy path (manager + wiring)** | **#1656** | | | PR 6: `create()` uv-install fallback | not started (needs 3, 5) | | | PR 7: persistence with `get`, `set`, and Memento | not started (needs 4) | | | PR 8: activation-time discovery | not started (needs 2, 4, 7) | | **Phase 3: Routing** | PR 9: route PEP 723 scripts to the inline manager | not started (needs 4, 7) | | | PR 10: per-script project registration | not started (needs 9) | | **Phase 4+: UX / lifecycle** | PRs 11-16 | not started | ### Why this PR PR 5c implements `InlineScriptEnvManager.create()`. Before touching the manager, this PR lands the **generic, reusable primitives** it relies on — a cross-process file lock, a venv Python-path helper, a cancellation-hardened process runner, and two small `createWithProgress` options. None of this code is inline-script-specific, so it is reviewed on its own. ### What this PR adds **Cross-process file lock** (`src/common/lockfile.apis.ts`, new): `acquireFileLock` uses an atomic `mkdir` of a `.lock` directory plus a per-owner marker file, returning `AcquiredFileLock { release, retain }`. `retain()` writes a `retained` marker so a later acquirer **fails fast with `ELOCKRETAINED`** instead of waiting out the 5-minute timeout — used when a build is cancelled mid-flight. Distinct error codes (`ELOCKED`, `ELOCKRETAINED`, `ELOCKORPHANED`, `ECOMPROMISED`, `ERETAINFAILED`) separate contention from corruption. **Shared `getVenvPythonPath`** (`src/common/utils/virtualEnvironment.ts`, new): returns `Scripts\python.exe` on Windows, else `bin/python`. Replaces an inline copy in `venvUtils` and is reused by 5b/5c. **Hardened process helper** (`src/managers/builtin/helpers.ts`): `runUV` and `runPython` now share one `runProcess` implementation whose cancellation guards `kill()` in `try/catch` and still emits a clean `CancellationError` if the process errors after a cancel. Per-caller options preserve existing behavior (`collectStderr`, `logPrefix`). **`venvUtils.ts`:** `createWithProgress` gains `CreateWithProgressOptions { trackUvEnvironment }`, and `CreateEnvironmentResult` gains `pkgInstallationCancelled` so a caller can tell cancellation apart from a real install failure. Existing callers are unaffected (both are optional / additive). ### Tests - **`lockfile.apis.unit.test.ts`** — 9 tests: contention, retain/fail-fast, orphaned and compromised locks, and timeout. - **`virtualEnvironment.unit.test.ts`** — 2 tests for `getVenvPythonPath` on Windows and POSIX. - **`helpers.cancellation.unit.test.ts`** — 4 tests for `runProcess` cancellation safety. - **`venvUtils.createWithProgress.unit.test.ts`** — 3 tests for `trackUvEnvironment` and `pkgInstallationCancelled`. On this branch alone `npm run compile-tests` is clean and `npm run unittest` reports **1447 passing, 0 failing, 4 pending**. ### User impact **None.** These are internal primitives with no new user-visible behavior. The refactors to `helpers.ts` and `venvUtils.ts` are behavior-preserving for existing callers. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 39dcc6a3-0fbd-4f36-9d0f-68677de49c27 --- src/common/lockfile.apis.ts | 127 +++++++++++ src/common/utils/virtualEnvironment.ts | 11 + src/managers/builtin/helpers.ts | 24 +- src/managers/builtin/venvUtils.ts | 21 +- src/test/common/lockfile.apis.unit.test.ts | 212 ++++++++++++++++++ .../common/virtualEnvironment.unit.test.ts | 27 +++ .../builtin/helpers.cancellation.unit.test.ts | 85 +++++++ .../venvUtils.createWithProgress.unit.test.ts | 134 +++++++++++ 8 files changed, 636 insertions(+), 5 deletions(-) create mode 100644 src/common/lockfile.apis.ts create mode 100644 src/common/utils/virtualEnvironment.ts create mode 100644 src/test/common/lockfile.apis.unit.test.ts create mode 100644 src/test/common/virtualEnvironment.unit.test.ts create mode 100644 src/test/managers/builtin/helpers.cancellation.unit.test.ts create mode 100644 src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts diff --git a/src/common/lockfile.apis.ts b/src/common/lockfile.apis.ts new file mode 100644 index 00000000..1d840905 --- /dev/null +++ b/src/common/lockfile.apis.ts @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as crypto from 'crypto'; +import * as fsapi from 'fs-extra'; +import * as path from 'path'; + +export interface AcquireFileLockOptions { + readonly timeoutMs: number; + readonly retryIntervalMs: number; +} + +export interface AcquiredFileLock { + readonly release: () => Promise; + /** Keep the lock and make later acquisition attempts fail immediately. */ + readonly retain: () => Promise; +} + +type LockState = 'held' | 'released' | 'retained'; + +/** Acquire an atomic lock released only explicitly; interrupted operations remain locked. */ +export async function acquireFileLock(filePath: string, options: AcquireFileLockOptions): Promise { + const lockPath = `${path.resolve(filePath)}.lock`; + const ownerMarker = path.join(lockPath, `owner-${process.pid}-${crypto.randomBytes(16).toString('hex')}`); + const retainedMarker = path.join(lockPath, 'retained'); + const deadline = Date.now() + options.timeoutMs; + + while (true) { + try { + await fsapi.mkdir(lockPath); + try { + await fsapi.writeFile(ownerMarker, '', { flag: 'wx' }); + } catch (error) { + try { + await fsapi.rmdir(lockPath); + } catch { + throw createLockError( + 'Lock initialization failed and left an owner-less lock directory', + 'ELOCKORPHANED', + lockPath, + ); + } + throw error; + } + + let state: LockState = 'held'; + return { + retain: async () => { + if (state !== 'held') { + return; + } + state = 'retained'; + try { + await fsapi.writeFile(retainedMarker, '', { flag: 'wx' }); + } catch (error) { + if (hasErrorCode(error, 'EEXIST')) { + return; + } + try { + await fsapi.rename(ownerMarker, retainedMarker); + } catch (renameError) { + if (!hasErrorCode(renameError, 'EEXIST')) { + throw createLockError( + 'Failed to mark the lock as retained', + 'ERETAINFAILED', + lockPath, + ); + } + } + } + }, + release: async () => { + if (state !== 'held') { + return; + } + state = 'released'; + try { + await fsapi.unlink(ownerMarker); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + throw createLockError('Lock ownership was compromised', 'ECOMPROMISED', lockPath); + } + throw error; + } + await fsapi.rmdir(lockPath); + }, + }; + } catch (error) { + if (!hasErrorCode(error, 'EEXIST')) { + throw error; + } + if (await isRetainedLock(lockPath)) { + throw createLockError('Lock was retained after an interrupted operation', 'ELOCKRETAINED', lockPath); + } + if (Date.now() >= deadline) { + throw createLockError('Lock is already being held', 'ELOCKED', lockPath); + } + await delay(Math.min(options.retryIntervalMs, Math.max(0, deadline - Date.now()))); + } + } +} + +async function isRetainedLock(lockPath: string): Promise { + try { + await fsapi.lstat(path.join(lockPath, 'retained')); + return true; + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return false; + } + throw error; + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === 'object' && error !== null && 'code' in error && (error as NodeJS.ErrnoException).code === code + ); +} + +function createLockError(message: string, code: string, lockPath: string): NodeJS.ErrnoException { + return Object.assign(new Error(message), { code, path: lockPath }); +} + +async function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/src/common/utils/virtualEnvironment.ts b/src/common/utils/virtualEnvironment.ts new file mode 100644 index 00000000..c581bf52 --- /dev/null +++ b/src/common/utils/virtualEnvironment.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import { isWindows } from './platformUtils'; + +export function getVenvPythonPath(envPath: string): string { + return isWindows() + ? path.join(envPath, 'Scripts', 'python.exe') + : path.join(envPath, 'bin', 'python'); +} diff --git a/src/managers/builtin/helpers.ts b/src/managers/builtin/helpers.ts index 911bc603..7fb7062a 100644 --- a/src/managers/builtin/helpers.ts +++ b/src/managers/builtin/helpers.ts @@ -73,12 +73,22 @@ export async function runUV( spawnOptions.timeout = timeout; } const proc = spawnProcess('uv', args, spawnOptions); + let cancellationRequested = false; token?.onCancellationRequested(() => { - proc.kill(); + cancellationRequested = true; + try { + proc.kill(); + } catch { + // Preserve cancellation when signaling fails. + } reject(new CancellationError()); }); proc.on('error', (err) => { + if (cancellationRequested) { + reject(new CancellationError()); + return; + } log?.error(`Error spawning uv: ${err}`); reject(new Error(`Error spawning uv: ${err.message}`)); }); @@ -114,12 +124,22 @@ export async function runPython( log?.info(`Running: ${python} ${args.join(' ')}`); return new Promise((resolve, reject) => { const proc = spawnProcess(python, args, { cwd: cwd, timeout }); + let cancellationRequested = false; token?.onCancellationRequested(() => { - proc.kill(); + cancellationRequested = true; + try { + proc.kill(); + } catch { + // Preserve cancellation when signaling fails. + } reject(new CancellationError()); }); proc.on('error', (err) => { + if (cancellationRequested) { + reject(new CancellationError()); + return; + } log?.error(`Error spawning python: ${err}`); reject(new Error(`Error spawning python: ${err.message}`)); }); diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index 4efab305..c0614699 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -1,7 +1,16 @@ import * as fsapi from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; -import { l10n, LogOutputChannel, ProgressLocation, QuickPickItem, QuickPickItemKind, ThemeIcon, Uri } from 'vscode'; +import { + CancellationError, + l10n, + LogOutputChannel, + ProgressLocation, + QuickPickItem, + QuickPickItemKind, + ThemeIcon, + Uri, +} from 'vscode'; import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../api'; import { ENVS_EXTENSION_ID } from '../../common/constants'; import { Common, VenvManagerStrings } from '../../common/localize'; @@ -10,6 +19,7 @@ import { getWorkspacePersistentState } from '../../common/persistentState'; import { EventNames } from '../../common/telemetry/constants'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { normalizePath } from '../../common/utils/pathUtils'; +import { getVenvPythonPath } from '../../common/utils/virtualEnvironment'; import { showErrorMessage, showOpenDialog, @@ -52,6 +62,9 @@ export interface CreateEnvironmentResult { * Exists if error occurred while installing packages and includes error description. */ pkgInstallationErr?: string; + + /** Cancellation may leave package processes running. */ + pkgInstallationCancelled?: boolean; } export async function clearVenvCache(): Promise { @@ -340,9 +353,9 @@ export async function createWithProgress( venvRoot: Uri, envPath: string, packages?: PipPackages, + trackUvEnvironment = true, ): Promise { - const pythonPath = - os.platform() === 'win32' ? path.join(envPath, 'Scripts', 'python.exe') : path.join(envPath, 'bin', 'python'); + const pythonPath = getVenvPythonPath(envPath); return await withProgress( { @@ -383,6 +396,7 @@ export async function createWithProgress( const env = api.createPythonEnvironmentItem(await getPythonInfo(resolved), manager); if ( + trackUvEnvironment && useUv && (resolved.kind === NativePythonEnvironmentKind.venvUv || resolved.kind === NativePythonEnvironmentKind.uvWorkspace) @@ -401,6 +415,7 @@ export async function createWithProgress( } catch (e) { // error occurred while installing packages result.pkgInstallationErr = e instanceof Error ? e.message : String(e); + result.pkgInstallationCancelled = e instanceof CancellationError; } } result.environment = env; diff --git a/src/test/common/lockfile.apis.unit.test.ts b/src/test/common/lockfile.apis.unit.test.ts new file mode 100644 index 00000000..a2d343a1 --- /dev/null +++ b/src/test/common/lockfile.apis.unit.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; +import fsExtra from 'fs-extra'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { acquireFileLock, AcquireFileLockOptions } from '../../common/lockfile.apis'; + +const OPTIONS: AcquireFileLockOptions = { + timeoutMs: 40, + retryIntervalMs: 5, +}; + +const LOCK_MODULE_PATH = path.resolve(__dirname, '..', '..', 'common', 'lockfile.apis.js'); + +suite('lockfile APIs', () => { + let tempRoot: string; + let targetPath: string; + + setup(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'python-envs-lock-')); + targetPath = path.join(tempRoot, 'cache-entry'); + }); + + teardown(async () => { + sinon.restore(); + await fs.remove(tempRoot); + }); + + function startLockingChild(exitWithoutRelease: boolean): ChildProcessWithoutNullStreams { + const script = ` + const { acquireFileLock } = require(process.argv[1]); + acquireFileLock(process.argv[2], { timeoutMs: 1000, retryIntervalMs: 10 }) + .then((lock) => { + process.stdout.write('locked\\n'); + if (${exitWithoutRelease}) { + process.exit(0); + } + process.stdin.once('data', async () => { + await lock.release(); + process.exit(0); + }); + }) + .catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }); + `; + return spawn(process.execPath, ['-e', script, LOCK_MODULE_PATH, targetPath]); + } + + async function waitForLocked(child: ChildProcessWithoutNullStreams): Promise { + await new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (data) => { + stdout += data.toString(); + if (stdout.includes('locked')) { + resolve(); + } + }); + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + child.once('error', reject); + child.once('exit', (code) => { + if (!stdout.includes('locked')) { + reject(new Error(`locking child exited with code ${code}: ${stderr}`)); + } + }); + }); + } + + async function waitForExit(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null) { + assert.strictEqual(child.exitCode, 0); + return; + } + await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`locking child exited with code ${code}`)); + } + }); + }); + } + + test('excludes a second owner until the first releases the lock', async () => { + const lock = await acquireFileLock(targetPath, OPTIONS); + await assert.rejects(acquireFileLock(targetPath, OPTIONS), (error: NodeJS.ErrnoException) => { + return error.code === 'ELOCKED'; + }); + + await lock.release(); + const lockAfterRetry = await acquireFileLock(targetPath, OPTIONS); + await lockAfterRetry.release(); + assert.strictEqual(await fs.pathExists(`${path.resolve(targetPath)}.lock`), false); + }); + + test('excludes another process until the owner explicitly releases', async () => { + const child = startLockingChild(false); + await waitForLocked(child); + + await assert.rejects(acquireFileLock(targetPath, OPTIONS), (error: NodeJS.ErrnoException) => { + return error.code === 'ELOCKED'; + }); + + child.stdin.write('release\n'); + await waitForExit(child); + const lock = await acquireFileLock(targetPath, OPTIONS); + await lock.release(); + }); + + test('leaves the lock fail-closed when the owner process exits without release', async () => { + const child = startLockingChild(true); + await waitForLocked(child); + await waitForExit(child); + + await assert.rejects(acquireFileLock(targetPath, OPTIONS), (error: NodeJS.ErrnoException) => { + return error.code === 'ELOCKED'; + }); + assert.strictEqual(await fs.pathExists(`${path.resolve(targetPath)}.lock`), true); + }); + + test('an old release cannot remove a successor lock generation', async () => { + const lock = await acquireFileLock(targetPath, OPTIONS); + const lockPath = `${path.resolve(targetPath)}.lock`; + await fs.remove(lockPath); + await fs.ensureDir(lockPath); + const successorMarker = path.join(lockPath, 'successor-owner'); + await fs.writeFile(successorMarker, ''); + + await assert.rejects(lock.release(), (error: NodeJS.ErrnoException) => { + return error.code === 'ECOMPROMISED'; + }); + assert.strictEqual(await fs.pathExists(successorMarker), true); + }); + + test('release is idempotent', async () => { + const lock = await acquireFileLock(targetPath, OPTIONS); + + await lock.release(); + await lock.release(); + + assert.strictEqual(await fs.pathExists(`${path.resolve(targetPath)}.lock`), false); + }); + + test('retained locks fail fast without waiting for the acquisition timeout', async () => { + const lock = await acquireFileLock(targetPath, OPTIONS); + await lock.retain(); + const startedAt = Date.now(); + + await assert.rejects( + acquireFileLock(targetPath, { timeoutMs: 10_000, retryIntervalMs: 1_000 }), + (error: NodeJS.ErrnoException) => error.code === 'ELOCKRETAINED', + ); + + assert.ok(Date.now() - startedAt < 1_000); + const lockPath = `${path.resolve(targetPath)}.lock`; + const retainedEntries = await fs.readdir(lockPath); + assert.ok(retainedEntries.includes('retained')); + assert.strictEqual(retainedEntries.filter((entry) => entry.startsWith('owner-')).length, 1); + + await lock.release(); + assert.deepStrictEqual(await fs.readdir(lockPath), retainedEntries); + }); + + test('falls back to renaming the owner marker when the retained sentinel cannot be written', async () => { + const lock = await acquireFileLock(targetPath, OPTIONS); + sinon.stub(fsExtra, 'writeFile').rejects(Object.assign(new Error('write failed'), { code: 'EACCES' })); + + await lock.retain(); + + const lockPath = `${path.resolve(targetPath)}.lock`; + assert.deepStrictEqual(await fs.readdir(lockPath), ['retained']); + await assert.rejects(acquireFileLock(targetPath, OPTIONS), (error: NodeJS.ErrnoException) => { + return error.code === 'ELOCKRETAINED'; + }); + }); + + test('remains fail-closed when neither retained-marker strategy succeeds', async () => { + const lock = await acquireFileLock(targetPath, OPTIONS); + sinon.stub(fsExtra, 'writeFile').rejects(Object.assign(new Error('write failed'), { code: 'EACCES' })); + sinon.stub(fsExtra, 'rename').rejects(Object.assign(new Error('rename failed'), { code: 'EBUSY' })); + + await assert.rejects(lock.retain(), (error: NodeJS.ErrnoException) => error.code === 'ERETAINFAILED'); + await lock.release(); + + const lockPath = `${path.resolve(targetPath)}.lock`; + const lockEntries = await fs.readdir(lockPath); + assert.strictEqual(lockEntries.filter((entry) => entry.startsWith('owner-')).length, 1); + await assert.rejects(acquireFileLock(targetPath, OPTIONS), (error: NodeJS.ErrnoException) => { + return error.code === 'ELOCKED'; + }); + }); + + test('reports an owner-less lock when initialization cleanup fails', async () => { + sinon.stub(fsExtra, 'writeFile').rejects(Object.assign(new Error('write failed'), { code: 'EIO' })); + sinon.stub(fsExtra, 'rmdir').rejects(Object.assign(new Error('cleanup failed'), { code: 'EACCES' })); + + await assert.rejects(acquireFileLock(targetPath, OPTIONS), (error: NodeJS.ErrnoException) => { + return error.code === 'ELOCKORPHANED' && error.path === `${path.resolve(targetPath)}.lock`; + }); + }); +}); diff --git a/src/test/common/virtualEnvironment.unit.test.ts b/src/test/common/virtualEnvironment.unit.test.ts new file mode 100644 index 00000000..093ab710 --- /dev/null +++ b/src/test/common/virtualEnvironment.unit.test.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as platformUtils from '../../common/utils/platformUtils'; +import { getVenvPythonPath } from '../../common/utils/virtualEnvironment'; + +suite('virtual environment utilities', () => { + teardown(() => { + sinon.restore(); + }); + + test('uses Scripts/python.exe on Windows', () => { + sinon.stub(platformUtils, 'isWindows').returns(true); + assert.strictEqual( + getVenvPythonPath(path.join('cache', 'env')), + path.join('cache', 'env', 'Scripts', 'python.exe'), + ); + }); + + test('uses bin/python outside Windows', () => { + sinon.stub(platformUtils, 'isWindows').returns(false); + assert.strictEqual(getVenvPythonPath(path.join('cache', 'env')), path.join('cache', 'env', 'bin', 'python')); + }); +}); diff --git a/src/test/managers/builtin/helpers.cancellation.unit.test.ts b/src/test/managers/builtin/helpers.cancellation.unit.test.ts new file mode 100644 index 00000000..5a458306 --- /dev/null +++ b/src/test/managers/builtin/helpers.cancellation.unit.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as sinon from 'sinon'; +import { CancellationError, CancellationToken, CancellationTokenSource } from 'vscode'; +import * as childProcessApis from '../../../common/childProcess.apis'; +import { runPython, runUV } from '../../../managers/builtin/helpers'; +import { MockChildProcess } from '../../mocks/mockChildProcess'; + +suite('process helper cancellation safety', () => { + let spawnStub: sinon.SinonStub; + + setup(() => { + spawnStub = sinon.stub(childProcessApis, 'spawnProcess'); + }); + + teardown(() => { + sinon.restore(); + }); + + async function expectCancellation( + process: MockChildProcess, + run: (token: CancellationToken) => Promise, + killBehavior: 'emitError' | 'throw', + ): Promise { + spawnStub.returns(process); + const killStub = sinon.stub(process, 'kill'); + if (killBehavior === 'emitError') { + killStub.callsFake(() => { + process.emit('error', new Error('kill EPERM')); + return false; + }); + } else { + killStub.throws(new Error('kill EPERM')); + } + + const tokenSource = new CancellationTokenSource(); + const result = run(tokenSource.token); + tokenSource.cancel(); + + await assert.rejects(result, (error: Error) => { + assert.ok(error instanceof CancellationError); + return true; + }); + assert.ok(killStub.calledOnce); + tokenSource.dispose(); + } + + test('runUV remains cancelled when kill emits an error synchronously', async () => { + const process = new MockChildProcess('uv', ['pip', 'install', 'requests']); + await expectCancellation( + process, + (token) => runUV(['pip', 'install', 'requests'], undefined, undefined, token), + 'emitError', + ); + }); + + test('runUV remains cancelled when kill throws', async () => { + const process = new MockChildProcess('uv', ['pip', 'install', 'requests']); + await expectCancellation( + process, + (token) => runUV(['pip', 'install', 'requests'], undefined, undefined, token), + 'throw', + ); + }); + + test('runPython remains cancelled when kill emits an error synchronously', async () => { + const process = new MockChildProcess('python', ['-m', 'pip', 'install', 'requests']); + await expectCancellation( + process, + (token) => runPython('python', ['-m', 'pip', 'install', 'requests'], undefined, undefined, token), + 'emitError', + ); + }); + + test('runPython remains cancelled when kill throws', async () => { + const process = new MockChildProcess('python', ['-m', 'pip', 'install', 'requests']); + await expectCancellation( + process, + (token) => runPython('python', ['-m', 'pip', 'install', 'requests'], undefined, undefined, token), + 'throw', + ); + }); +}); diff --git a/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts b/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts new file mode 100644 index 00000000..bd8e720e --- /dev/null +++ b/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { CancellationError, LogOutputChannel, Uri } from 'vscode'; +import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import * as windowApis from '../../../common/window.apis'; +import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment'; +import * as builtinHelpers from '../../../managers/builtin/helpers'; +import * as uvEnvironments from '../../../managers/builtin/uvEnvironments'; +import { createWithProgress } from '../../../managers/builtin/venvUtils'; +import { NativePythonEnvironmentKind, NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import * as managerUtils from '../../../managers/common/utils'; + +suite('createWithProgress uv tracking', () => { + let addUvEnvironmentStub: sinon.SinonStub; + let api: PythonEnvironmentApi; + let baseEnvironment: PythonEnvironment; + let envPath: string; + let log: LogOutputChannel; + let manager: EnvironmentManager; + let nativeFinder: NativePythonFinder; + let tempRoot: string; + + setup(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'create-with-progress-')); + envPath = path.join(tempRoot, 'env'); + const pythonPath = getVenvPythonPath(envPath); + await fs.outputFile(pythonPath, ''); + + baseEnvironment = { + envId: { id: 'base', managerId: 'ms-python.python:system' }, + name: 'base', + displayName: 'base', + displayPath: pythonPath, + version: '3.12.4', + environmentPath: Uri.file(pythonPath), + execInfo: { run: { executable: pythonPath } }, + sysPrefix: tempRoot, + }; + const createdEnvironment = { + ...baseEnvironment, + envId: { id: 'created', managerId: 'ms-python.python:inline-script' }, + }; + api = { + createPythonEnvironmentItem: sinon.stub().returns(createdEnvironment), + managePackages: sinon.stub().resolves(), + } as unknown as PythonEnvironmentApi; + nativeFinder = { + resolve: sinon.stub().resolves({ + executable: pythonPath, + prefix: envPath, + version: '3.12.4', + kind: NativePythonEnvironmentKind.venvUv, + }), + } as unknown as NativePythonFinder; + log = { + error: sinon.stub(), + info: sinon.stub(), + append: sinon.stub(), + } as unknown as LogOutputChannel; + manager = { log } as EnvironmentManager; + + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); + sinon.stub(builtinHelpers, 'shouldUseUv').resolves(true); + sinon.stub(builtinHelpers, 'runUV').resolves(''); + sinon.stub(managerUtils, 'getShellActivationCommands').resolves({ + shellActivation: new Map(), + shellDeactivation: new Map(), + }); + addUvEnvironmentStub = sinon.stub(uvEnvironments, 'addUvEnvironment').resolves(); + }); + + teardown(async () => { + sinon.restore(); + await fs.remove(tempRoot); + }); + + test('tracks uv environments by default for existing callers', async () => { + const result = await createWithProgress( + nativeFinder, + api, + log, + manager, + baseEnvironment, + Uri.file(tempRoot), + envPath, + ); + + assert.ok(result?.environment); + assert.ok(addUvEnvironmentStub.calledOnce); + }); + + test('skips workspace-scoped uv tracking when explicitly disabled', async () => { + const result = await createWithProgress( + nativeFinder, + api, + log, + manager, + baseEnvironment, + Uri.file(tempRoot), + envPath, + undefined, + false, // trackUvEnvironment + ); + + assert.ok(result?.environment); + assert.strictEqual(addUvEnvironmentStub.callCount, 0); + }); + + test('marks cancelled package installation as potentially still mutating', async () => { + (api.managePackages as sinon.SinonStub).rejects(new CancellationError()); + + const result = await createWithProgress( + nativeFinder, + api, + log, + manager, + baseEnvironment, + Uri.file(tempRoot), + envPath, + { install: ['requests'], uninstall: [] }, + false, // trackUvEnvironment + ); + + assert.ok(result?.environment); + assert.strictEqual(typeof result.pkgInstallationErr, 'string'); + assert.strictEqual(result.pkgInstallationCancelled, true); + }); +}); From 293f0ecb7e7fe93ec65079c1b50dba1fdd418116 Mon Sep 17 00:00:00 2001 From: Eleanor Boyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:40:36 -0700 Subject: [PATCH 3/5] marketplace: remove preview status (#1660) ## Summary - remove the extension-level Marketplace preview designation - remove stale README language about the completed rollout - retain labels for individual features that are still experimental Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 6 +----- package.json | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/README.md b/README.md index 2782b6ac..ff8a3de6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -# Python Environments (preview) - -> **Note:** The Python Environments icon may no longer appear in the Activity Bar due to the ongoing rollout of the Python Environments extension. To restore the extension, add `"python.useEnvironmentsExtension": true` to your User settings. This setting is temporarily necessary until the rollout is complete! +# Python Environments ## Overview @@ -11,8 +9,6 @@ The Python Environments extension for VS Code helps you manage Python environmen - ✅ Create activated terminals - 🖌️ Add and create new Python projects -> **Note:** This extension is in preview, and its APIs and features are subject to change as the project evolves. - > **Important:** This extension requires version `2024.23`, or later, of the Python extension (`ms-python.python`). ## Features diff --git a/package.json b/package.json index a8878394..94112947 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "description": "Provides a unified python environment experience", "version": "1.37.0", "publisher": "ms-python", - "preview": true, "engines": { "vscode": "^1.110.0-20260204" }, From 96e049409012da873971975ad8390ab3fc092493 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:29:25 -0700 Subject: [PATCH 4/5] chore: decouple API package version from extension version (#1670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `version-match` CI job blocked release PRs (e.g. #1668) whenever the extension and API package versions diverged. These versions should be independent — the API package is separately published and versioned. ## Changes - **`api/package.json`** — Reset version `1.37.0` → `1.0.0` - **`api/package-lock.json`** — Reset both root-level and `packages[""]` version fields to match - **`.github/workflows/pr-file-check.yml`** — Remove the `version-match` job entirely; the check requiring `api/package.json` to be bumped on public API changes (`src/api.ts`) is preserved --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .github/workflows/pr-file-check.yml | 17 ----------------- api/package-lock.json | 4 ++-- api/package.json | 2 +- 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/.github/workflows/pr-file-check.yml b/.github/workflows/pr-file-check.yml index 1450ee6f..bbe3f575 100644 --- a/.github/workflows/pr-file-check.yml +++ b/.github/workflows/pr-file-check.yml @@ -57,20 +57,3 @@ jobs: file-pattern: 'api/CHANGELOG.md' skip-label: 'skip api changelog' failure-message: 'The public API (${prereq-pattern}) was changed without a changelog entry in ${file-pattern} (the ${skip-label} label can be used to pass this check)' - - version-match: - name: 'Extension and API package versions match' - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - - name: 'Compare package.json versions' - run: python scripts/compare_package_versions.py diff --git a/api/package-lock.json b/api/package-lock.json index d74fb7e2..f8d4912d 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/python-environments", - "version": "1.37.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vscode/python-environments", - "version": "1.37.0", + "version": "1.0.0", "license": "MIT", "dependencies": { "@renovatebot/pep440": "^3.1.0" diff --git a/api/package.json b/api/package.json index 00d4aa84..042d6409 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "@vscode/python-environments", "description": "An API facade for the Python Environments extension in VS Code", - "version": "1.37.0", + "version": "1.0.0", "author": { "name": "Microsoft Corporation" }, From d72d5df4e3ee7dbf75d036af8ef85aef42731e07 Mon Sep 17 00:00:00 2001 From: Mohit Yadav Date: Fri, 7 Aug 2026 08:36:58 +0530 Subject: [PATCH 5/5] fix: restore trailing newline in package.json Signed-off-by: Mohit Yadav --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7adc4787..05eed47c 100644 --- a/package.json +++ b/package.json @@ -750,4 +750,4 @@ "overrides": { "serialize-javascript": "^7.0.3" } -} \ No newline at end of file +}