Skip to content

Commit 0c92933

Browse files
authored
Environment Variable management and bug fixes (#28)
1 parent e19d9d3 commit 0c92933

File tree

10 files changed

+280
-21
lines changed

10 files changed

+280
-21
lines changed

package-lock.json

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,7 @@
451451
"@iarna/toml": "^2.2.5",
452452
"@vscode/extension-telemetry": "^0.9.7",
453453
"@vscode/test-cli": "^0.0.10",
454+
"dotenv": "^16.4.5",
454455
"fs-extra": "^11.2.0",
455456
"stack-trace": "0.0.10",
456457
"vscode-jsonrpc": "^9.0.0-next.5",

src/api.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Terminal,
99
TaskExecution,
1010
TerminalOptions,
11+
FileChangeType,
1112
} from 'vscode';
1213

1314
/**
@@ -1105,11 +1106,45 @@ export interface PythonExecutionApi
11051106
PythonTerminalRunApi,
11061107
PythonTaskRunApi,
11071108
PythonBackgroundRunApi {}
1109+
1110+
export interface DidChangeEnvironmentVariablesEventArgs {
1111+
uri?: Uri;
1112+
changeTye: FileChangeType;
1113+
}
1114+
1115+
export interface PythonEnvironmentVariablesApi {
1116+
/**
1117+
* Get environment variables for a workspace. This picks up `.env` file from the root of the
1118+
* workspace.
1119+
*
1120+
* Order of overrides:
1121+
* 1. `baseEnvVar` if given or `process.env`
1122+
* 2. `.env` file from the "python.envFile" setting in the workspace.
1123+
* 3. `.env` file at the root of the python project.
1124+
* 4. `overrides` in the order provided.
1125+
*
1126+
* @param uri The URI of the project, workspace or a file in a for which environment variables are required.
1127+
* @param overrides Additional environment variables to override the defaults.
1128+
* @param baseEnvVar The base environment variables that should be used as a starting point.
1129+
*/
1130+
getEnvironmentVariables(
1131+
uri: Uri,
1132+
overrides?: ({ [key: string]: string | undefined } | Uri)[],
1133+
baseEnvVar?: { [key: string]: string | undefined },
1134+
): Promise<{ [key: string]: string | undefined }>;
1135+
1136+
/**
1137+
* Event raised when `.env` file changes or any other monitored source of env variable changes.
1138+
*/
1139+
onDidChangeEnvironmentVariables: Event<DidChangeEnvironmentVariablesEventArgs>;
1140+
}
1141+
11081142
/**
11091143
* The API for interacting with Python environments, package managers, and projects.
11101144
*/
11111145
export interface PythonEnvironmentApi
11121146
extends PythonEnvironmentManagerApi,
11131147
PythonPackageManagerApi,
11141148
PythonProjectApi,
1115-
PythonExecutionApi {}
1149+
PythonExecutionApi,
1150+
PythonEnvironmentVariablesApi {}

src/common/utils/internalVariables.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Uri } from 'vscode';
2+
import { getWorkspaceFolder, getWorkspaceFolders } from '../workspace.apis';
3+
4+
export function resolveVariables(value: string, project?: Uri, env?: { [key: string]: string }): string {
5+
const substitutions = new Map<string, string>();
6+
const home = process.env.HOME || process.env.USERPROFILE;
7+
if (home) {
8+
substitutions.set('${userHome}', home);
9+
}
10+
11+
if (project) {
12+
substitutions.set('${pythonProject}', project.fsPath);
13+
}
14+
15+
const workspace = project ? getWorkspaceFolder(project) : undefined;
16+
if (workspace) {
17+
substitutions.set('${workspaceFolder}', workspace.uri.fsPath);
18+
}
19+
substitutions.set('${cwd}', process.cwd());
20+
(getWorkspaceFolders() ?? []).forEach((w) => {
21+
substitutions.set('${workspaceFolder:' + w.name + '}', w.uri.fsPath);
22+
});
23+
24+
const substEnv = env || process.env;
25+
if (substEnv) {
26+
for (const [key, value] of Object.entries(substEnv)) {
27+
if (value && key.length > 0) {
28+
substitutions.set('${env:' + key + '}', value);
29+
}
30+
}
31+
}
32+
33+
let result = value;
34+
substitutions.forEach((v, k) => {
35+
while (k.length > 0 && result.indexOf(k) >= 0) {
36+
result = result.replace(k, v);
37+
}
38+
});
39+
return result;
40+
}

src/common/workspace.fs.apis.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { FileStat, Uri, workspace } from 'vscode';
2+
3+
export function readFile(uri: Uri): Thenable<Uint8Array> {
4+
return workspace.fs.readFile(uri);
5+
}
6+
7+
export function stat(uri: Uri): Thenable<FileStat> {
8+
return workspace.fs.stat(uri);
9+
}

src/extension.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
} from './features/terminal/activateMenuButton';
5252
import { PythonStatusBarImpl } from './features/views/pythonStatusBar';
5353
import { updateViewsAndStatus } from './features/views/revealHandler';
54+
import { EnvVarManager, PythonEnvVariableManager } from './features/execution/envVariableManager';
5455

5556
export async function activate(context: ExtensionContext): Promise<PythonEnvironmentApi> {
5657
// Logging should be set up before anything else.
@@ -69,6 +70,9 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
6970
const projectManager: PythonProjectManager = new PythonProjectManagerImpl();
7071
context.subscriptions.push(projectManager);
7172

73+
const envVarManager: EnvVarManager = new PythonEnvVariableManager(projectManager);
74+
context.subscriptions.push(envVarManager);
75+
7276
const envManagers: EnvironmentManagers = new PythonEnvironmentManagers(projectManager);
7377
context.subscriptions.push(envManagers);
7478

@@ -79,7 +83,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
7983
registerAutoProjectProvider(projectCreators),
8084
);
8185

82-
setPythonApi(envManagers, projectManager, projectCreators, terminalManager);
86+
setPythonApi(envManagers, projectManager, projectCreators, terminalManager, envVarManager);
8387

8488
const managerView = new EnvManagerView(envManagers);
8589
context.subscriptions.push(managerView);
@@ -103,10 +107,10 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
103107
await refreshPackagesCommand(item);
104108
}),
105109
commands.registerCommand('python-envs.create', async (item) => {
106-
await createEnvironmentCommand(item, envManagers, projectManager);
110+
return await createEnvironmentCommand(item, envManagers, projectManager);
107111
}),
108112
commands.registerCommand('python-envs.createAny', async () => {
109-
await createAnyEnvironmentCommand(envManagers, projectManager);
113+
return await createAnyEnvironmentCommand(envManagers, projectManager);
110114
}),
111115
commands.registerCommand('python-envs.remove', async (item) => {
112116
await removeEnvironmentCommand(item, envManagers);

src/features/envCommands.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,48 +68,58 @@ export async function createEnvironmentCommand(
6868
context: unknown,
6969
em: EnvironmentManagers,
7070
pm: PythonProjectManager,
71-
): Promise<void> {
71+
): Promise<PythonEnvironment | undefined> {
7272
if (context instanceof EnvManagerTreeItem) {
7373
const manager = (context as EnvManagerTreeItem).manager;
7474
const projects = await pickProjectMany(pm.getProjects());
7575
if (projects) {
76-
await manager.create(projects.length === 0 ? 'global' : projects.map((p) => p.uri));
76+
return await manager.create(projects.length === 0 ? 'global' : projects.map((p) => p.uri));
77+
} else {
78+
traceError(`No projects found for ${context}`);
7779
}
7880
} else if (context instanceof Uri) {
7981
const manager = em.getEnvironmentManager(context as Uri);
8082
const project = pm.get(context as Uri);
8183
if (project) {
82-
await manager?.create(project.uri);
84+
return await manager?.create(project.uri);
85+
} else {
86+
traceError(`No project found for ${context}`);
8387
}
8488
} else {
8589
traceError(`Invalid context for create command: ${context}`);
8690
}
8791
}
8892

89-
export async function createAnyEnvironmentCommand(em: EnvironmentManagers, pm: PythonProjectManager): Promise<void> {
93+
export async function createAnyEnvironmentCommand(
94+
em: EnvironmentManagers,
95+
pm: PythonProjectManager,
96+
): Promise<PythonEnvironment | undefined> {
9097
const projects = await pickProjectMany(pm.getProjects());
9198
if (projects && projects.length > 0) {
9299
const defaultManagers: InternalEnvironmentManager[] = [];
100+
93101
projects.forEach((p) => {
94102
const manager = em.getEnvironmentManager(p.uri);
95103
if (manager && manager.supportsCreate && !defaultManagers.includes(manager)) {
96104
defaultManagers.push(manager);
97105
}
98106
});
107+
99108
const managerId = await pickEnvironmentManager(
100109
em.managers.filter((m) => m.supportsCreate),
101110
defaultManagers,
102111
);
103112

104113
const manager = em.managers.find((m) => m.id === managerId);
105114
if (manager) {
106-
await manager.create(projects.map((p) => p.uri));
115+
return await manager.create(projects.map((p) => p.uri));
107116
}
108117
} else if (projects && projects.length === 0) {
109118
const managerId = await pickEnvironmentManager(em.managers.filter((m) => m.supportsCreate));
119+
110120
const manager = em.managers.find((m) => m.id === managerId);
111121
if (manager) {
112-
await manager.create('global');
122+
return await manager.create('global');
113123
}
114124
}
115125
}

src/features/execution/envVarUtils.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Uri } from 'vscode';
2+
import { readFile } from '../../common/workspace.fs.apis';
3+
import { parse } from 'dotenv';
4+
5+
export function mergeEnvVariables(
6+
base: { [key: string]: string | undefined },
7+
other: { [key: string]: string | undefined },
8+
) {
9+
const env: { [key: string]: string | undefined } = {};
10+
11+
Object.keys(other).forEach((otherKey) => {
12+
let value = other[otherKey];
13+
if (value === undefined || value === '') {
14+
// SOME_ENV_VAR=
15+
delete env[otherKey];
16+
} else {
17+
Object.keys(base).forEach((baseKey) => {
18+
const baseValue = base[baseKey];
19+
if (baseValue) {
20+
value = value?.replace(`\${${baseKey}}`, baseValue);
21+
}
22+
});
23+
env[otherKey] = value;
24+
}
25+
});
26+
27+
return env;
28+
}
29+
30+
export async function parseEnvFile(envFile: Uri): Promise<{ [key: string]: string | undefined }> {
31+
const raw = await readFile(envFile);
32+
const contents = Buffer.from(raw).toString('utf-8');
33+
return parse(contents);
34+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import * as path from 'path';
2+
import * as fsapi from 'fs-extra';
3+
import { Uri, Event, EventEmitter, FileChangeType } from 'vscode';
4+
import { DidChangeEnvironmentVariablesEventArgs, PythonEnvironmentVariablesApi } from '../../api';
5+
import { Disposable } from 'vscode-jsonrpc';
6+
import { createFileSystemWatcher, getConfiguration } from '../../common/workspace.apis';
7+
import { PythonProjectManager } from '../../internal.api';
8+
import { mergeEnvVariables, parseEnvFile } from './envVarUtils';
9+
import { resolveVariables } from '../../common/utils/internalVariables';
10+
11+
export interface EnvVarManager extends PythonEnvironmentVariablesApi, Disposable {}
12+
13+
export class PythonEnvVariableManager implements EnvVarManager {
14+
private disposables: Disposable[] = [];
15+
16+
private _onDidChangeEnvironmentVariables;
17+
private watcher;
18+
19+
constructor(private pm: PythonProjectManager) {
20+
this._onDidChangeEnvironmentVariables = new EventEmitter<DidChangeEnvironmentVariablesEventArgs>();
21+
this.onDidChangeEnvironmentVariables = this._onDidChangeEnvironmentVariables.event;
22+
23+
this.watcher = createFileSystemWatcher('**/.env');
24+
this.disposables.push(
25+
this._onDidChangeEnvironmentVariables,
26+
this.watcher,
27+
this.watcher.onDidCreate((e) =>
28+
this._onDidChangeEnvironmentVariables.fire({ uri: e, changeTye: FileChangeType.Created }),
29+
),
30+
this.watcher.onDidChange((e) =>
31+
this._onDidChangeEnvironmentVariables.fire({ uri: e, changeTye: FileChangeType.Changed }),
32+
),
33+
this.watcher.onDidDelete((e) =>
34+
this._onDidChangeEnvironmentVariables.fire({ uri: e, changeTye: FileChangeType.Deleted }),
35+
),
36+
);
37+
}
38+
39+
async getEnvironmentVariables(
40+
uri: Uri,
41+
overrides?: ({ [key: string]: string | undefined } | Uri)[],
42+
baseEnvVar?: { [key: string]: string | undefined },
43+
): Promise<{ [key: string]: string | undefined }> {
44+
const project = this.pm.get(uri);
45+
46+
const base = baseEnvVar || { ...process.env };
47+
let env = base;
48+
49+
const config = getConfiguration('python', project?.uri ?? uri);
50+
let envFilePath = config.get<string>('envFile');
51+
envFilePath = envFilePath ? path.normalize(resolveVariables(envFilePath)) : undefined;
52+
53+
if (envFilePath && (await fsapi.pathExists(envFilePath))) {
54+
const other = await parseEnvFile(Uri.file(envFilePath));
55+
env = mergeEnvVariables(env, other);
56+
}
57+
58+
let projectEnvFilePath = project ? path.normalize(path.join(project.uri.fsPath, '.env')) : undefined;
59+
if (
60+
projectEnvFilePath &&
61+
projectEnvFilePath?.toLowerCase() !== envFilePath?.toLowerCase() &&
62+
(await fsapi.pathExists(projectEnvFilePath))
63+
) {
64+
const other = await parseEnvFile(Uri.file(projectEnvFilePath));
65+
env = mergeEnvVariables(env, other);
66+
}
67+
68+
if (overrides) {
69+
for (const override of overrides) {
70+
const other = override instanceof Uri ? await parseEnvFile(override) : override;
71+
env = mergeEnvVariables(env, other);
72+
}
73+
}
74+
75+
return env;
76+
}
77+
78+
onDidChangeEnvironmentVariables: Event<DidChangeEnvironmentVariablesEventArgs>;
79+
80+
dispose(): void {
81+
this.disposables.forEach((disposable) => disposable.dispose());
82+
}
83+
}

0 commit comments

Comments
 (0)