Skip to content

Commit 90840d3

Browse files
Support simple variable substitution in .env files. (#4267)
(for #3275) Notable: * only simple substitution is supported (via `${NAME}`; no nesting) * substitution can be made relative to the current env vars * recursion (setting var relative to itself) is supported * stopped using `dotenv` module (doesn't preserve order) * any invalid substitution causes value to be left as-is * adds the `ENVFILE_VARIABLE_SUBSTITUTION` telemetry event
1 parent a650612 commit 90840d3

File tree

13 files changed

+399
-24
lines changed

13 files changed

+399
-24
lines changed

.github/test_plan.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ print('Hello,', os.environ.get('WHO'), '!')
8383
# .env
8484
WHO=world
8585
PYTHONPATH=some/path/somewhere
86+
SPAM='hello ${WHO}'
8687
````
8788
8889
**ALWAYS**:
@@ -92,6 +93,7 @@ PYTHONPATH=some/path/somewhere
9293
- [ ] Environment variables in a `.env` file are exposed when running under the debugger
9394
- [ ] `"python.envFile"` allows for specifying an environment file manually (e.g. Jedi picks up `PYTHONPATH` changes)
9495
- [ ] `envFile` in a `launch.json` configuration works
96+
- [ ] simple variable substitution works
9597
9698
#### [Debugging](https://code.visualstudio.com/docs/python/environments#_python-interpreter-for-debugging)
9799

news/1 Enhancements/3275.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support simple variable substitution in .env files.

package-lock.json

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

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1920,7 +1920,6 @@
19201920
"arch": "^2.1.0",
19211921
"azure-storage": "^2.10.1",
19221922
"diff-match-patch": "^1.0.0",
1923-
"dotenv": "^5.0.1",
19241923
"file-matcher": "^1.3.0",
19251924
"fs-extra": "^4.0.3",
19261925
"fuzzy": "^0.1.3",
@@ -1974,7 +1973,6 @@
19741973
"@types/copy-webpack-plugin": "^4.4.2",
19751974
"@types/del": "^3.0.0",
19761975
"@types/diff-match-patch": "^1.0.32",
1977-
"@types/dotenv": "^4.0.3",
19781976
"@types/download": "^6.2.2",
19791977
"@types/enzyme": "^3.1.14",
19801978
"@types/enzyme-adapter-react-16": "^1.0.3",

src/client/common/variables/environment.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
import * as dotenv from 'dotenv';
54
import * as fs from 'fs-extra';
65
import { inject, injectable } from 'inversify';
76
import * as path from 'path';
7+
import { sendTelemetryEvent } from '../../telemetry';
8+
import { EventName } from '../../telemetry/constants';
89
import { IPathUtils } from '../types';
910
import { EnvironmentVariables, IEnvironmentVariablesService } from './types';
1011

@@ -14,14 +15,14 @@ export class EnvironmentVariablesService implements IEnvironmentVariablesService
1415
constructor(@inject(IPathUtils) pathUtils: IPathUtils) {
1516
this.pathVariable = pathUtils.getPathVariableName();
1617
}
17-
public async parseFile(filePath?: string): Promise<EnvironmentVariables | undefined> {
18+
public async parseFile(filePath?: string, baseVars?: EnvironmentVariables): Promise<EnvironmentVariables | undefined> {
1819
if (!filePath || !await fs.pathExists(filePath)) {
1920
return;
2021
}
2122
if (!fs.lstatSync(filePath).isFile()) {
2223
return;
2324
}
24-
return dotenv.parse(await fs.readFile(filePath));
25+
return parseEnvFile(await fs.readFile(filePath), baseVars);
2526
}
2627
public mergeVariables(source: EnvironmentVariables, target: EnvironmentVariables) {
2728
if (!target) {
@@ -61,3 +62,77 @@ export class EnvironmentVariablesService implements IEnvironmentVariablesService
6162
return vars;
6263
}
6364
}
65+
66+
export function parseEnvFile(
67+
lines: string | Buffer,
68+
baseVars?: EnvironmentVariables
69+
): EnvironmentVariables {
70+
const globalVars = baseVars ? baseVars : {};
71+
const vars = {};
72+
lines.toString().split('\n').forEach((line, idx) => {
73+
const [name, value] = parseEnvLine(line);
74+
if (name === '') {
75+
return;
76+
}
77+
vars[name] = substituteEnvVars(value, vars, globalVars);
78+
});
79+
return vars;
80+
}
81+
82+
function parseEnvLine(line: string): [string, string] {
83+
// Most of the following is an adaptation of the dotenv code:
84+
// https://github.com/motdotla/dotenv/blob/master/lib/main.js#L32
85+
// We don't use dotenv here because it loses ordering, which is
86+
// significant for substitution.
87+
const match = line.match(/^\s*([a-zA-Z]\w*)\s*=\s*(.*?)?\s*$/);
88+
if (!match) {
89+
return ['', ''];
90+
}
91+
92+
const name = match[1];
93+
let value = match[2];
94+
if (value && value !== '') {
95+
if (value[0] === '\'' && value[value.length - 1] === '\'') {
96+
value = value.substring(1, value.length - 1);
97+
value = value.replace(/\\n/gm, '\n');
98+
} else if (value[0] === '"' && value[value.length - 1] === '"') {
99+
value = value.substring(1, value.length - 1);
100+
value = value.replace(/\\n/gm, '\n');
101+
}
102+
} else {
103+
value = '';
104+
}
105+
106+
return [name, value];
107+
}
108+
109+
const SUBST_REGEX = /\${([a-zA-Z]\w*)?([^}\w].*)?}/g;
110+
111+
function substituteEnvVars(
112+
value: string,
113+
localVars: EnvironmentVariables,
114+
globalVars: EnvironmentVariables,
115+
missing = ''
116+
): string {
117+
// Substitution here is inspired a little by dotenv-expand:
118+
// https://github.com/motdotla/dotenv-expand/blob/master/lib/main.js
119+
120+
let invalid = false;
121+
let replacement = value;
122+
replacement = replacement.replace(SUBST_REGEX, (match, substName, bogus, offset, orig) => {
123+
if (offset > 0 && orig[offset - 1] === '\\') {
124+
return match;
125+
}
126+
if ((bogus && bogus !== '') || !substName || substName === '') {
127+
invalid = true;
128+
return match;
129+
}
130+
return localVars[substName] || globalVars[substName] || missing;
131+
});
132+
if (!invalid && replacement !== value) {
133+
value = replacement;
134+
sendTelemetryEvent(EventName.ENVFILE_VARIABLE_SUBSTITUTION);
135+
}
136+
137+
return value.replace(/\\\$/g, '$');
138+
}

src/client/common/variables/environmentVariablesProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export class EnvironmentVariablesProvider implements IEnvironmentVariablesProvid
4444
const workspaceFolderUri = this.getWorkspaceFolderUri(resource);
4545
this.trackedWorkspaceFolders.add(workspaceFolderUri ? workspaceFolderUri.fsPath : '');
4646
this.createFileWatcher(settings.envFile, workspaceFolderUri);
47-
let mergedVars = await this.envVarsService.parseFile(settings.envFile);
47+
let mergedVars = await this.envVarsService.parseFile(settings.envFile, this.process.env);
4848
if (!mergedVars) {
4949
mergedVars = {};
5050
}

src/client/common/variables/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export type EnvironmentVariables = Object & Record<string, string | undefined>;
88
export const IEnvironmentVariablesService = Symbol('IEnvironmentVariablesService');
99

1010
export interface IEnvironmentVariablesService {
11-
parseFile(filePath?: string): Promise<EnvironmentVariables | undefined>;
11+
parseFile(filePath?: string, baseVars?: EnvironmentVariables): Promise<EnvironmentVariables | undefined>;
1212
mergeVariables(source: EnvironmentVariables, target: EnvironmentVariables): void;
1313
appendPythonPath(vars: EnvironmentVariables, ...pythonPaths: string[]): void;
1414
appendPath(vars: EnvironmentVariables, ...paths: string[]): void;

src/client/debugger/debugAdapter/DebugClients/helper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ export class DebugClientHelper {
99
const pathVariableName = this.pathUtils.getPathVariableName();
1010

1111
// Merge variables from both .env file and env json variables.
12-
const envFileVars = await this.envParser.parseFile(args.envFile);
1312
// tslint:disable-next-line:no-any
1413
const debugLaunchEnvVars: Record<string, string> = (args.env && Object.keys(args.env).length > 0) ? { ...args.env } as any : {} as any;
14+
const envFileVars = await this.envParser.parseFile(args.envFile, debugLaunchEnvVars);
1515
const env = envFileVars ? { ...envFileVars! } : {};
1616
this.envParser.mergeVariables(debugLaunchEnvVars, env);
1717

src/client/telemetry/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export enum EventName {
2828
PYTHON_INTERPRETER_ACTIVATION_ENVIRONMENT_VARIABLES = 'PYTHON_INTERPRETER_ACTIVATION_ENVIRONMENT_VARIABLES',
2929
PYTHON_INTERPRETER_ACTIVATION_FOR_RUNNING_CODE = 'PYTHON_INTERPRETER_ACTIVATION_FOR_RUNNING_CODE',
3030
PYTHON_INTERPRETER_ACTIVATION_FOR_TERMINAL = 'PYTHON_INTERPRETER_ACTIVATION_FOR_TERMINAL',
31+
ENVFILE_VARIABLE_SUBSTITUTION = 'ENVFILE_VARIABLE_SUBSTITUTION',
3132
WORKSPACE_SYMBOLS_BUILD = 'WORKSPACE_SYMBOLS.BUILD',
3233
WORKSPACE_SYMBOLS_GO_TO = 'WORKSPACE_SYMBOLS.GO_TO',
3334
EXECUTION_CODE = 'EXECUTION_CODE',

src/client/telemetry/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ interface IEventNamePropertyMapping {
253253
[EventName.DIAGNOSTICS_ACTION]: DiagnosticsAction;
254254
[EventName.DIAGNOSTICS_MESSAGE]: DiagnosticsMessages;
255255
[EventName.EDITOR_LOAD]: EditorLoadTelemetry;
256+
[EventName.ENVFILE_VARIABLE_SUBSTITUTION]: never | undefined;
256257
[EventName.EXECUTION_CODE]: CodeExecutionTelemetry;
257258
[EventName.EXECUTION_DJANGO]: CodeExecutionTelemetry;
258259
[EventName.FORMAT]: FormatTelemetry;

0 commit comments

Comments
 (0)