Skip to content
Open
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
158 changes: 135 additions & 23 deletions src/managers/builtin/pipUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import { PackageManagementOptions, PythonEnvironment, PythonEnvironmentApi, Pyth
import { EXTENSION_ROOT_DIR } from '../../common/constants';
import { PackageManagement, Pickers, VenvManagerStrings } from '../../common/localize';
import { traceInfo } from '../../common/logging';
import { normalizePath } from '../../common/utils/pathUtils';
import { showQuickPickWithButtons, withProgress } from '../../common/window.apis';
import { findFiles } from '../../common/workspace.apis';
import { selectFromCommonPackagesToInstall, selectFromInstallableToInstall } from '../common/pickers';
import { Installable } from '../common/types';
import { mergePackages } from '../common/utils';
import { refreshPipPackages } from './utils';
import { normalizePackageName, refreshPipPackages } from './utils';

export interface PyprojectToml {
project?: {
Expand Down Expand Up @@ -80,6 +81,17 @@ function isPipInstallableToml(toml: tomljs.JsonMap): boolean {
return toml['build-system'] !== undefined && toml.project !== undefined;
}

/**
* Extracts the declared package name from a parsed `pyproject.toml` (the
* `[project].name` field, PEP 621). Returns `undefined` when the file does not
* declare a name.
*/
function getTomlProjectName(toml: tomljs.JsonMap): string | undefined {
const project = toml.project as tomljs.JsonMap | undefined;
const name = project?.name;
return typeof name === 'string' && name.length > 0 ? name : undefined;
}

function getTomlInstallable(toml: tomljs.JsonMap, tomlPath: Uri): Installable[] {
const extras: Installable[] = [];
const projectDir = path.dirname(tomlPath.fsPath);
Expand Down Expand Up @@ -239,6 +251,19 @@ export interface ValidationError {
fileUri: Uri;
}

export interface ProjectInstallableOptions {
/**
* Prevent automatic installation from passing multiple editable sources for
* the same Python package to pip.
*/
deduplicateProjectPackages?: boolean;

/**
* Creation root used to prefer the pyproject.toml that owns the environment.
*/
preferredRoot?: Uri;
}

export async function getWorkspacePackagesToInstall(
api: PythonEnvironmentApi,
options: PackageManagementOptions,
Expand All @@ -259,6 +284,7 @@ export async function getWorkspacePackagesToInstall(
export async function getProjectInstallable(
api: PythonEnvironmentApi,
projects?: PythonProject[],
options?: ProjectInstallableOptions,
): Promise<ProjectInstallableResult> {
if (!projects) {
return { installables: [] };
Expand Down Expand Up @@ -312,38 +338,124 @@ export async function getProjectInstallable(
if (depthA !== depthB) {
return depthA - depthB;
}
return a.fsPath.localeCompare(b.fsPath);
const pathA = normalizePath(a.fsPath);
const pathB = normalizePath(b.fsPath);
return pathA < pathB ? -1 : pathA > pathB ? 1 : 0;
});

// Parse all TOML files first (in parallel), keyed by fsPath, so the
// subsequent duplicate-detection pass can run sequentially and
// deterministically over the depth-sorted `filtered` list.
const parsedTomls = new Map<string, tomljs.JsonMap>();
await Promise.all(
filtered.map(async (uri) => {
if (uri.fsPath.endsWith('.toml')) {
const toml = await tomlParse(uri.fsPath);
parsedTomls.set(uri.fsPath, toml);
}
}),
);

// Validate pyproject.toml
if (!validationError) {
const error = validatePyprojectToml(toml);
if (error) {
validationError = {
message: error,
fileUri: uri,
};
}
}
const selectedTomls = new Map<string, Uri>();
const ambiguousProjectNames = new Set<string>();
if (options?.deduplicateProjectPackages) {
const tomlsByProjectName = new Map<string, Uri[]>();
for (const uri of filtered) {
if (!uri.fsPath.endsWith('.toml')) {
continue;
}
const toml = parsedTomls.get(uri.fsPath) ?? {};
const projectName = getTomlProjectName(toml);
if (!projectName || getTomlInstallable(toml, uri).length === 0) {
continue;
}
const normalizedName = normalizePackageName(projectName);
const candidates = tomlsByProjectName.get(normalizedName) ?? [];
candidates.push(uri);
tomlsByProjectName.set(normalizedName, candidates);
}

installable.push(...getTomlInstallable(toml, uri));
tomlsByProjectName.forEach((candidates, projectName) => {
if (candidates.length === 1) {
selectedTomls.set(projectName, candidates[0]);
return;
}

const preferredRoot = options.preferredRoot?.fsPath;
const preferredCandidates = preferredRoot
? candidates
.filter((candidate) => {
const projectDir = path.dirname(candidate.fsPath);
const relative = path.relative(projectDir, preferredRoot);
return (
relative === '' ||
(relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative))
);
})
.sort(
(a, b) =>
path.dirname(b.fsPath).length - path.dirname(a.fsPath).length,
)
: [];

if (preferredCandidates.length > 0) {
selectedTomls.set(projectName, preferredCandidates[0]);
} else {
const name = path.basename(uri.fsPath);
installable.push({
name,
uri,
displayName: name,
group: 'Requirements',
args: ['-r', uri.fsPath],
});
ambiguousProjectNames.add(projectName);
traceInfo(
`Skipping ambiguous editable installs for package "${projectName}" because no ` +
'candidate contains the environment creation root.',
);
}
}),
);
});
}

for (const uri of filtered) {
if (uri.fsPath.endsWith('.toml')) {
const toml = parsedTomls.get(uri.fsPath) ?? {};
const tomlInstallables = getTomlInstallable(toml, uri);

const projectName = getTomlProjectName(toml);
if (options?.deduplicateProjectPackages && projectName && tomlInstallables.length > 0) {
const normalizedName = normalizePackageName(projectName);
const selected = selectedTomls.get(normalizedName);
if (ambiguousProjectNames.has(normalizedName)) {
continue;
}
if (selected && selected.fsPath !== uri.fsPath) {
traceInfo(
`Skipping duplicate project package "${projectName}" from ${uri.fsPath}; it is ` +
`already provided by ${selected.fsPath}.`,
);
continue;
}
}

// Validate pyproject.toml (report only the first error found).
if (!validationError) {
const error = validatePyprojectToml(toml);
if (error) {
validationError = {
message: error,
fileUri: uri,
};
}
}

installable.push(...tomlInstallables);
} else {
const name = path.basename(uri.fsPath);
installable.push({
name,
uri,
displayName: name,
group: 'Requirements',
args: ['-r', uri.fsPath],
});
}
}
},
);

Expand Down
5 changes: 4 additions & 1 deletion src/managers/builtin/venvStepBasedFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,10 @@ export async function createStepBasedVenvFlow(

// Get workspace dependencies to install
const project = api.getPythonProject(venvRoot);
const result = await getProjectInstallable(api, project ? [project] : undefined);
const result = await getProjectInstallable(api, project ? [project] : undefined, {
deduplicateProjectPackages: true,
preferredRoot: venvRoot,
});
const installables = result.installables;
const allPackages = [];
allPackages.push(...(installables?.flatMap((i) => i.args ?? []) ?? []));
Expand Down
5 changes: 4 additions & 1 deletion src/managers/builtin/venvUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,10 @@ export async function quickCreateVenv(
const project = api.getPythonProject(venvRoot);

sendTelemetryEvent(EventNames.VENV_CREATION, undefined, { creationType: 'quick' });
const result = await getProjectInstallable(api, project ? [project] : undefined);
const result = await getProjectInstallable(api, project ? [project] : undefined, {
deduplicateProjectPackages: true,
preferredRoot: venvRoot,
});
const installables = result.installables;
const allPackages = [];
allPackages.push(...(installables?.flatMap((i) => i.args ?? []) ?? []));
Expand Down
Loading
Loading