Skip to content

Commit 243af23

Browse files
Copiloteleanorjboyd
andcommitted
Add Phase 2: Per-project discovery infrastructure
- Created projectRootFinder.ts utility to detect Python project roots - Updated PythonTestController to support multiple projects per workspace - Modified discovery flow to detect projects and create adapters for each - Updated test execution to route tests to correct project adapter Co-authored-by: eleanorjboyd <[email protected]>
1 parent dfab775 commit 243af23

2 files changed

Lines changed: 317 additions & 88 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as path from 'path';
5+
import { Uri, workspace, RelativePattern } from 'vscode';
6+
import { traceVerbose } from '../../../logging';
7+
import { TestProvider } from '../../types';
8+
9+
/**
10+
* Markers that indicate a Python project root for different test frameworks
11+
*/
12+
const PYTEST_PROJECT_MARKERS = ['pytest.ini', 'pyproject.toml', 'setup.py', 'setup.cfg', 'tox.ini'];
13+
const UNITTEST_PROJECT_MARKERS = ['pyproject.toml', 'setup.py', 'setup.cfg'];
14+
15+
/**
16+
* Represents a detected Python project within a workspace
17+
*/
18+
export interface ProjectRoot {
19+
/**
20+
* URI of the project root directory
21+
*/
22+
uri: Uri;
23+
24+
/**
25+
* Marker file that was used to identify this project
26+
*/
27+
markerFile: string;
28+
}
29+
30+
/**
31+
* Finds all Python project roots within a workspace folder.
32+
* A project root is identified by the presence of configuration files like
33+
* pyproject.toml, setup.py, pytest.ini, etc.
34+
*
35+
* @param workspaceUri The workspace folder URI to search
36+
* @param testProvider The test provider (pytest or unittest) to determine which markers to look for
37+
* @returns Array of detected project roots, or a single element with the workspace root if no projects found
38+
*/
39+
export async function findProjectRoots(workspaceUri: Uri, testProvider: TestProvider): Promise<ProjectRoot[]> {
40+
const markers = testProvider === 'pytest' ? PYTEST_PROJECT_MARKERS : UNITTEST_PROJECT_MARKERS;
41+
const projectRoots: Map<string, ProjectRoot> = new Map();
42+
43+
traceVerbose(`Searching for ${testProvider} project roots in workspace: ${workspaceUri.fsPath}`);
44+
45+
// Search for each marker file type
46+
for (const marker of markers) {
47+
try {
48+
// Use VS Code's findFiles API to search for marker files
49+
// Exclude common directories to improve performance
50+
const pattern = `**/${marker}`;
51+
const exclude = '**/node_modules/**,**/.venv/**,**/venv/**,**/__pycache__/**,**/.git/**';
52+
const foundFiles = await workspace.findFiles(
53+
new RelativePattern(workspaceUri, pattern),
54+
exclude,
55+
100, // Limit to 100 projects max
56+
);
57+
58+
for (const fileUri of foundFiles) {
59+
// The project root is the directory containing the marker file
60+
const projectRootPath = path.dirname(fileUri.fsPath);
61+
62+
// Only add if we haven't already found a project at this location
63+
if (!projectRoots.has(projectRootPath)) {
64+
projectRoots.set(projectRootPath, {
65+
uri: Uri.file(projectRootPath),
66+
markerFile: marker,
67+
});
68+
traceVerbose(`Found ${testProvider} project root at ${projectRootPath} (marker: ${marker})`);
69+
}
70+
}
71+
} catch (error) {
72+
traceVerbose(`Error searching for ${marker}: ${error}`);
73+
}
74+
}
75+
76+
// If no projects found, treat the entire workspace as a single project
77+
if (projectRoots.size === 0) {
78+
traceVerbose(`No project markers found, using workspace root as single project: ${workspaceUri.fsPath}`);
79+
return [{
80+
uri: workspaceUri,
81+
markerFile: 'none',
82+
}];
83+
}
84+
85+
// Sort projects by path depth (shallowest first) to handle nested projects
86+
const sortedProjects = Array.from(projectRoots.values()).sort((a, b) => {
87+
const depthA = a.uri.fsPath.split(path.sep).length;
88+
const depthB = b.uri.fsPath.split(path.sep).length;
89+
return depthA - depthB;
90+
});
91+
92+
// Filter out nested projects (projects contained within other projects)
93+
const filteredProjects = sortedProjects.filter((project, index) => {
94+
// Keep the project if no earlier project contains it
95+
for (let i = 0; i < index; i++) {
96+
const parentProject = sortedProjects[i];
97+
if (project.uri.fsPath.startsWith(parentProject.uri.fsPath + path.sep)) {
98+
traceVerbose(`Filtering out nested project at ${project.uri.fsPath} (contained in ${parentProject.uri.fsPath})`);
99+
return false;
100+
}
101+
}
102+
return true;
103+
});
104+
105+
traceVerbose(`Found ${filteredProjects.length} ${testProvider} project(s) in workspace ${workspaceUri.fsPath}`);
106+
return filteredProjects;
107+
}
108+
109+
/**
110+
* Checks if a file path belongs to a specific project root
111+
* @param filePath The file path to check
112+
* @param projectRoot The project root to check against
113+
* @returns true if the file belongs to the project
114+
*/
115+
export function isFileInProject(filePath: string, projectRoot: ProjectRoot): boolean {
116+
const normalizedFilePath = path.normalize(filePath);
117+
const normalizedProjectPath = path.normalize(projectRoot.uri.fsPath);
118+
119+
return normalizedFilePath === normalizedProjectPath ||
120+
normalizedFilePath.startsWith(normalizedProjectPath + path.sep);
121+
}
122+
123+
/**
124+
* Finds which project root a test item belongs to based on its URI
125+
* @param testItemUri The URI of the test item
126+
* @param projectRoots Array of project roots to search
127+
* @returns The matching project root, or undefined if not found
128+
*/
129+
export function findProjectForTestItem(testItemUri: Uri, projectRoots: ProjectRoot[]): ProjectRoot | undefined {
130+
// Find the most specific (deepest) project root that contains this test
131+
const matchingProjects = projectRoots
132+
.filter(project => isFileInProject(testItemUri.fsPath, project))
133+
.sort((a, b) => b.uri.fsPath.length - a.uri.fsPath.length); // Sort by path length descending
134+
135+
return matchingProjects[0];
136+
}

0 commit comments

Comments
 (0)