Skip to content

Commit 4e7a325

Browse files
committed
refinement
1 parent 2abfbbe commit 4e7a325

File tree

2 files changed

+386
-305
lines changed

2 files changed

+386
-305
lines changed
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as path from 'path';
5+
import { TestController, Uri } from 'vscode';
6+
import { IConfigurationService } from '../../../common/types';
7+
import { IInterpreterService } from '../../../interpreter/contracts';
8+
import { traceError, traceInfo, traceVerbose } from '../../../logging';
9+
import { UNITTEST_PROVIDER } from '../../common/constants';
10+
import { TestProvider } from '../../types';
11+
import { IEnvironmentVariablesProvider } from '../../../common/variables/types';
12+
import { PythonProject, PythonEnvironment } from '../../../envExt/types';
13+
import { getEnvExtApi, useEnvExtension } from '../../../envExt/api.internal';
14+
import { isParentPath } from '../../../common/platform/fs-paths';
15+
import { ProjectAdapter } from './projectAdapter';
16+
import { getProjectId, createProjectDisplayName } from './projectUtils';
17+
import { PythonResultResolver } from './resultResolver';
18+
import { ITestDiscoveryAdapter, ITestExecutionAdapter } from './types';
19+
import { UnittestTestDiscoveryAdapter } from '../unittest/testDiscoveryAdapter';
20+
import { UnittestTestExecutionAdapter } from '../unittest/testExecutionAdapter';
21+
import { PytestTestDiscoveryAdapter } from '../pytest/pytestDiscoveryAdapter';
22+
import { PytestTestExecutionAdapter } from '../pytest/pytestExecutionAdapter';
23+
24+
/**
25+
* Registry for Python test projects within workspaces.
26+
*
27+
* Manages the lifecycle of test projects including:
28+
* - Discovering Python projects via Python Environments API
29+
* - Creating and storing ProjectAdapter instances per workspace
30+
* - Computing nested project relationships for ignore lists
31+
* - Fallback to default "legacy" project when API unavailable
32+
*
33+
* Key concepts:
34+
* - Workspace: A VS Code workspace folder (may contain multiple projects)
35+
* - Project: A Python project within a workspace (has its own pyproject.toml, etc.)
36+
* - Each project gets its own test tree root and Python environment
37+
*/
38+
export class TestProjectRegistry {
39+
/**
40+
* Map of workspace URI -> Map of project ID -> ProjectAdapter
41+
* Project IDs match Python Environments extension's Map<string, PythonProject> keys
42+
*/
43+
private readonly workspaceProjects: Map<Uri, Map<string, ProjectAdapter>> = new Map();
44+
45+
constructor(
46+
private readonly testController: TestController,
47+
private readonly configSettings: IConfigurationService,
48+
private readonly interpreterService: IInterpreterService,
49+
private readonly envVarsService: IEnvironmentVariablesProvider,
50+
) {}
51+
52+
/**
53+
* Checks if project-based testing is available (Python Environments API).
54+
*/
55+
public isProjectBasedTestingAvailable(): boolean {
56+
return useEnvExtension();
57+
}
58+
59+
/**
60+
* Gets the projects map for a workspace, if it exists.
61+
*/
62+
public getWorkspaceProjects(workspaceUri: Uri): Map<string, ProjectAdapter> | undefined {
63+
return this.workspaceProjects.get(workspaceUri);
64+
}
65+
66+
/**
67+
* Checks if a workspace has been initialized with projects.
68+
*/
69+
public hasProjects(workspaceUri: Uri): boolean {
70+
return this.workspaceProjects.has(workspaceUri);
71+
}
72+
73+
/**
74+
* Gets all projects for a workspace as an array.
75+
*/
76+
public getProjectsArray(workspaceUri: Uri): ProjectAdapter[] {
77+
const projectsMap = this.workspaceProjects.get(workspaceUri);
78+
return projectsMap ? Array.from(projectsMap.values()) : [];
79+
}
80+
81+
/**
82+
* Discovers and registers all Python projects for a workspace.
83+
* Returns the discovered projects for the caller to use.
84+
*/
85+
public async discoverAndRegisterProjects(workspaceUri: Uri): Promise<ProjectAdapter[]> {
86+
traceInfo(`[ProjectManager] Discovering projects for workspace: ${workspaceUri.fsPath}`);
87+
88+
const projects = await this.discoverProjects(workspaceUri);
89+
90+
// Create map for this workspace, keyed by project URI
91+
const projectsMap = new Map<string, ProjectAdapter>();
92+
projects.forEach((project) => {
93+
projectsMap.set(getProjectId(project.projectUri), project);
94+
});
95+
96+
this.workspaceProjects.set(workspaceUri, projectsMap);
97+
traceInfo(`[ProjectManager] Registered ${projects.length} project(s) for ${workspaceUri.fsPath}`);
98+
99+
return projects;
100+
}
101+
102+
/**
103+
* Computes and populates nested project ignore lists for all projects in a workspace.
104+
* Must be called before discovery to ensure parent projects ignore nested children.
105+
*/
106+
public configureNestedProjectIgnores(workspaceUri: Uri): void {
107+
const projectIgnores = this.computeNestedProjectIgnores(workspaceUri);
108+
const projects = this.getProjectsArray(workspaceUri);
109+
110+
for (const project of projects) {
111+
const ignorePaths = projectIgnores.get(project.projectId);
112+
if (ignorePaths && ignorePaths.length > 0) {
113+
project.nestedProjectPathsToIgnore = ignorePaths;
114+
traceInfo(`[ProjectManager] ${project.projectName} will ignore nested: ${ignorePaths.join(', ')}`);
115+
}
116+
}
117+
}
118+
119+
/**
120+
* Clears all projects for a workspace.
121+
*/
122+
public clearWorkspace(workspaceUri: Uri): void {
123+
this.workspaceProjects.delete(workspaceUri);
124+
}
125+
126+
// ====== Private Methods ======
127+
128+
/**
129+
* Discovers Python projects in a workspace using the Python Environment API.
130+
* Falls back to creating a single default project if API is unavailable.
131+
*/
132+
private async discoverProjects(workspaceUri: Uri): Promise<ProjectAdapter[]> {
133+
try {
134+
if (!useEnvExtension()) {
135+
traceInfo('[ProjectManager] Python Environments API not available, using default project');
136+
return [await this.createDefaultProject(workspaceUri)];
137+
}
138+
139+
const envExtApi = await getEnvExtApi();
140+
const allProjects = envExtApi.getPythonProjects();
141+
traceInfo(`[ProjectManager] Found ${allProjects.length} total Python projects from API`);
142+
143+
// Filter to projects within this workspace
144+
const workspaceProjects = allProjects.filter((project) =>
145+
isParentPath(project.uri.fsPath, workspaceUri.fsPath),
146+
);
147+
traceInfo(`[ProjectManager] Filtered to ${workspaceProjects.length} projects in workspace`);
148+
149+
if (workspaceProjects.length === 0) {
150+
traceInfo('[ProjectManager] No projects found, creating default project');
151+
return [await this.createDefaultProject(workspaceUri)];
152+
}
153+
154+
// Create ProjectAdapter for each discovered project
155+
const adapters: ProjectAdapter[] = [];
156+
for (const pythonProject of workspaceProjects) {
157+
try {
158+
const adapter = await this.createProjectAdapter(pythonProject, workspaceUri);
159+
adapters.push(adapter);
160+
} catch (error) {
161+
traceError(`[ProjectManager] Failed to create adapter for ${pythonProject.uri.fsPath}:`, error);
162+
}
163+
}
164+
165+
if (adapters.length === 0) {
166+
traceInfo('[ProjectManager] All adapters failed, falling back to default project');
167+
return [await this.createDefaultProject(workspaceUri)];
168+
}
169+
170+
return adapters;
171+
} catch (error) {
172+
traceError('[ProjectManager] Discovery failed, using default project:', error);
173+
return [await this.createDefaultProject(workspaceUri)];
174+
}
175+
}
176+
177+
/**
178+
* Creates a ProjectAdapter from a PythonProject.
179+
*/
180+
private async createProjectAdapter(pythonProject: PythonProject, workspaceUri: Uri): Promise<ProjectAdapter> {
181+
const projectId = pythonProject.uri.fsPath;
182+
traceInfo(`[ProjectManager] Creating adapter for: ${pythonProject.name} at ${projectId}`);
183+
184+
// Resolve Python environment
185+
const envExtApi = await getEnvExtApi();
186+
const pythonEnvironment = await envExtApi.getEnvironment(pythonProject.uri);
187+
if (!pythonEnvironment) {
188+
throw new Error(`No Python environment found for project ${projectId}`);
189+
}
190+
191+
// Create test infrastructure
192+
const testProvider = this.getTestProvider(workspaceUri);
193+
const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri, projectId);
194+
const { discoveryAdapter, executionAdapter } = this.createAdapters(testProvider, resultResolver);
195+
196+
const projectName = createProjectDisplayName(pythonProject.name, pythonEnvironment.version);
197+
198+
return {
199+
projectId,
200+
projectName,
201+
projectUri: pythonProject.uri,
202+
workspaceUri,
203+
pythonProject,
204+
pythonEnvironment,
205+
testProvider,
206+
discoveryAdapter,
207+
executionAdapter,
208+
resultResolver,
209+
isDiscovering: false,
210+
isExecuting: false,
211+
};
212+
}
213+
214+
/**
215+
* Creates a default project for legacy/fallback mode.
216+
*/
217+
private async createDefaultProject(workspaceUri: Uri): Promise<ProjectAdapter> {
218+
traceInfo(`[ProjectManager] Creating default project for: ${workspaceUri.fsPath}`);
219+
220+
const testProvider = this.getTestProvider(workspaceUri);
221+
const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri);
222+
const { discoveryAdapter, executionAdapter } = this.createAdapters(testProvider, resultResolver);
223+
224+
const interpreter = await this.interpreterService.getActiveInterpreter(workspaceUri);
225+
226+
const pythonEnvironment: PythonEnvironment = {
227+
name: 'default',
228+
displayName: interpreter?.displayName || 'Python',
229+
shortDisplayName: interpreter?.displayName || 'Python',
230+
displayPath: interpreter?.path || 'python',
231+
version: interpreter?.version?.raw || '3.x',
232+
environmentPath: Uri.file(interpreter?.path || 'python'),
233+
sysPrefix: interpreter?.sysPrefix || '',
234+
execInfo: { run: { executable: interpreter?.path || 'python' } },
235+
envId: { id: 'default', managerId: 'default' },
236+
};
237+
238+
const pythonProject: PythonProject = {
239+
name: path.basename(workspaceUri.fsPath) || 'workspace',
240+
uri: workspaceUri,
241+
};
242+
243+
return {
244+
projectId: getProjectId(workspaceUri),
245+
projectName: pythonProject.name,
246+
projectUri: workspaceUri,
247+
workspaceUri,
248+
pythonProject,
249+
pythonEnvironment,
250+
testProvider,
251+
discoveryAdapter,
252+
executionAdapter,
253+
resultResolver,
254+
isDiscovering: false,
255+
isExecuting: false,
256+
};
257+
}
258+
259+
/**
260+
* Identifies nested projects and returns ignore paths for parent projects.
261+
*/
262+
private computeNestedProjectIgnores(workspaceUri: Uri): Map<string, string[]> {
263+
const ignoreMap = new Map<string, string[]>();
264+
const projects = this.getProjectsArray(workspaceUri);
265+
266+
if (projects.length === 0) return ignoreMap;
267+
268+
for (const parent of projects) {
269+
const nestedPaths: string[] = [];
270+
271+
for (const child of projects) {
272+
if (parent.projectId === child.projectId) continue;
273+
274+
const parentPath = parent.projectUri.fsPath;
275+
const childPath = child.projectUri.fsPath;
276+
277+
if (childPath.startsWith(parentPath + path.sep)) {
278+
nestedPaths.push(childPath);
279+
traceVerbose(`[ProjectManager] Nested: ${child.projectName} under ${parent.projectName}`);
280+
}
281+
}
282+
283+
if (nestedPaths.length > 0) {
284+
ignoreMap.set(parent.projectId, nestedPaths);
285+
}
286+
}
287+
288+
return ignoreMap;
289+
}
290+
291+
/**
292+
* Determines the test provider based on workspace settings.
293+
*/
294+
private getTestProvider(workspaceUri: Uri): TestProvider {
295+
const settings = this.configSettings.getSettings(workspaceUri);
296+
return settings.testing.unittestEnabled ? UNITTEST_PROVIDER : 'pytest';
297+
}
298+
299+
/**
300+
* Creates discovery and execution adapters for a test provider.
301+
*/
302+
private createAdapters(
303+
testProvider: TestProvider,
304+
resultResolver: PythonResultResolver,
305+
): { discoveryAdapter: ITestDiscoveryAdapter; executionAdapter: ITestExecutionAdapter } {
306+
if (testProvider === UNITTEST_PROVIDER) {
307+
return {
308+
discoveryAdapter: new UnittestTestDiscoveryAdapter(
309+
this.configSettings,
310+
resultResolver,
311+
this.envVarsService,
312+
),
313+
executionAdapter: new UnittestTestExecutionAdapter(
314+
this.configSettings,
315+
resultResolver,
316+
this.envVarsService,
317+
),
318+
};
319+
}
320+
321+
return {
322+
discoveryAdapter: new PytestTestDiscoveryAdapter(this.configSettings, resultResolver, this.envVarsService),
323+
executionAdapter: new PytestTestExecutionAdapter(this.configSettings, resultResolver, this.envVarsService),
324+
};
325+
}
326+
}

0 commit comments

Comments
 (0)