Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/common/utils/pathUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ export function normalizePath(fsPath: string): string {
return path1;
}

/**
* Returns `true` when `candidateFsPath` is the same path as, or nested inside, `scopeFsPath`
* (inclusive of the scope itself). Both operands are resolved to absolute paths and compared with
* `path.relative`, so sibling directories sharing a name prefix (e.g. `.../app` vs `.../app-2`) are
* correctly treated as outside the scope.
*/
export function isPathInside(scopeFsPath: string, candidateFsPath: string): boolean {
const relative = path.relative(path.resolve(scopeFsPath), path.resolve(candidateFsPath));
return (
relative === '' ||
(relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
);
Comment thread
StellaHuang95 marked this conversation as resolved.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue · Please address or respond

This duplicates PythonProjectManagerImpl.isUriMatching, and the implementations already disagree for filesystem-root projects. Consolidate project ownership on the shared containment helper so scoped-refresh ownership matches the project manager.

}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue · Please address or respond

This duplicates PythonProjectManagerImpl.isUriMatching with conflicting filesystem-root semantics. Consolidate project ownership and scoped-refresh containment on one shared implementation so these rules cannot drift.

[verified]

export function getResourceUri(resourcePath: string, root?: string): Uri | undefined {
try {
if (!resourcePath) {
Expand Down
67 changes: 57 additions & 10 deletions src/managers/builtin/venvManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { PYTHON_EXTENSION_ID } from '../../common/constants';
import { VenvManagerStrings } from '../../common/localize';
import { traceError, traceWarn } from '../../common/logging';
import { createDeferred, Deferred } from '../../common/utils/deferred';
import { normalizePath } from '../../common/utils/pathUtils';
import { normalizePath, isPathInside } from '../../common/utils/pathUtils';
import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis';
import { findParentIfFile } from '../../features/envCommands';
import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath';
Expand Down Expand Up @@ -324,12 +324,7 @@ export class VenvManager implements EnvironmentManager {
title,
},
async () => {
const discard = this.collection.map((env) => ({
kind: EnvironmentChangeKind.remove,
environment: env,
}));

this.collection =
const discovered =
(await findVirtualEnvironments(
hardRefresh,
this.nativeFinder,
Expand All @@ -338,14 +333,66 @@ export class VenvManager implements EnvironmentManager {
this,
scope ? [scope] : undefined,
)) ?? [];
await this.loadEnvMap();

const added = this.collection.map((env) => ({ environment: env, kind: EnvironmentChangeKind.add }));
this._onDidChangeEnvironments.fire([...discard, ...added]);
let changes: DidChangeEnvironmentsEventArgs;
if (scope) {
changes = await this.mergeScopedEnvironments(scope, discovered);
} else {
const discard = this.collection.map((env) => ({
kind: EnvironmentChangeKind.remove,
environment: env,
}));
this.collection = discovered;
await this.loadEnvMap();
const added = this.collection.map((env) => ({
environment: env,
kind: EnvironmentChangeKind.add,
}));
changes = [...discard, ...added];
}

this._onDidChangeEnvironments.fire(changes);
},
);
}

// A scoped discovery is authoritative only within `scope`: environments in other workspace
// folders (and globals outside it) are retained untouched, so they neither disappear nor emit
// events; only in-scope environments are replaced by the freshly discovered ones.
private async mergeScopedEnvironments(
scope: Uri,
discovered: PythonEnvironment[],
): Promise<DidChangeEnvironmentsEventArgs> {
let scopeDir: string;
try {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue · Please address or respond

findParentIfFile makes a nested file scope such as project/src/main.py merge at project/src. The existing project/.venv is therefore retained, and its rediscovered replacement is discarded by the retained-path filter. Resolve the owning project for the scope before discovery and merging, and add a nested-subdirectory regression test.

[verified]

scopeDir = await findParentIfFile(scope.fsPath);
} catch {
scopeDir = scope.fsPath;
}
const inScope = (env: PythonEnvironment): boolean => isPathInside(scopeDir, env.environmentPath.fsPath);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue · Please address or respond

For a nested file scope such as project/src/main.py, findParentIfFile produces project/src, so the existing project/.venv is retained and the rediscovered replacement is filtered out by path. Resolve the owning Python project URI before partitioning, and cover a file below the project root with a regression test.


const retained: PythonEnvironment[] = [];
const removed: PythonEnvironment[] = [];
for (const env of this.collection) {
(inScope(env) ? removed : retained).push(env);
}
const retainedIds = new Set(retained.map((env) => env.envId.id));
const retainedPaths = new Set(retained.map((env) => normalizePath(env.environmentPath.fsPath)));

this.collection = [
...retained,
...discovered.filter((env) => !retainedPaths.has(normalizePath(env.environmentPath.fsPath))),
];
await this.loadEnvMap();

return [
...removed.map((env) => ({ kind: EnvironmentChangeKind.remove, environment: env })),
...this.collection
.filter((env) => !retainedIds.has(env.envId.id))
.map((env) => ({ environment: env, kind: EnvironmentChangeKind.add })),
];

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue · Please address or respond

The merge only deduplicates discovered paths against retained environments; it does not exclude newly discovered out-of-scope venvFolders results. A scoped refresh can therefore add and emit an event for an uncached global environment. Filter discovery results to the authoritative scope, or classify them by project ownership/provenance before merging.

}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue · Please address or respond

loadEnvMap() clears the complete project map before reading each previousEnv, so refreshing workspace A can report persisted selections in unrelated workspace B as newly selected. Preserve the previous map for comparison, or reconcile only the scoped project during a scoped refresh.

[verified]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue · Please address or respond

Calling the collection-wide loadEnvMap() during a scoped refresh reprocesses retained environments from unrelated projects, which can re-report their persisted selections. Preserve the existing map entries or reconcile only the refreshed project.


async getEnvironments(scope: GetEnvironmentsScope): Promise<PythonEnvironment[]> {
await this.initialize();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

Path-only partitioning treats configured global environments physically beneath the project scope, including every environment for a filesystem-root workspace, as authoritative in-scope data. Exclude configured global roots from the replaceable partition or classify discovery results by ownership/provenance.

[verified]

Expand Down
51 changes: 50 additions & 1 deletion src/test/common/pathUtils.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import assert from 'node:assert';
import * as path from 'node:path';
import * as sinon from 'sinon';
import { Uri } from 'vscode';
import { getResourceUri, normalizePath } from '../../common/utils/pathUtils';
import { getResourceUri, isPathInside, normalizePath } from '../../common/utils/pathUtils';
import * as utils from '../../common/utils/platformUtils';

suite('Path Utilities', () => {
Expand Down Expand Up @@ -128,4 +129,52 @@ suite('Path Utilities', () => {
assert.strictEqual(result, 'C:/Path/To/File.txt');
});
});

suite('isPathInside', () => {
const root = path.join(path.parse(process.cwd()).root, 'workspaces', 'app');

test('returns true when the candidate equals the scope (inclusive of scope.fsPath)', () => {
assert.strictEqual(isPathInside(root, root), true);
});

test('returns true for a direct child path', () => {
assert.strictEqual(isPathInside(root, path.join(root, '.venv')), true);
});

test('returns true for a deeply nested child path', () => {
assert.strictEqual(isPathInside(root, path.join(root, '.venv', 'bin', 'python')), true);
});

test('returns false for the parent directory', () => {
assert.strictEqual(isPathInside(root, path.dirname(root)), false);
});

test('returns false for a sibling directory that shares a name prefix (app vs app-2)', () => {
const sibling = path.join(path.dirname(root), 'app-2', '.venv', 'bin', 'python');
assert.strictEqual(isPathInside(root, sibling), false);
});

test('returns false for an unrelated directory', () => {
const unrelated = path.join(path.dirname(root), 'other', '.venv');
assert.strictEqual(isPathInside(root, unrelated), false);
});

test('resolves relative segments in the candidate before comparing', () => {
assert.strictEqual(isPathInside(root, path.join(root, 'pkg', '..', '.venv')), true);
});

test('returns false for a path on a different Windows drive', function () {
if (process.platform !== 'win32') {
this.skip();
}
assert.strictEqual(isPathInside('C:\\workspaces\\app', 'D:\\workspaces\\app\\.venv'), false);
});

test('is case-insensitive on Windows (drive letter and folder casing)', function () {
if (process.platform !== 'win32') {
this.skip();
}
assert.strictEqual(isPathInside('C:\\Workspaces\\App', 'c:\\workspaces\\app\\.venv\\Scripts\\python.exe'), true);
});
});
});
Loading
Loading