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
7 changes: 2 additions & 5 deletions src/features/creators/autoFindProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 46 additions & 1 deletion src/features/projectManager.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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<Uri | undefined> {
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[];
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 @@ -19,6 +19,7 @@ import { ITemporaryStateManager } from './temporaryStateManager';
import {
GlobalProjectItem,
NoProjectEnvironment,
ProjectDependencyFile,
ProjectEnvironment,
ProjectEnvironmentInfo,
ProjectItem,
Expand Down Expand Up @@ -191,8 +192,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 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,
Expand All @@ -201,35 +210,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 @@ -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;
}
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 10 additions & 3 deletions src/test/extensionApi.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
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