diff --git a/src/api.ts b/src/api.ts index 13504a2d..0f243c63 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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; } /** diff --git a/src/features/creators/autoFindProjects.ts b/src/features/creators/autoFindProjects.ts index 9953e878..2c22c0a0 100644 --- a/src/features/creators/autoFindProjects.ts +++ b/src/features/creators/autoFindProjects.ts @@ -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; diff --git a/src/features/projectManager.ts b/src/features/projectManager.ts index ee713379..4a2f2068 100644 --- a/src/features/projectManager.ts +++ b/src/features/projectManager.ts @@ -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, @@ -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, @@ -197,10 +197,7 @@ export class PythonProjectManagerImpl implements PythonProjectManager { return new PythonProjectsImpl(name, uri, options); } - async add( - projects: PythonProject | ProjectArray, - options?: { persistSettings?: boolean }, - ): Promise { + async add(projects: PythonProject | ProjectArray, options?: { persistSettings?: boolean }): Promise { const _projects = Array.isArray(projects) ? projects : [projects]; if (_projects.length === 0) { return; diff --git a/src/features/views/projectView.ts b/src/features/views/projectView.ts index c81648c3..a326a2ac 100644 --- a/src/features/views/projectView.ts +++ b/src/features/views/projectView.ts @@ -22,6 +22,7 @@ import { ProjectEnvironmentInfo, ProjectItem, ProjectPackage, + ProjectSetupFile, ProjectTreeItem, ProjectTreeItemKind, } from './treeViewItems'; @@ -190,8 +191,16 @@ export class ProjectView implements TreeDataProvider { 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, @@ -200,35 +209,39 @@ export class ProjectView implements TreeDataProvider { 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) { diff --git a/src/features/views/treeViewItems.ts b/src/features/views/treeViewItems.ts index 84c088a3..9ec198fd 100644 --- a/src/features/views/treeViewItems.ts +++ b/src/features/views/treeViewItems.ts @@ -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'; @@ -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; } @@ -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', @@ -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; diff --git a/src/internal.api.ts b/src/internal.api.ts index c8133e95..d0bcc26a 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -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, @@ -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; @@ -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; + add(pyWorkspace: PythonProject | PythonProject[], options?: { persistSettings?: boolean }): Promise; remove(pyWorkspace: PythonProject | PythonProject[]): void; getProjects(uris?: Uri[]): ReadonlyArray; get(uri: Uri): PythonProject | undefined; @@ -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; @@ -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 { + 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 { diff --git a/src/test/features/creators/newScriptProject.unit.test.ts b/src/test/features/creators/newScriptProject.unit.test.ts index 02f7ebb9..b3886b82 100644 --- a/src/test/features/creators/newScriptProject.unit.test.ts +++ b/src/test/features/creators/newScriptProject.unit.test.ts @@ -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'; @@ -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) => { @@ -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; }); @@ -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); @@ -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: '', replaceValue: scriptFileName }], ), 'quick create should retain Copilot-instruction handling', @@ -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(); diff --git a/src/test/features/projectSetupFile.unit.test.ts b/src/test/features/projectSetupFile.unit.test.ts new file mode 100644 index 00000000..ded8ab6f --- /dev/null +++ b/src/test/features/projectSetupFile.unit.test.ts @@ -0,0 +1,88 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { FileStat, FileType, Uri } from 'vscode'; +import * as workspaceFs from '../../common/workspace.fs.apis'; +import { PythonProjectsImpl } from '../../internal.api'; + +function fileStat(type: FileType): FileStat { + return { type, ctime: 0, mtime: 0, size: 0 }; +} + +suite('Project setup file discovery', () => { + teardown(() => { + sinon.restore(); + }); + + test('prefers pyproject.toml and preserves the project URI scheme', async () => { + const projectUri = Uri.parse('vscode-remote://ssh-remote+host/workspace/project'); + const project = new PythonProjectsImpl('project', projectUri); + const statStub = sinon.stub(workspaceFs, 'stat').callsFake((uri) => { + if (uri.toString() === projectUri.toString()) { + return Promise.resolve(fileStat(FileType.Directory)); + } + if (uri.path.endsWith('/pyproject.toml')) { + return Promise.resolve(fileStat(FileType.File)); + } + return Promise.reject(new Error('File not found')); + }); + + const result = await project.discoverProjectSetupFile(); + + assert.strictEqual(result?.scheme, projectUri.scheme); + assert.strictEqual(result?.authority, projectUri.authority); + assert.strictEqual(result?.path, '/workspace/project/pyproject.toml'); + assert.strictEqual(statStub.callCount, 2); + }); + + test('falls back to setup.py and requirements.txt', async () => { + const projectUri = Uri.file('/workspace/project'); + const availableFileNames = new Set(['setup.py']); + sinon.stub(workspaceFs, 'stat').callsFake((uri) => { + if (uri.toString() === projectUri.toString()) { + return Promise.resolve(fileStat(FileType.Directory)); + } + const fileName = uri.path.split('/').pop(); + return fileName && availableFileNames.has(fileName) + ? Promise.resolve(fileStat(FileType.File)) + : Promise.reject(new Error('File not found')); + }); + + const setupProject = new PythonProjectsImpl('project', projectUri); + const setupFileUri = await setupProject.discoverProjectSetupFile(); + assert.strictEqual(setupFileUri?.path.endsWith('/setup.py'), true); + + availableFileNames.clear(); + availableFileNames.add('requirements.txt'); + const requirementsProject = new PythonProjectsImpl('project', projectUri); + const requirementsFileUri = await requirementsProject.discoverProjectSetupFile(); + assert.strictEqual(requirementsFileUri?.path.endsWith('/requirements.txt'), true); + }); + + test('returns undefined when no setup file exists', async () => { + const projectUri = Uri.file('/workspace/project'); + const project = new PythonProjectsImpl('project', projectUri); + sinon.stub(workspaceFs, 'stat').callsFake((uri) => { + return uri.toString() === projectUri.toString() + ? Promise.resolve(fileStat(FileType.Directory)) + : Promise.reject(new Error('File not found')); + }); + + assert.strictEqual(await project.discoverProjectSetupFile(), undefined); + }); + + test('does not append setup paths to a standalone Python file', async () => { + const scriptUri = Uri.file('/workspace/script.py'); + const project = new PythonProjectsImpl('script.py', scriptUri); + sinon.stub(workspaceFs, 'stat').resolves(fileStat(FileType.File)); + + assert.strictEqual(await project.discoverProjectSetupFile(), undefined); + }); + + test('accepts a recognized setup file as the project URI', async () => { + const setupFileUri = Uri.file('/workspace/pyproject.toml'); + const project = new PythonProjectsImpl('pyproject.toml', setupFileUri); + sinon.stub(workspaceFs, 'stat').resolves(fileStat(FileType.File)); + + assert.strictEqual(await project.discoverProjectSetupFile(), setupFileUri); + }); +}); diff --git a/src/test/features/pythonApi.unit.test.ts b/src/test/features/pythonApi.unit.test.ts index bd464b4b..7246dda0 100644 --- a/src/test/features/pythonApi.unit.test.ts +++ b/src/test/features/pythonApi.unit.test.ts @@ -19,7 +19,9 @@ suite('PythonEnvironmentApiImpl - onDidChangePythonProjects', () => { const mockEnvManagers = { onDidChangeActiveEnvironment: new EventEmitter().event } as unknown as ApiArgs[0]; const mockProjectCreators = {} as unknown as ApiArgs[2]; const mockTerminalManager = {} as unknown as ApiArgs[3]; - const mockEnvVarManager = { onDidChangeEnvironmentVariables: new EventEmitter().event } as unknown as ApiArgs[4]; + const mockEnvVarManager = { + onDidChangeEnvironmentVariables: new EventEmitter().event, + } as unknown as ApiArgs[4]; const api = new PythonEnvironmentApiImpl( mockEnvManagers, @@ -40,7 +42,10 @@ suite('PythonEnvironmentApiImpl - onDidChangePythonProjects', () => { assert.ok(firedEventPayload, 'Event should have fired'); assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added.length, 1); - assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added[0].uri.fsPath, newProject.uri.fsPath); + assert.strictEqual( + (firedEventPayload as { added: PythonProject[] }).added[0].uri.fsPath, + newProject.uri.fsPath, + ); assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed.length, 0); firedEventPayload = null; @@ -100,7 +105,9 @@ suite('PythonEnvironmentApiImpl - getEnvironment timeout fallback', () => { } as unknown as ApiArgs[0]; const mockProjectCreators = {} as unknown as ApiArgs[2]; const mockTerminalManager = {} as unknown as ApiArgs[3]; - const mockEnvVarManager = { onDidChangeEnvironmentVariables: new EventEmitter().event } as unknown as ApiArgs[4]; + const mockEnvVarManager = { + onDidChangeEnvironmentVariables: new EventEmitter().event, + } as unknown as ApiArgs[4]; const api = new PythonEnvironmentApiImpl( mockEnvManagers, diff --git a/src/test/features/views/treeViewItems.unit.test.ts b/src/test/features/views/treeViewItems.unit.test.ts index 75e53a8a..44fc000b 100644 --- a/src/test/features/views/treeViewItems.unit.test.ts +++ b/src/test/features/views/treeViewItems.unit.test.ts @@ -8,7 +8,9 @@ import { NoPythonEnvTreeItem, PackageTreeItem, ProjectEnvironment, + ProjectItem, ProjectPackage, + ProjectSetupFile, PythonEnvTreeItem, PythonGroupEnvTreeItem, } from '../../../features/views/treeViewItems'; @@ -79,6 +81,20 @@ function createMockManager( } suite('Test TreeView Items', () => { + suite('ProjectSetupFile', () => { + test('opens the setup file', () => { + const parent = new ProjectItem({ name: 'project', uri: Uri.file('.') }); + const setupFileUri = Uri.file('pyproject.toml'); + + const item = new ProjectSetupFile(parent, setupFileUri); + + assert.strictEqual(item.parent, parent); + assert.strictEqual(item.treeItem.resourceUri, setupFileUri); + assert.strictEqual(item.treeItem.command?.command, 'vscode.open'); + assert.deepStrictEqual(item.treeItem.command?.arguments, [setupFileUri]); + }); + }); + suite('EnvManagerTreeItem', () => { test('Sets id to manager id for tree item identification', () => { // Arrange @@ -587,7 +603,11 @@ suite('Test TreeView Items', () => { test('Prefers package-provided iconPath over default icon', () => { // Arrange - const pkg = createMockPackage({ name: 'numpy', isTransitive: true, iconPath: new ThemeIcon('symbol-numeric') }); + const pkg = createMockPackage({ + name: 'numpy', + isTransitive: true, + iconPath: new ThemeIcon('symbol-numeric'), + }); // Act const item = new ProjectPackage(parent, pkg, manager);