22// Licensed under the MIT License.
33import * as path from 'path' ;
44import { CancellationToken , CancellationTokenSource , Uri } from 'vscode' ;
5- import * as fs from 'fs' ;
65import { ChildProcess } from 'child_process' ;
76import {
87 ExecutionFactoryCreateWithEnvironmentOptions ,
98 IPythonExecutionFactory ,
109 SpawnOptions ,
1110} from '../../../common/process/types' ;
1211import { IConfigurationService } from '../../../common/types' ;
13- import { createDeferred , Deferred } from '../../../common/utils/async' ;
12+ import { Deferred } from '../../../common/utils/async' ;
1413import { EXTENSION_ROOT_DIR } from '../../../constants' ;
15- import { traceError , traceInfo , traceVerbose , traceWarn } from '../../../logging' ;
14+ import { traceError , traceInfo , traceVerbose } from '../../../logging' ;
1615import { 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' ;
2517import { IEnvironmentVariablesProvider } from '../../../common/variables/types' ;
2618import { PythonEnvironment } from '../../../pythonEnvironments/info' ;
2719import { 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