diff --git a/src/features/creators/autoFindProjects.ts b/src/features/creators/autoFindProjects.ts index 1c636335..a9c5b782 100644 --- a/src/features/creators/autoFindProjects.ts +++ b/src/features/creators/autoFindProjects.ts @@ -3,13 +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 '../projectManager'; -import { normalizePath } from '../../common/utils/pathUtils'; +import { PythonProjectManager, PythonProjectsImpl } from '../projectManager'; function getUniqueUri(uris: Uri[]): { label: string; diff --git a/src/features/projectManager.ts b/src/features/projectManager.ts index cdabc5fe..49a0538e 100644 --- a/src/features/projectManager.ts +++ b/src/features/projectManager.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { Disposable, Event, EventEmitter, MarkdownString, Uri } from 'vscode'; +import { Disposable, Event, EventEmitter, FileType, MarkdownString, Uri } from 'vscode'; import type { IconPath, PythonProject } from '../api'; import { DEFAULT_ENV_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID } from '../common/constants'; import { createSimpleDebounce } from '../common/utils/debounce'; @@ -12,6 +12,7 @@ import { onDidRenameFiles, } from '../common/workspace.apis'; import { normalizePath } from '../common/utils/pathUtils'; +import { stat } from '../common/workspace.fs.apis'; import { addPythonProjectSetting, EditProjectSettings, @@ -50,6 +51,13 @@ export interface InlineScriptProjectRegistrationMarker { } export class PythonProjectsImpl implements PythonProject { + private static readonly dependencyFileNames = [ + 'requirements.txt', + 'pyproject.toml', + 'requirements.in', + 'environment.yml', + ] as const; + readonly name: string; readonly uri: Uri; readonly description?: string; @@ -67,6 +75,43 @@ export class PythonProjectsImpl implements PythonProject { this.tooltip = options?.tooltip ?? uri.fsPath; this.iconPath = options?.iconPath; } + + /** + * Finds the preferred dependency file at the project root. + * @returns The dependency file URI, or `undefined` when no supported dependency file exists. + */ + async discoverDependencyFiles(): Promise { + let projectType: FileType; + try { + projectType = (await stat(this.uri)).type; + } catch { + return undefined; + } + + // A project URI may point directly to a dependency file instead of its parent directory. + if (projectType !== FileType.Directory) { + const fileName = path.posix.basename(this.uri.path); + return projectType === FileType.File && + PythonProjectsImpl.dependencyFileNames.some((candidate) => candidate === fileName) + ? this.uri + : undefined; + } + + // Search directory candidates in dependency-file priority order. + for (const fileName of PythonProjectsImpl.dependencyFileNames) { + 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 dependency file. + } + } + + return undefined; + } } type ProjectArray = PythonProject[]; diff --git a/src/features/views/projectView.ts b/src/features/views/projectView.ts index 4a2ce9aa..a3428042 100644 --- a/src/features/views/projectView.ts +++ b/src/features/views/projectView.ts @@ -19,6 +19,7 @@ import { ITemporaryStateManager } from './temporaryStateManager'; import { GlobalProjectItem, NoProjectEnvironment, + ProjectDependencyFile, ProjectEnvironment, ProjectEnvironmentInfo, ProjectItem, @@ -191,8 +192,16 @@ export class ProjectView implements TreeDataProvider { if (element.kind === ProjectTreeItemKind.project) { const projectItem = element as ProjectItem; + const views: ProjectTreeItem[] = []; + if (projectItem instanceof ProjectItem) { + const dependencyFileUri = await projectItem.project.discoverDependencyFiles?.(); + if (dependencyFileUri) { + views.push(new ProjectDependencyFile(projectItem, dependencyFileUri)); + } + } + if (this.envManagers.managers.length === 0) { - return [ + views.push( new NoProjectEnvironment( projectItem.project, projectItem, @@ -201,35 +210,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 7b0224e6..7691132c 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'; @@ -242,7 +242,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; } @@ -292,6 +294,7 @@ export class PackageRootInfoTreeItem implements EnvTreeItem { export enum ProjectTreeItemKind { project = 'project', + dependencyFile = 'project-dependency-file', environment = 'project-environment', none = 'project-no-environment', environmentInfo = 'environment-info', @@ -326,6 +329,27 @@ export class ProjectItem implements ProjectTreeItem { } } +export class ProjectDependencyFile implements ProjectTreeItem { + public readonly kind = ProjectTreeItemKind.dependencyFile; + public readonly id: string; + public readonly treeItem: TreeItem; + + constructor( + public readonly parent: ProjectItem, + public readonly uri: Uri, + ) { + this.id = `${parent.id}>>>dependency-file`; + const item = new TreeItem(uri, TreeItemCollapsibleState.None); + item.contextValue = 'project-dependency-file'; + item.command = { + command: 'vscode.open', + title: l10n.t('Open Dependency File'), + arguments: [uri], + }; + this.treeItem = item; + } +} + export class GlobalProjectItem implements ProjectTreeItem { public readonly kind = ProjectTreeItemKind.project; public readonly parent: undefined; diff --git a/src/test/extensionApi.unit.test.ts b/src/test/extensionApi.unit.test.ts index d49bc79c..362f2e25 100644 --- a/src/test/extensionApi.unit.test.ts +++ b/src/test/extensionApi.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; @@ -101,7 +106,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/creators/newScriptProject.unit.test.ts b/src/test/features/creators/newScriptProject.unit.test.ts index fc2bbed1..a389d4ae 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/projectDependencyFiles.unit.test.ts b/src/test/features/projectDependencyFiles.unit.test.ts new file mode 100644 index 00000000..efc35461 --- /dev/null +++ b/src/test/features/projectDependencyFiles.unit.test.ts @@ -0,0 +1,94 @@ +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 '../../features/projectManager'; + +function fileStat(type: FileType): FileStat { + return { type, ctime: 0, mtime: 0, size: 0 }; +} + +suite('Project dependency file discovery', () => { + teardown(() => { + sinon.restore(); + }); + + test('prefers requirements.txt 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('/requirements.txt')) { + return Promise.resolve(fileStat(FileType.File)); + } + return Promise.reject(new Error('File not found')); + }); + + const result = await project.discoverDependencyFiles(); + + assert.strictEqual(result?.scheme, projectUri.scheme); + assert.strictEqual(result?.authority, projectUri.authority); + assert.strictEqual(result?.path, '/workspace/project/requirements.txt'); + assert.strictEqual(statStub.callCount, 2); + }); + + test('falls back through generated dependency file names', async () => { + const projectUri = Uri.file('/workspace/project'); + const availableFileNames = new Set(['pyproject.toml']); + 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 pyproject = new PythonProjectsImpl('project', projectUri); + const pyprojectUri = await pyproject.discoverDependencyFiles(); + assert.strictEqual(pyprojectUri?.path.endsWith('/pyproject.toml'), true); + + availableFileNames.clear(); + availableFileNames.add('requirements.in'); + const requirements = new PythonProjectsImpl('project', projectUri); + const requirementsUri = await requirements.discoverDependencyFiles(); + assert.strictEqual(requirementsUri?.path.endsWith('/requirements.in'), true); + + availableFileNames.clear(); + availableFileNames.add('environment.yml'); + const environment = new PythonProjectsImpl('project', projectUri); + const environmentUri = await environment.discoverDependencyFiles(); + assert.strictEqual(environmentUri?.path.endsWith('/environment.yml'), true); + }); + + test('returns undefined when no dependency 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.discoverDependencyFiles(), undefined); + }); + + test('does not append dependency 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.discoverDependencyFiles(), undefined); + }); + + test('accepts a recognized dependency file as the project URI', async () => { + const dependencyFileUri = Uri.file('/workspace/pyproject.toml'); + const project = new PythonProjectsImpl('pyproject.toml', dependencyFileUri); + sinon.stub(workspaceFs, 'stat').resolves(fileStat(FileType.File)); + + assert.strictEqual(await project.discoverDependencyFiles(), dependencyFileUri); + }); +}); \ No newline at end of file diff --git a/src/test/features/views/treeViewItems.unit.test.ts b/src/test/features/views/treeViewItems.unit.test.ts index 8f221060..494d6aa0 100644 --- a/src/test/features/views/treeViewItems.unit.test.ts +++ b/src/test/features/views/treeViewItems.unit.test.ts @@ -7,7 +7,9 @@ import { getEnvironmentParentDirName, NoPythonEnvTreeItem, PackageTreeItem, + ProjectDependencyFile, ProjectEnvironment, + ProjectItem, ProjectPackage, PythonEnvTreeItem, PythonGroupEnvTreeItem, @@ -81,6 +83,20 @@ function createMockManager( } suite('Test TreeView Items', () => { + suite('ProjectDependencyFile', () => { + test('opens the dependency file', () => { + const parent = new ProjectItem({ name: 'project', uri: Uri.file('.') }); + const dependencyFileUri = Uri.file('pyproject.toml'); + + const item = new ProjectDependencyFile(parent, dependencyFileUri); + + assert.strictEqual(item.parent, parent); + assert.strictEqual(item.treeItem.resourceUri, dependencyFileUri); + assert.strictEqual(item.treeItem.command?.command, 'vscode.open'); + assert.deepStrictEqual(item.treeItem.command?.arguments, [dependencyFileUri]); + }); + }); + suite('EnvManagerTreeItem', () => { test('Sets id to manager id for tree item identification', () => { // Arrange @@ -589,7 +605,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); diff --git a/src/types.ts b/src/types.ts index 50ceff79..4d197aca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -805,6 +805,13 @@ 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 dependency file, such as `requirements.txt`, `pyproject.toml`, + * `requirements.in`, or `environment.yml`. + * @returns The dependency file URI, or `undefined` when no supported dependency file exists. + */ + discoverDependencyFiles?(): Promise; } /**