Skip to content

Commit 46723ff

Browse files
authored
Address conda environment parsing issues (#11103)
* Address conda environment parsing issues * Address comments * Clean up
1 parent eccc9fc commit 46723ff

File tree

4 files changed

+177
-90
lines changed

4 files changed

+177
-90
lines changed

news/2 Fixes/10942.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix issue with parsing long conda environment names.

src/client/interpreter/locators/services/condaHelper.ts

Lines changed: 124 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -11,81 +11,137 @@ export type EnvironmentName = string;
1111
/**
1212
* Helpers for conda.
1313
*/
14-
export class CondaHelper {
15-
/**
16-
* Return the string to display for the conda interpreter.
17-
*/
18-
public getDisplayName(condaInfo: CondaInfo = {}): string {
19-
// Samples.
20-
// "3.6.1 |Anaconda 4.4.0 (64-bit)| (default, May 11 2017, 13:25:24) [MSC v.1900 64 bit (AMD64)]".
21-
// "3.6.2 |Anaconda, Inc.| (default, Sep 21 2017, 18:29:43) \n[GCC 4.2.1 Compatible Clang 4.0.1 (tags/RELEASE_401/final)]".
22-
const sysVersion = condaInfo['sys.version'];
23-
if (!sysVersion) {
24-
return AnacondaDisplayName;
25-
}
2614

27-
// Take the second part of the sys.version.
28-
const sysVersionParts = sysVersion.split('|', 2);
29-
if (sysVersionParts.length === 2) {
30-
const displayName = sysVersionParts[1].trim();
31-
if (this.isIdentifiableAsAnaconda(displayName)) {
32-
return displayName;
33-
} else {
34-
return `${displayName} : ${AnacondaDisplayName}`;
35-
}
15+
/**
16+
* Return the string to display for the conda interpreter.
17+
*/
18+
export function getDisplayName(condaInfo: CondaInfo = {}): string {
19+
// Samples.
20+
// "3.6.1 |Anaconda 4.4.0 (64-bit)| (default, May 11 2017, 13:25:24) [MSC v.1900 64 bit (AMD64)]".
21+
// "3.6.2 |Anaconda, Inc.| (default, Sep 21 2017, 18:29:43) \n[GCC 4.2.1 Compatible Clang 4.0.1 (tags/RELEASE_401/final)]".
22+
const sysVersion = condaInfo['sys.version'];
23+
if (!sysVersion) {
24+
return AnacondaDisplayName;
25+
}
26+
27+
// Take the second part of the sys.version.
28+
const sysVersionParts = sysVersion.split('|', 2);
29+
if (sysVersionParts.length === 2) {
30+
const displayName = sysVersionParts[1].trim();
31+
if (isIdentifiableAsAnaconda(displayName)) {
32+
return displayName;
3633
} else {
37-
return AnacondaDisplayName;
34+
return `${displayName} : ${AnacondaDisplayName}`;
3835
}
36+
} else {
37+
return AnacondaDisplayName;
3938
}
39+
}
4040

41-
/**
42-
* Parses output returned by the command `conda env list`.
43-
* Sample output is as follows:
44-
* # conda environments:
45-
* #
46-
* base * /Users/donjayamanne/anaconda3
47-
* one /Users/donjayamanne/anaconda3/envs/one
48-
* one two /Users/donjayamanne/anaconda3/envs/one two
49-
* py27 /Users/donjayamanne/anaconda3/envs/py27
50-
* py36 /Users/donjayamanne/anaconda3/envs/py36
51-
* three /Users/donjayamanne/anaconda3/envs/three
52-
* /Users/donjayamanne/anaconda3/envs/four
53-
* /Users/donjayamanne/anaconda3/envs/five 5
54-
* @param {string} condaEnvironmentList
55-
* @param {CondaInfo} condaInfo
56-
* @returns {{ name: string, path: string }[] | undefined}
57-
* @memberof CondaHelper
58-
*/
59-
public parseCondaEnvironmentNames(condaEnvironmentList: string): { name: string; path: string }[] | undefined {
60-
const environments = condaEnvironmentList.splitLines({ trim: false });
61-
const baseEnvironmentLine = environments.filter((line) => line.indexOf('*') > 0);
62-
if (baseEnvironmentLine.length === 0) {
63-
return;
41+
/**
42+
* Parses output returned by the command `conda env list`.
43+
* Sample output is as follows:
44+
* # conda environments:
45+
* #
46+
* base * /Users/donjayamanne/anaconda3
47+
* one /Users/donjayamanne/anaconda3/envs/one
48+
* py27 /Users/donjayamanne/anaconda3/envs/py27
49+
* py36 /Users/donjayamanne/anaconda3/envs/py36
50+
* three /Users/donjayamanne/anaconda3/envs/three
51+
* /Users/donjayamanne/anaconda3/envs/four
52+
* /Users/donjayamanne/anaconda3/envs/five 5
53+
* aaaa_bbbb_cccc_dddd_eeee_ffff_gggg /Users/donjayamanne/anaconda3/envs/aaaa_bbbb_cccc_dddd_eeee_ffff_gggg
54+
* with*star /Users/donjayamanne/anaconda3/envs/with*star
55+
* "/Users/donjayamanne/anaconda3/envs/seven "
56+
*/
57+
export function parseCondaEnvFileContents(
58+
condaEnvFileContents: string
59+
): { name: string; path: string; isActive: boolean }[] | undefined {
60+
// Don't trim the lines. `path` portion of the line can end with a space.
61+
const lines = condaEnvFileContents.splitLines({ trim: false });
62+
const envs: { name: string; path: string; isActive: boolean }[] = [];
63+
64+
lines.forEach((line) => {
65+
const item = parseCondaEnvFileLine(line);
66+
if (item) {
67+
envs.push(item);
6468
}
65-
const pathStartIndex = baseEnvironmentLine[0].indexOf(baseEnvironmentLine[0].split('*')[1].trim());
66-
const envs: { name: string; path: string }[] = [];
67-
environments.forEach((line) => {
68-
if (line.length <= pathStartIndex) {
69-
return;
70-
}
71-
let name = line.substring(0, pathStartIndex).trim();
72-
if (name.endsWith('*')) {
73-
name = name.substring(0, name.length - 1).trim();
74-
}
75-
const envPath = line.substring(pathStartIndex).trim();
76-
if (envPath.length > 0) {
77-
envs.push({ name, path: envPath });
78-
}
79-
});
80-
81-
return envs;
69+
});
70+
71+
return envs.length > 0 ? envs : undefined;
72+
}
73+
74+
function parseCondaEnvFileLine(line: string): { name: string; path: string; isActive: boolean } | undefined {
75+
// Empty lines or lines starting with `#` are comments and can be ignored.
76+
if (line.length === 0 || line.startsWith('#')) {
77+
return undefined;
78+
}
79+
80+
// This extraction is based on the following code for `conda env list`:
81+
// https://github.com/conda/conda/blob/f207a2114c388fd17644ee3a5f980aa7cf86b04b/conda/cli/common.py#L188
82+
// It uses "%-20s %s %s" as the format string. Where the middle %s is '*'
83+
// if the environment is active, and ' ' if it is not active.
84+
85+
// If conda environment was created using `-p` then it may NOT have a name.
86+
// Use empty string as default name for envs created using path only.
87+
let name = '';
88+
let remainder = line;
89+
90+
// The `name` and `path` parts are separated by at least 5 spaces. We cannot
91+
// use a single space here since it can be part of the name (see below for
92+
// name spec). Another assumption here is that `name` does not start with
93+
// 5*spaces or somewhere in the center. However, ` name` or `a b` is
94+
// a valid name when using --clone. Highly unlikely that users will have this
95+
// form as the environment name. lastIndexOf() can also be used but that assumes
96+
// that `path` does NOT end with 5*spaces.
97+
let spaceIndex = line.indexOf(' ');
98+
if (spaceIndex === -1) {
99+
// This means the environment name is longer than 17 characters and it is
100+
// active. Try ' * ' for separator between name and path.
101+
spaceIndex = line.indexOf(' * ');
82102
}
83103

84-
/**
85-
* Does the given string match a known Anaconda identifier.
86-
*/
87-
private isIdentifiableAsAnaconda(value: string) {
88-
const valueToSearch = value.toLowerCase();
89-
return AnacondaIdentifiers.some((item) => valueToSearch.indexOf(item.toLowerCase()) !== -1);
104+
if (spaceIndex > 0) {
105+
// Parsing `name`
106+
// > `conda create -n <name>`
107+
// conda environment `name` should NOT have following characters
108+
// ('/', ' ', ':', '#'). So we can use the index of 5*space
109+
// characters to extract the name.
110+
//
111+
// > `conda create --clone one -p "~/envs/one two"`
112+
// this can generate a cloned env with name `one two`. This is
113+
// only allowed for cloned environments. In both cases, the best
114+
// separator is 5*spaces. It is highly unlikely that users will have
115+
// 5*spaces in their environment name.
116+
//
117+
// Notes: When using clone if the path has a trailing space, it will
118+
// not be preserved for the name. Trailing spaces in environment names
119+
// are NOT allowed. But leading spaces are allowed. Currently there no
120+
// special separator character between name and path, other than spaces.
121+
// We will need a well known separator if this ever becomes a issue.
122+
name = line.substring(0, spaceIndex).trimRight();
123+
remainder = line.substring(spaceIndex);
90124
}
125+
126+
// Detecting Active Environment:
127+
// Only active environment will have `*` between `name` and `path`. `name`
128+
// or `path` can have `*` in them as well. So we have to look for `*` in
129+
// between `name` and `path`. We already extracted the name, the next non-
130+
// whitespace character should either be `*` or environment path.
131+
remainder = remainder.trimLeft();
132+
const isActive = remainder.startsWith('*');
133+
134+
// Parsing `path`
135+
// If `*` is the first then we can skip that character. Trim left again,
136+
// don't do trim() or trimRight(), since paths can end with a space.
137+
remainder = (isActive ? remainder.substring(1) : remainder).trimLeft();
138+
139+
return { name, path: remainder, isActive };
140+
}
141+
/**
142+
* Does the given string match a known Anaconda identifier.
143+
*/
144+
function isIdentifiableAsAnaconda(value: string) {
145+
const valueToSearch = value.toLowerCase();
146+
return AnacondaIdentifiers.some((item) => valueToSearch.indexOf(item.toLowerCase()) !== -1);
91147
}

src/client/interpreter/locators/services/condaService.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
PythonInterpreter,
1919
WINDOWS_REGISTRY_SERVICE
2020
} from '../../contracts';
21-
import { CondaHelper } from './condaHelper';
21+
import { parseCondaEnvFileContents } from './condaHelper';
2222

2323
// tslint:disable-next-line:no-require-imports no-var-requires
2424
const untildify: (value: string) => string = require('untildify');
@@ -58,7 +58,6 @@ export const CondaGetEnvironmentPrefix = 'Outputting Environment Now...';
5858
export class CondaService implements ICondaService {
5959
private condaFile?: Promise<string | undefined>;
6060
private isAvailable: boolean | undefined;
61-
private readonly condaHelper = new CondaHelper();
6261

6362
constructor(
6463
@inject(IProcessServiceFactory) private processServiceFactory: IProcessServiceFactory,
@@ -260,7 +259,7 @@ export class CondaService implements ICondaService {
260259
.exec(condaFile, ['env', 'list'], { env: newEnv })
261260
.then((output) => output.stdout);
262261
}
263-
const environments = this.condaHelper.parseCondaEnvironmentNames(envInfo);
262+
const environments = parseCondaEnvFileContents(envInfo);
264263
await globalPersistence.updateValue({ data: environments });
265264
return environments;
266265
} catch (ex) {

src/test/interpreters/condaHelper.unit.test.ts

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,27 @@ import * as assert from 'assert';
22
import { expect } from 'chai';
33
import { CondaInfo } from '../../client/interpreter/contracts';
44
import { AnacondaDisplayName } from '../../client/interpreter/locators/services/conda';
5-
import { CondaHelper } from '../../client/interpreter/locators/services/condaHelper';
5+
import { getDisplayName, parseCondaEnvFileContents } from '../../client/interpreter/locators/services/condaHelper';
66

77
// tslint:disable-next-line:max-func-body-length
88
suite('Interpreters display name from Conda Environments', () => {
9-
const condaHelper = new CondaHelper();
109
test('Must return default display name for invalid Conda Info', () => {
11-
assert.equal(condaHelper.getDisplayName(), AnacondaDisplayName, 'Incorrect display name');
12-
assert.equal(condaHelper.getDisplayName({}), AnacondaDisplayName, 'Incorrect display name');
10+
assert.equal(getDisplayName(), AnacondaDisplayName, 'Incorrect display name');
11+
assert.equal(getDisplayName({}), AnacondaDisplayName, 'Incorrect display name');
1312
});
1413
test('Must return at least Python Version', () => {
1514
const info: CondaInfo = {
1615
python_version: '3.6.1.final.10'
1716
};
18-
const displayName = condaHelper.getDisplayName(info);
17+
const displayName = getDisplayName(info);
1918
assert.equal(displayName, AnacondaDisplayName, 'Incorrect display name');
2019
});
2120
test('Must return info without first part if not a python version', () => {
2221
const info: CondaInfo = {
2322
'sys.version':
2423
'3.6.1 |Anaconda 4.4.0 (64-bit)| (default, May 11 2017, 13:25:24) [MSC v.1900 64 bit (AMD64)]'
2524
};
26-
const displayName = condaHelper.getDisplayName(info);
25+
const displayName = getDisplayName(info);
2726
assert.equal(displayName, 'Anaconda 4.4.0 (64-bit)', 'Incorrect display name');
2827
});
2928
test("Must return info without prefixing with word 'Python'", () => {
@@ -32,15 +31,15 @@ suite('Interpreters display name from Conda Environments', () => {
3231
'sys.version':
3332
'3.6.1 |Anaconda 4.4.0 (64-bit)| (default, May 11 2017, 13:25:24) [MSC v.1900 64 bit (AMD64)]'
3433
};
35-
const displayName = condaHelper.getDisplayName(info);
34+
const displayName = getDisplayName(info);
3635
assert.equal(displayName, 'Anaconda 4.4.0 (64-bit)', 'Incorrect display name');
3736
});
3837
test('Must include Ananconda name if Company name not found', () => {
3938
const info: CondaInfo = {
4039
python_version: '3.6.1.final.10',
4140
'sys.version': '3.6.1 |4.4.0 (64-bit)| (default, May 11 2017, 13:25:24) [MSC v.1900 64 bit (AMD64)]'
4241
};
43-
const displayName = condaHelper.getDisplayName(info);
42+
const displayName = getDisplayName(info);
4443
assert.equal(displayName, `4.4.0 (64-bit) : ${AnacondaDisplayName}`, 'Incorrect display name');
4544
});
4645
test('Parse conda environments', () => {
@@ -49,22 +48,54 @@ suite('Interpreters display name from Conda Environments', () => {
4948
# conda environments:
5049
#
5150
base * /Users/donjayamanne/anaconda3
52-
one1 /Users/donjayamanne/anaconda3/envs/one
53-
two2 2 /Users/donjayamanne/anaconda3/envs/two 2
54-
three3 /Users/donjayamanne/anaconda3/envs/three
51+
* /Users/donjayamanne/anaconda3
52+
one /Users/donjayamanne/anaconda3/envs/one
53+
one /Users/donjayamanne/anaconda3/envs/ one
54+
one two /Users/donjayamanne/anaconda3/envs/one two
55+
three /Users/donjayamanne/anaconda3/envs/three
5556
/Users/donjayamanne/anaconda3/envs/four
56-
/Users/donjayamanne/anaconda3/envs/five 5`;
57+
/Users/donjayamanne/anaconda3/envs/five six
58+
aaaa_bbbb_cccc_dddd_eeee_ffff_gggg /Users/donjayamanne/anaconda3/envs/aaaa_bbbb_cccc_dddd_eeee_ffff_gggg
59+
aaaa_bbbb_cccc_dddd_eeee_ffff_gggg * /Users/donjayamanne/anaconda3/envs/aaaa_bbbb_cccc_dddd_eeee_ffff_gggg
60+
with*star /Users/donjayamanne/anaconda3/envs/with*star
61+
with*one*two*three*four*five*six*seven* /Users/donjayamanne/anaconda3/envs/with*one*two*three*four*five*six*seven*
62+
with*one*two*three*four*five*six*seven* * /Users/donjayamanne/anaconda3/envs/with*one*two*three*four*five*six*seven*
63+
/Users/donjayamanne/anaconda3/envs/seven `; // note the space after seven
5764

5865
const expectedList = [
59-
{ name: 'base', path: '/Users/donjayamanne/anaconda3' },
60-
{ name: 'one1', path: '/Users/donjayamanne/anaconda3/envs/one' },
61-
{ name: 'two2 2', path: '/Users/donjayamanne/anaconda3/envs/two 2' },
62-
{ name: 'three3', path: '/Users/donjayamanne/anaconda3/envs/three' },
63-
{ name: '', path: '/Users/donjayamanne/anaconda3/envs/four' },
64-
{ name: '', path: '/Users/donjayamanne/anaconda3/envs/five 5' }
66+
{ name: 'base', path: '/Users/donjayamanne/anaconda3', isActive: true },
67+
{ name: '', path: '/Users/donjayamanne/anaconda3', isActive: true },
68+
{ name: 'one', path: '/Users/donjayamanne/anaconda3/envs/one', isActive: false },
69+
{ name: ' one', path: '/Users/donjayamanne/anaconda3/envs/ one', isActive: false },
70+
{ name: 'one two', path: '/Users/donjayamanne/anaconda3/envs/one two', isActive: false },
71+
{ name: 'three', path: '/Users/donjayamanne/anaconda3/envs/three', isActive: false },
72+
{ name: '', path: '/Users/donjayamanne/anaconda3/envs/four', isActive: false },
73+
{ name: '', path: '/Users/donjayamanne/anaconda3/envs/five six', isActive: false },
74+
{
75+
name: 'aaaa_bbbb_cccc_dddd_eeee_ffff_gggg',
76+
path: '/Users/donjayamanne/anaconda3/envs/aaaa_bbbb_cccc_dddd_eeee_ffff_gggg',
77+
isActive: false
78+
},
79+
{
80+
name: 'aaaa_bbbb_cccc_dddd_eeee_ffff_gggg',
81+
path: '/Users/donjayamanne/anaconda3/envs/aaaa_bbbb_cccc_dddd_eeee_ffff_gggg',
82+
isActive: true
83+
},
84+
{ name: 'with*star', path: '/Users/donjayamanne/anaconda3/envs/with*star', isActive: false },
85+
{
86+
name: 'with*one*two*three*four*five*six*seven*',
87+
path: '/Users/donjayamanne/anaconda3/envs/with*one*two*three*four*five*six*seven*',
88+
isActive: false
89+
},
90+
{
91+
name: 'with*one*two*three*four*five*six*seven*',
92+
path: '/Users/donjayamanne/anaconda3/envs/with*one*two*three*four*five*six*seven*',
93+
isActive: true
94+
},
95+
{ name: '', path: '/Users/donjayamanne/anaconda3/envs/seven ', isActive: false }
6596
];
6697

67-
const list = condaHelper.parseCondaEnvironmentNames(environments);
98+
const list = parseCondaEnvFileContents(environments);
6899
expect(list).deep.equal(expectedList);
69100
});
70101
});

0 commit comments

Comments
 (0)