Skip to content

Commit e2f4691

Browse files
committed
Refactor pytest test discovery
1 parent 8989323 commit e2f4691

File tree

2 files changed

+244
-174
lines changed

2 files changed

+244
-174
lines changed

src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts

Lines changed: 110 additions & 174 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,27 @@
22
// Licensed under the MIT License.
33
import * as path from 'path';
44
import { CancellationToken, CancellationTokenSource, Uri } from 'vscode';
5-
import * as fs from 'fs';
65
import { ChildProcess } from 'child_process';
76
import {
87
ExecutionFactoryCreateWithEnvironmentOptions,
98
IPythonExecutionFactory,
109
SpawnOptions,
1110
} from '../../../common/process/types';
1211
import { IConfigurationService } from '../../../common/types';
13-
import { createDeferred, Deferred } from '../../../common/utils/async';
12+
import { Deferred } from '../../../common/utils/async';
1413
import { EXTENSION_ROOT_DIR } from '../../../constants';
15-
import { traceError, traceInfo, traceVerbose, traceWarn } from '../../../logging';
14+
import { traceError, traceInfo, traceVerbose } from '../../../logging';
1615
import { DiscoveredTestPayload, ITestDiscoveryAdapter, ITestResultResolver } from '../common/types';
17-
import {
18-
createDiscoveryErrorPayload,
19-
createTestingDeferred,
20-
fixLogLinesNoTrailing,
21-
startDiscoveryNamedPipe,
22-
addValueIfKeyNotExist,
23-
hasSymlinkParent,
24-
} from '../common/utils';
16+
import { createTestingDeferred, startDiscoveryNamedPipe } from '../common/utils';
2517
import { IEnvironmentVariablesProvider } from '../../../common/variables/types';
2618
import { PythonEnvironment } from '../../../pythonEnvironments/info';
2719
import { useEnvExtension, getEnvironment, runInBackground } from '../../../envExt/api.internal';
20+
import {
21+
cleanupOnCancellation,
22+
buildPytestEnv as configureSubprocessEnv,
23+
createProcessHandlers,
24+
handleSymlinkAndRootDir,
25+
} from './pytestHelpers';
2826

2927
/**
3028
* Wrapper class for unittest test discovery. This is where we call `runTestCommand`. #this seems incorrectly copied
@@ -42,185 +40,123 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter {
4240
token?: CancellationToken,
4341
interpreter?: PythonEnvironment,
4442
): Promise<void> {
45-
const cSource = new CancellationTokenSource();
46-
const deferredReturn = createDeferred<void>();
47-
48-
token?.onCancellationRequested(() => {
49-
traceInfo(`Test discovery cancelled.`);
50-
cSource.cancel();
51-
deferredReturn.resolve();
52-
});
53-
54-
const name = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => {
55-
// if the token is cancelled, we don't want process the data
56-
if (!token?.isCancellationRequested) {
57-
this.resultResolver?.resolveDiscovery(data);
58-
}
59-
}, cSource.token);
60-
61-
this.runPytestDiscovery(uri, name, cSource, executionFactory, interpreter, token).then(() => {
62-
deferredReturn.resolve();
63-
});
64-
65-
return deferredReturn.promise;
66-
}
67-
68-
async runPytestDiscovery(
69-
uri: Uri,
70-
discoveryPipeName: string,
71-
cSource: CancellationTokenSource,
72-
executionFactory: IPythonExecutionFactory,
73-
interpreter?: PythonEnvironment,
74-
token?: CancellationToken,
75-
): Promise<void> {
76-
const relativePathToPytest = 'python_files';
77-
const fullPluginPath = path.join(EXTENSION_ROOT_DIR, relativePathToPytest);
78-
const settings = this.configSettings.getSettings(uri);
79-
let { pytestArgs } = settings.testing;
80-
const cwd = settings.testing.cwd && settings.testing.cwd.length > 0 ? settings.testing.cwd : uri.fsPath;
81-
82-
// check for symbolic path
83-
const stats = await fs.promises.lstat(cwd);
84-
const resolvedPath = await fs.promises.realpath(cwd);
85-
let isSymbolicLink = false;
86-
if (stats.isSymbolicLink()) {
87-
isSymbolicLink = true;
88-
traceWarn('The cwd is a symbolic link.');
89-
} else if (resolvedPath !== cwd) {
90-
traceWarn(
91-
'The cwd resolves to a different path, checking if it has a symbolic link somewhere in its path.',
92-
);
93-
isSymbolicLink = await hasSymlinkParent(cwd);
94-
}
95-
if (isSymbolicLink) {
96-
traceWarn("Symlink found, adding '--rootdir' to pytestArgs only if it doesn't already exist. cwd: ", cwd);
97-
pytestArgs = addValueIfKeyNotExist(pytestArgs, '--rootdir', cwd);
98-
}
99-
// if user has provided `--rootdir` then use that, otherwise add `cwd`
100-
// root dir is required so pytest can find the relative paths and for symlinks
101-
addValueIfKeyNotExist(pytestArgs, '--rootdir', cwd);
102-
103-
// get and edit env vars
104-
const mutableEnv = {
105-
...(await this.envVarsService?.getEnvironmentVariables(uri)),
106-
};
107-
// get python path from mutable env, it contains process.env as well
108-
const pythonPathParts: string[] = mutableEnv.PYTHONPATH?.split(path.delimiter) ?? [];
109-
const pythonPathCommand = [fullPluginPath, ...pythonPathParts].join(path.delimiter);
110-
mutableEnv.PYTHONPATH = pythonPathCommand;
111-
mutableEnv.TEST_RUN_PIPE = discoveryPipeName;
112-
traceInfo(
113-
`Environment variables set for pytest discovery: PYTHONPATH=${mutableEnv.PYTHONPATH}, TEST_RUN_PIPE=${mutableEnv.TEST_RUN_PIPE}`,
114-
);
115-
116-
// delete UUID following entire discovery finishing.
117-
const execArgs = ['-m', 'pytest', '-p', 'vscode_pytest', '--collect-only'].concat(pytestArgs);
118-
traceVerbose(`Running pytest discovery with command: ${execArgs.join(' ')} for workspace ${uri.fsPath}.`);
119-
120-
if (useEnvExtension()) {
121-
const pythonEnv = await getEnvironment(uri);
122-
if (pythonEnv) {
123-
const deferredTillExecClose: Deferred<void> = createTestingDeferred();
43+
// Setup: cancellation and discovery pipe
44+
const discoveryPipeCancellation = new CancellationTokenSource();
45+
try {
46+
token?.onCancellationRequested(() => {
47+
traceInfo(`Test discovery cancelled.`);
48+
discoveryPipeCancellation.cancel();
49+
});
50+
51+
const discoveryPipeName = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => {
52+
if (!token?.isCancellationRequested) {
53+
this.resultResolver?.resolveDiscovery(data);
54+
}
55+
}, discoveryPipeCancellation.token);
56+
traceVerbose(`Created discovery pipe: ${discoveryPipeName} for workspace ${uri.fsPath}`);
57+
58+
// Build pytest command and arguments
59+
const settings = this.configSettings.getSettings(uri);
60+
let { pytestArgs } = settings.testing;
61+
const cwd = settings.testing.cwd && settings.testing.cwd.length > 0 ? settings.testing.cwd : uri.fsPath;
62+
pytestArgs = await handleSymlinkAndRootDir(cwd, pytestArgs);
63+
const execArgs = ['-m', 'pytest', '-p', 'vscode_pytest', '--collect-only'].concat(pytestArgs);
64+
traceVerbose(`Running pytest discovery with command: ${execArgs.join(' ')} for workspace ${uri.fsPath}.`);
65+
66+
// Configure subprocess environment
67+
const fullPluginPath = path.join(EXTENSION_ROOT_DIR, 'python_files');
68+
const envVars = await this.envVarsService?.getEnvironmentVariables(uri);
69+
const mutableEnv = await configureSubprocessEnv(envVars, fullPluginPath, discoveryPipeName);
70+
71+
// Setup process handlers (shared by both execution paths)
72+
const deferredTillExecClose: Deferred<void> = createTestingDeferred();
73+
const handlers = createProcessHandlers(uri, cwd, this.resultResolver, deferredTillExecClose);
74+
75+
// Execute using environment extension if available
76+
if (useEnvExtension()) {
77+
traceInfo(`Using environment extension for pytest discovery in workspace ${uri.fsPath}`);
78+
const pythonEnv = await getEnvironment(uri);
79+
if (!pythonEnv) {
80+
traceError(
81+
`Python environment not found for workspace ${uri.fsPath}. Cannot proceed with test discovery.`,
82+
);
83+
return;
84+
}
85+
traceVerbose(`Using Python environment: ${JSON.stringify(pythonEnv)}`);
12486

12587
const proc = await runInBackground(pythonEnv, {
12688
cwd,
12789
args: execArgs,
12890
env: (mutableEnv as unknown) as { [key: string]: string },
12991
});
92+
traceInfo(`Started pytest discovery subprocess (environment extension) for workspace ${uri.fsPath}`);
93+
94+
// Wire up cancellation and process events
13095
token?.onCancellationRequested(() => {
131-
traceInfo(`Test discovery cancelled, killing pytest subprocess for workspace ${uri.fsPath}`);
132-
proc.kill();
133-
deferredTillExecClose.resolve();
134-
cSource.cancel();
135-
});
136-
proc.stdout.on('data', (data) => {
137-
const out = fixLogLinesNoTrailing(data.toString());
138-
traceInfo(out);
139-
});
140-
proc.stderr.on('data', (data) => {
141-
const out = fixLogLinesNoTrailing(data.toString());
142-
traceError(out);
96+
cleanupOnCancellation(proc, deferredTillExecClose, discoveryPipeCancellation, uri);
14397
});
98+
proc.stdout.on('data', handlers.onStdout);
99+
proc.stderr.on('data', handlers.onStderr);
144100
proc.onExit((code, signal) => {
145-
if (code !== 0) {
146-
traceError(
147-
`Subprocess exited unsuccessfully with exit code ${code} and signal ${signal} on workspace ${uri.fsPath}`,
148-
);
149-
this.resultResolver?.resolveDiscovery(createDiscoveryErrorPayload(code, signal, cwd));
150-
}
151-
deferredTillExecClose.resolve();
101+
handlers.onExit(code, signal);
102+
handlers.onClose(code, signal);
152103
});
104+
153105
await deferredTillExecClose.promise;
154-
} else {
155-
traceError(`Python Environment not found for: ${uri.fsPath}`);
106+
traceInfo(`Pytest discovery completed for workspace ${uri.fsPath}`);
107+
return;
156108
}
157-
return;
158-
}
159109

160-
const spawnOptions: SpawnOptions = {
161-
cwd,
162-
throwOnStdErr: true,
163-
env: mutableEnv,
164-
token,
165-
};
166-
167-
// Create the Python environment in which to execute the command.
168-
const creationOptions: ExecutionFactoryCreateWithEnvironmentOptions = {
169-
allowEnvironmentFetchExceptions: false,
170-
resource: uri,
171-
interpreter,
172-
};
173-
const execService = await executionFactory.createActivatedEnvironment(creationOptions);
174-
175-
const execInfo = await execService?.getExecutablePath();
176-
traceVerbose(`Executable path for pytest discovery: ${execInfo}.`);
177-
178-
const deferredTillExecClose: Deferred<void> = createTestingDeferred();
179-
180-
let resultProc: ChildProcess | undefined;
181-
182-
token?.onCancellationRequested(() => {
183-
traceInfo(`Test discovery cancelled, killing pytest subprocess for workspace ${uri.fsPath}`);
184-
// if the resultProc exists just call kill on it which will handle resolving the ExecClose deferred, otherwise resolve the deferred here.
185-
if (resultProc) {
186-
resultProc?.kill();
187-
} else {
188-
deferredTillExecClose.resolve();
189-
cSource.cancel();
190-
}
191-
});
192-
const result = execService?.execObservable(execArgs, spawnOptions);
193-
resultProc = result?.proc;
194-
195-
// Take all output from the subprocess and add it to the test output channel. This will be the pytest output.
196-
// Displays output to user and ensure the subprocess doesn't run into buffer overflow.
197-
198-
result?.proc?.stdout?.on('data', (data) => {
199-
const out = fixLogLinesNoTrailing(data.toString());
200-
traceInfo(out);
201-
});
202-
result?.proc?.stderr?.on('data', (data) => {
203-
const out = fixLogLinesNoTrailing(data.toString());
204-
traceError(out);
205-
});
206-
result?.proc?.on('exit', (code, signal) => {
207-
if (code !== 0) {
110+
// Execute using execution factory (fallback path)
111+
traceInfo(`Using execution factory for pytest discovery in workspace ${uri.fsPath}`);
112+
const creationOptions: ExecutionFactoryCreateWithEnvironmentOptions = {
113+
allowEnvironmentFetchExceptions: false,
114+
resource: uri,
115+
interpreter,
116+
};
117+
const execService = await executionFactory.createActivatedEnvironment(creationOptions);
118+
if (!execService) {
208119
traceError(
209-
`Subprocess exited unsuccessfully with exit code ${code} and signal ${signal} on workspace ${uri.fsPath}.`,
120+
`Failed to create execution service for workspace ${uri.fsPath}. Cannot proceed with test discovery.`,
210121
);
122+
return;
211123
}
212-
});
213-
result?.proc?.on('close', (code, signal) => {
214-
// pytest exits with code of 5 when 0 tests are found- this is not a failure for discovery.
215-
if (code !== 0 && code !== 5) {
216-
traceError(
217-
`Subprocess exited unsuccessfully with exit code ${code} and signal ${signal} on workspace ${uri.fsPath}. Creating and sending error discovery payload`,
218-
);
219-
this.resultResolver?.resolveDiscovery(createDiscoveryErrorPayload(code, signal, cwd));
124+
const execInfo = await execService.getExecutablePath();
125+
traceVerbose(`Using Python executable: ${execInfo} for workspace ${uri.fsPath}`);
126+
127+
const spawnOptions: SpawnOptions = {
128+
cwd,
129+
throwOnStdErr: true,
130+
env: mutableEnv,
131+
token,
132+
};
133+
134+
let resultProc: ChildProcess | undefined;
135+
const result = execService.execObservable(execArgs, spawnOptions);
136+
resultProc = result?.proc;
137+
138+
if (!resultProc) {
139+
traceError(`Failed to spawn pytest discovery subprocess for workspace ${uri.fsPath}`);
140+
return;
220141
}
221-
// due to the sync reading of the output.
222-
deferredTillExecClose?.resolve();
223-
});
224-
await deferredTillExecClose.promise;
142+
traceInfo(`Started pytest discovery subprocess (execution factory) for workspace ${uri.fsPath}`);
143+
144+
// Wire up cancellation and process events
145+
token?.onCancellationRequested(() => {
146+
cleanupOnCancellation(resultProc, deferredTillExecClose, discoveryPipeCancellation, uri);
147+
});
148+
resultProc.stdout?.on('data', handlers.onStdout);
149+
resultProc.stderr?.on('data', handlers.onStderr);
150+
resultProc.on('exit', handlers.onExit);
151+
resultProc.on('close', handlers.onClose);
152+
153+
await deferredTillExecClose.promise;
154+
traceInfo(`Pytest discovery completed for workspace ${uri.fsPath}`);
155+
} catch (error) {
156+
traceError(`Error during pytest discovery for workspace ${uri.fsPath}: ${error}`);
157+
throw error;
158+
} finally {
159+
discoveryPipeCancellation.dispose();
160+
}
225161
}
226162
}

0 commit comments

Comments
 (0)