diff --git a/src/extension/debugger/configuration/resolvers/helper.ts b/src/extension/debugger/configuration/resolvers/helper.ts index 6791bded..59258381 100644 --- a/src/extension/debugger/configuration/resolvers/helper.ts +++ b/src/extension/debugger/configuration/resolvers/helper.ts @@ -4,6 +4,7 @@ 'use strict'; +import * as path from 'path'; import { PYTHON_LANGUAGE } from '../../../common/constants'; import { getSearchPathEnvVarNames } from '../../../common/utils/exec'; import { EnvironmentVariables } from '../../../common/variables/types'; @@ -11,8 +12,38 @@ import { getActiveTextEditor } from '../../../common/vscodeapi'; import { LaunchRequestArguments } from '../../../types'; import * as envParser from '../../../common/variables/environment'; +function getSearchPathEnvVar(vars: EnvironmentVariables, names: ('Path' | 'PATH')[]): string | undefined { + return names.map((name) => vars[name]).find((value) => typeof value === 'string' && value.length > 0); +} + +function normalizeSearchPathEnvVar(env: EnvironmentVariables, names: ('Path' | 'PATH')[]) { + const [pathVariableName, ...alternatePathVariableNames] = names; + for (const alternatePathVariableName of alternatePathVariableNames) { + const alternateValue = env[alternatePathVariableName]; + if (typeof alternateValue !== 'string' || alternateValue.length === 0) { + delete env[alternatePathVariableName]; + continue; + } + + const currentValue = env[pathVariableName]; + if (typeof currentValue === 'string' && currentValue.length > 0) { + const currentSegments = new Set(currentValue.split(path.delimiter).filter((item) => item.length > 0)); + const alternateSegments = alternateValue + .split(path.delimiter) + .filter((item) => item.length > 0 && !currentSegments.has(item)); + if (alternateSegments.length > 0) { + env[pathVariableName] = [currentValue, ...alternateSegments].join(path.delimiter); + } + } else { + env[pathVariableName] = alternateValue; + } + delete env[alternatePathVariableName]; + } +} + export async function getDebugEnvironmentVariables(args: LaunchRequestArguments): Promise { - const pathVariableName = getSearchPathEnvVarNames()[0]; + const pathVariableNames = getSearchPathEnvVarNames(); + const pathVariableName = pathVariableNames[0]; // Merge variables from both .env file and env json variables. const debugLaunchEnvVars: Record = @@ -20,20 +51,29 @@ export async function getDebugEnvironmentVariables(args: LaunchRequestArguments) ? ({ ...args.env } as Record) : ({} as Record); const envFileVars = await envParser.parseFile(args.envFile, debugLaunchEnvVars); + normalizeSearchPathEnvVar(debugLaunchEnvVars, pathVariableNames); const env = envFileVars ? { ...envFileVars } : {}; + normalizeSearchPathEnvVar(env, pathVariableNames); // "overwrite: true" to ensure that debug-configuration env variable values // take precedence over env file. envParser.mergeVariables(debugLaunchEnvVars, env, { overwrite: true }); + normalizeSearchPathEnvVar(env, pathVariableNames); // Append the PYTHONPATH and PATH variables. - envParser.appendPath(env, debugLaunchEnvVars[pathVariableName]); + const debugLaunchSearchPath = getSearchPathEnvVar(debugLaunchEnvVars, pathVariableNames); + if (debugLaunchSearchPath) { + envParser.appendPath(env, debugLaunchSearchPath); + } envParser.appendPythonPath(env, debugLaunchEnvVars.PYTHONPATH); if (typeof env[pathVariableName] === 'string' && env[pathVariableName]!.length > 0) { // Now merge this path with the current system path. // We need to do this to ensure the PATH variable always has the system PATHs as well. - envParser.appendPath(env, process.env[pathVariableName]!); + const processSearchPath = getSearchPathEnvVar(process.env, pathVariableNames); + if (processSearchPath) { + envParser.appendPath(env, processSearchPath); + } } if (typeof env.PYTHONPATH === 'string' && env.PYTHONPATH.length > 0) { // We didn't have a value for PATH earlier and now we do. @@ -47,14 +87,17 @@ export async function getDebugEnvironmentVariables(args: LaunchRequestArguments) // As we're spawning the process, we need to ensure all env variables are passed. // Including those from the current process (i.e. everything, not just custom vars). envParser.mergeVariables(process.env, env); + normalizeSearchPathEnvVar(env, pathVariableNames); - if (env[pathVariableName] === undefined && typeof process.env[pathVariableName] === 'string') { - env[pathVariableName] = process.env[pathVariableName]; + const processSearchPath = getSearchPathEnvVar(process.env, pathVariableNames); + if (env[pathVariableName] === undefined && typeof processSearchPath === 'string') { + env[pathVariableName] = processSearchPath; } if (env.PYTHONPATH === undefined && typeof process.env.PYTHONPATH === 'string') { env.PYTHONPATH = process.env.PYTHONPATH; } } + normalizeSearchPathEnvVar(env, pathVariableNames); if (!env.hasOwnProperty('PYTHONIOENCODING')) { env.PYTHONIOENCODING = 'UTF-8'; diff --git a/src/test/unittest/configuration/resolvers/helper.unit.test.ts b/src/test/unittest/configuration/resolvers/helper.unit.test.ts index c2d6d483..e9125820 100644 --- a/src/test/unittest/configuration/resolvers/helper.unit.test.ts +++ b/src/test/unittest/configuration/resolvers/helper.unit.test.ts @@ -8,8 +8,13 @@ import * as sinon from 'sinon'; import * as typemoq from 'typemoq'; import { TextDocument, TextEditor } from 'vscode'; import { PYTHON_LANGUAGE } from '../../../../extension/common/constants'; +import * as platform from '../../../../extension/common/platform'; import * as vscodeapi from '../../../../extension/common/vscodeapi'; -import { getProgram } from '../../../../extension/debugger/configuration/resolvers/helper'; +import { + getDebugEnvironmentVariables, + getProgram, +} from '../../../../extension/debugger/configuration/resolvers/helper'; +import { LaunchRequestArguments } from '../../../../extension/types'; suite('Debugging - Helpers', () => { let getActiveTextEditorStub: sinon.SinonStub; @@ -67,4 +72,42 @@ suite('Debugging - Helpers', () => { expect(program).to.be.equal(undefined, 'Not undefined'); }); + + test('Debug environment should not include duplicate Windows search path keys', async () => { + sinon.stub(platform, 'getOSType').returns(platform.OSType.Windows); + + const originalPath = process.env.Path; + const originalPATH = process.env.PATH; + process.env.Path = 'C:\\Windows\\System32'; + process.env.PATH = 'C:\\Tools'; + + try { + const launchEnv = { ['PATH']: 'C:\\Project\\.venv\\Scripts' }; + const env = await getDebugEnvironmentVariables({ + name: 'Python Debug Test', + type: 'debugpy', + request: 'launch', + env: launchEnv, + console: 'internalConsole', + } as unknown as LaunchRequestArguments); + + expect(env).to.have.property('Path'); + expect(env).not.to.have.property('PATH'); + expect(env.Path).to.contain('C:\\Project\\.venv\\Scripts'); + expect(env.Path).to.contain('C:\\Tools'); + expect(env.Path!.split(';').filter((item) => item === 'C:\\Project\\.venv\\Scripts')).to.have.lengthOf(1); + expect(env.Path!.split(';').filter((item) => item === 'C:\\Tools')).to.have.lengthOf(1); + } finally { + if (originalPath === undefined) { + delete process.env.Path; + } else { + process.env.Path = originalPath; + } + if (originalPATH === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPATH; + } + } + }); });