Skip to content
Draft
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
6 changes: 6 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,12 @@ export interface PythonProject {
* The tooltip for the Python project, which can be a string or a Markdown string.
*/
readonly tooltip?: string | MarkdownString;

/**
* Finds the preferred project setup file, such as `pyproject.toml`, `setup.py`, or `requirements.txt`.
* @returns The setup file URI, or `undefined` when no supported setup file exists.
*/
discoverProjectSetupFile?(): Promise<Uri | undefined>;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/features/creators/autoFindProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { Uri } from 'vscode';
import { PythonProject, PythonProjectCreator, PythonProjectCreatorOptions } from '../../api';
import { ProjectCreatorString } from '../../common/localize';
import { traceInfo } from '../../common/logging';
import { normalizePath } from '../../common/utils/pathUtils';
import { showErrorMessage, showQuickPickWithButtons, showWarningMessage } from '../../common/window.apis';
import { findFiles } from '../../common/workspace.apis';
import { PythonProjectManager, PythonProjectsImpl } from '../../internal.api';
import { normalizePath } from '../../common/utils/pathUtils';

function getUniqueUri(uris: Uri[]): {
label: string;
Expand Down
7 changes: 2 additions & 5 deletions src/features/projectManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Disposable, EventEmitter, MarkdownString, Uri, workspace } from 'vscode
import { IconPath, PythonProject } from '../api';
import { DEFAULT_ENV_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID } from '../common/constants';
import { createSimpleDebounce } from '../common/utils/debounce';
import { normalizePath } from '../common/utils/pathUtils';
import {
getConfiguration,
getWorkspaceFolders,
Expand All @@ -12,7 +13,6 @@ import {
onDidRenameFiles,
} from '../common/workspace.apis';
import { PythonProjectManager, PythonProjectSettings, PythonProjectsImpl } from '../internal.api';
import { normalizePath } from '../common/utils/pathUtils';
import {
addPythonProjectSetting,
EditProjectSettings,
Expand Down Expand Up @@ -197,10 +197,7 @@ export class PythonProjectManagerImpl implements PythonProjectManager {
return new PythonProjectsImpl(name, uri, options);
}

async add(
projects: PythonProject | ProjectArray,
options?: { persistSettings?: boolean },
): Promise<void> {
async add(projects: PythonProject | ProjectArray, options?: { persistSettings?: boolean }): Promise<void> {
const _projects = Array.isArray(projects) ? projects : [projects];
if (_projects.length === 0) {
return;
Expand Down
27 changes: 20 additions & 7 deletions src/features/views/projectView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
ProjectEnvironmentInfo,
ProjectItem,
ProjectPackage,
ProjectSetupFile,
ProjectTreeItem,
ProjectTreeItemKind,
} from './treeViewItems';
Expand Down Expand Up @@ -190,8 +191,16 @@ export class ProjectView implements TreeDataProvider<ProjectTreeItem> {

if (element.kind === ProjectTreeItemKind.project) {
const projectItem = element as ProjectItem;
const views: ProjectTreeItem[] = [];
if (projectItem instanceof ProjectItem) {
const setupFileUri = await projectItem.project.discoverProjectSetupFile?.();
if (setupFileUri) {
views.push(new ProjectSetupFile(projectItem, setupFileUri));
}
}

if (this.envManagers.managers.length === 0) {
return [
views.push(
new NoProjectEnvironment(
projectItem.project,
projectItem,
Expand All @@ -200,35 +209,39 @@ export class ProjectView implements TreeDataProvider<ProjectTreeItem> {
undefined,
'$(loading~spin)',
),
];
);
return views;
}

const uri = projectItem.id === 'global' ? undefined : projectItem.project.uri;
const manager = this.envManagers.getEnvironmentManager(uri);
if (!manager) {
return [
views.push(
new NoProjectEnvironment(
projectItem.project,
projectItem,
ProjectViews.noEnvironmentManager,
ProjectViews.noEnvironmentManagerDescription,
),
];
);
return views;
}

const environment = await this.envManagers.getEnvironment(uri);
if (!environment) {
return [
views.push(
new NoProjectEnvironment(
projectItem.project,
projectItem,
`${ProjectViews.noEnvironmentProvided} ${manager.displayName}`,
),
];
);
return views;
}
const view = new ProjectEnvironment(projectItem, environment);
this.revealMap.set(uri ? uri.fsPath : 'global', view);
return [view];
views.push(view);
return views;
}

if (element.kind === ProjectTreeItemKind.environment) {
Expand Down
28 changes: 26 additions & 2 deletions src/features/views/treeViewItems.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Command, MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState, l10n } from 'vscode';
import { Command, MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, l10n } from 'vscode';
import { EnvironmentGroupInfo, IconPath, Package, PythonEnvironment, PythonProject } from '../../api';
import { INLINE_SCRIPT_MANAGER_ID } from '../../common/constants';
import { EnvViewStrings, UvInstallStrings, VenvManagerStrings } from '../../common/localize';
Expand Down Expand Up @@ -239,7 +239,9 @@ export class PackageTreeItem implements EnvTreeItem {
item.contextValue = getPackageContextValue(pkg, parent.environment);
item.description = (pkg.isTransitive ? l10n.t('(transitive) ') : '') + (pkg.description ?? pkg.version ?? '');
item.tooltip = pkg.isTransitive
? l10n.t('This package is a dependency of another installed package. It may also have been explicitly installed.')
? l10n.t(
'This package is a dependency of another installed package. It may also have been explicitly installed.',
)
: pkg.tooltip;
this.treeItem = item;
}
Expand Down Expand Up @@ -289,6 +291,7 @@ export class PackageRootInfoTreeItem implements EnvTreeItem {

export enum ProjectTreeItemKind {
project = 'project',
setupFile = 'project-setup-file',
environment = 'project-environment',
none = 'project-no-environment',
environmentInfo = 'environment-info',
Expand Down Expand Up @@ -323,6 +326,27 @@ export class ProjectItem implements ProjectTreeItem {
}
}

export class ProjectSetupFile implements ProjectTreeItem {
public readonly kind = ProjectTreeItemKind.setupFile;
public readonly id: string;
public readonly treeItem: TreeItem;

constructor(
public readonly parent: ProjectItem,
public readonly uri: Uri,
) {
this.id = `${parent.id}>>>setup-file`;
const item = new TreeItem(uri, TreeItemCollapsibleState.None);
item.contextValue = 'project-setup-file';
item.command = {
command: 'vscode.open',
title: l10n.t('Open Setup File'),
arguments: [uri],
};
this.treeItem = item;
}
}

export class GlobalProjectItem implements ProjectTreeItem {
public readonly kind = ProjectTreeItemKind.project;
public readonly parent: undefined;
Expand Down
57 changes: 52 additions & 5 deletions src/internal.api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import type { Pep440Version } from '@renovatebot/pep440';
import { CancellationError, Disposable, Event, LogOutputChannel, MarkdownString, RelativePattern, Uri } from 'vscode';
import * as path from 'path';
import {
CancellationError,
Disposable,
Event,
FileType,
LogOutputChannel,
MarkdownString,
RelativePattern,
Uri,
} from 'vscode';
import {
CreateEnvironmentOptions,
CreateEnvironmentScope,
Expand Down Expand Up @@ -39,6 +49,7 @@ import { StopWatch } from './common/stopWatch';
import { EventNames } from './common/telemetry/constants';
import { classifyError, isTimeoutErrorType } from './common/telemetry/errorClassifier';
import { sendTelemetryEvent } from './common/telemetry/sender';
import { stat } from './common/workspace.fs.apis';

export type EnvironmentManagerScope = undefined | string | Uri | PythonEnvironment;
export type PackageManagerScope = undefined | string | Uri | PythonEnvironment | Package;
Expand Down Expand Up @@ -461,10 +472,7 @@ export interface PythonProjectManager extends Disposable {
uri: Uri,
options?: { description?: string; tooltip?: string | MarkdownString; iconPath?: IconPath },
): PythonProject;
add(
pyWorkspace: PythonProject | PythonProject[],
options?: { persistSettings?: boolean },
): Promise<void>;
add(pyWorkspace: PythonProject | PythonProject[], options?: { persistSettings?: boolean }): Promise<void>;
remove(pyWorkspace: PythonProject | PythonProject[]): void;
getProjects(uris?: Uri[]): ReadonlyArray<PythonProject>;
get(uri: Uri): PythonProject | undefined;
Expand Down Expand Up @@ -547,6 +555,8 @@ export class PythonPackageImpl implements Package {
}

export class PythonProjectsImpl implements PythonProject {
private static readonly setupFileNames = ['pyproject.toml', 'setup.py', 'requirements.txt'] as const;

name: string;
uri: Uri;
description?: string;
Expand All @@ -564,6 +574,43 @@ export class PythonProjectsImpl implements PythonProject {
this.tooltip = options?.tooltip ?? uri.fsPath;
this.iconPath = options?.iconPath;
}

/**
* Finds the preferred setup file at the project root.
* @returns The setup file URI, or `undefined` when no supported setup file exists.
*/
async discoverProjectSetupFile(): Promise<Uri | undefined> {
let projectType: FileType;
try {
projectType = (await stat(this.uri)).type;
} catch {
return undefined;
}

// A project URI may point directly to a setup file instead of its parent directory.
if (projectType !== FileType.Directory) {
const fileName = path.posix.basename(this.uri.path);
return projectType === FileType.File &&
PythonProjectsImpl.setupFileNames.some((candidate) => candidate === fileName)
? this.uri
: undefined;
}

// Search directory candidates in setup-file priority order.
for (const fileName of PythonProjectsImpl.setupFileNames) {
const candidate = this.uri.with({ path: path.posix.join(this.uri.path, fileName) });
try {
const candidateType = (await stat(candidate)).type;
if (candidateType === FileType.File) {
return candidate;
}
} catch {
// Try the next supported setup file.
}
}

return undefined;
}
}

export interface ProjectCreators extends Disposable {
Expand Down
31 changes: 10 additions & 21 deletions src/test/features/creators/newScriptProject.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import assert from 'assert';
import fsExtra from 'fs-extra';
import * as fs from 'fs-extra';
import fsExtra, * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import * as sinon from 'sinon';
Expand Down Expand Up @@ -66,9 +65,7 @@ suite('newInlineScriptTemplate / NewScriptProject', () => {
const templateFile = path.resolve(
path.join(NEW_PROJECT_TEMPLATES_FOLDER, 'newInlineScriptTemplate', 'script.py'),
);
const showTextDocumentStub = sinon
.stub(windowApis, 'showTextDocument')
.resolves({} as TextEditor);
const showTextDocumentStub = sinon.stub(windowApis, 'showTextDocument').resolves({} as TextEditor);
// Resolve existence by the requested path (the template exists, the new
// script does not) so the fixture does not depend on probe call order.
sinon.stub(fsExtra, 'pathExists').callsFake(async (checkedPath) => {
Expand Down Expand Up @@ -147,11 +144,7 @@ suite('newInlineScriptTemplate / NewScriptProject', () => {
);
}
for (const validName of ['console.py', 'com10.py', 'lpt10.py']) {
assert.strictEqual(
await validateInput(validName),
null,
`${validName} should remain valid on Windows`,
);
assert.strictEqual(await validateInput(validName), null, `${validName} should remain valid on Windows`);
}
return undefined;
});
Expand Down Expand Up @@ -248,11 +241,7 @@ suite('newInlineScriptTemplate / NewScriptProject', () => {
const scriptFileName = 'quick_script.py';
const rootUri = Uri.file(tmpDir);
const scriptDestination = path.resolve(rootUri.fsPath, scriptFileName);
const expectedTemplatePath = path.join(
NEW_PROJECT_TEMPLATES_FOLDER,
'newInlineScriptTemplate',
'script.py',
);
const expectedTemplatePath = path.join(NEW_PROJECT_TEMPLATES_FOLDER, 'newInlineScriptTemplate', 'script.py');
const addStub = sinon.stub().resolves();
const projectManager = { add: addStub } as unknown as PythonProjectManager;
const creator = new NewScriptProject(projectManager);
Expand Down Expand Up @@ -292,11 +281,7 @@ suite('newInlineScriptTemplate / NewScriptProject', () => {
assert.ok(
instructionsStub.calledOnceWithExactly(
rootUri.fsPath,
path.join(
NEW_PROJECT_TEMPLATES_FOLDER,
'copilot-instructions-text',
'script-copilot-instructions.md',
),
path.join(NEW_PROJECT_TEMPLATES_FOLDER, 'copilot-instructions-text', 'script-copilot-instructions.md'),
[{ searchValue: '<script_name>', replaceValue: scriptFileName }],
),
'quick create should retain Copilot-instruction handling',
Expand Down Expand Up @@ -513,7 +498,11 @@ suite('newInlineScriptTemplate / NewScriptProject', () => {
await Promise.resolve();

assert.strictEqual(createSettled, false, 'create should remain pending while project registration is pending');
assert.strictEqual(showTextDocumentStub.called, false, 'the script must not open before registration completes');
assert.strictEqual(
showTextDocumentStub.called,
false,
'the script must not open before registration completes',
);
assert.strictEqual(promptStub.called, false);

releaseRegistration();
Expand Down
Loading
Loading