Skip to content

Commit 1e5884f

Browse files
committed
Address conda environment parsing issues
1 parent 5c106fa commit 1e5884f

File tree

4 files changed

+147
-89
lines changed

4 files changed

+147
-89
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: 112 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -11,81 +11,126 @@ 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+
}
40+
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; isBase: 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; isBase: boolean }[] = [];
4063

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;
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-
});
69+
});
8070

81-
return envs;
71+
return envs.length > 0 ? envs : undefined;
72+
}
73+
74+
function parseCondaEnvFileLine(line: string): { name: string; path: string; isBase: 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;
8278
}
8379

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);
80+
// If conda environment was created using `-p` then it may NOT have a name.
81+
// Use empty string as default name for envs created using path only.
82+
let name = '';
83+
let remainder = line;
84+
85+
// The `name` and `path` parts are separated by at least 5 spaces. We cannot
86+
// use a single space here since it can be part of the name (see below for
87+
// name spec). Another assumption here is that `name` does not start with
88+
// 5*spaces or somewhere in the center. However, ` name` or `a b` is
89+
// a valid name when using --clone. Highly unlikely that users will have this
90+
// form as the environment name. lastIndexOf() can also be used but that assumes
91+
// that `path` does NOT end with 5*spaces.
92+
const spaceIndex = line.indexOf(' ');
93+
if (spaceIndex > 0) {
94+
// Parsing `name`
95+
// > `conda create -n <name>`
96+
// conda environment `name` should NOT have following characters
97+
// ('/', ' ', ':', '#'). So we can use the index of 5*space
98+
// characters to extract the name.
99+
//
100+
// > `conda create --clone one -p "~/envs/one two"`
101+
// this can generate a cloned env with name `one two`. This is
102+
// only allowed for cloned environments. In both cases, the best
103+
// separator is 5*spaces. It is highly unlikely that users will have
104+
// 5*spaces in their environment name.
105+
//
106+
// Notes: When using clone if the path has a trailing space, it will
107+
// not be preserved for the name. Trailing spaces in environment names
108+
// are NOT allowed. But leading spaces are allowed. Currently there no
109+
// special separator character between name and path, other than spaces.
110+
// We will need a well known separator if this ever becomes a issue.
111+
name = line.substring(0, spaceIndex).trimRight();
112+
remainder = line.substring(spaceIndex);
90113
}
114+
115+
// Detecting Base:
116+
// Only `base` environment will have `*` between `name` and `path`. `name`
117+
// or `path` can have `*` in them as well. So we have to look for `*` in
118+
// between `name` and `path`. We already extracted the name, the next non-
119+
// whitespace character should either be `*` or environment path.
120+
remainder = remainder.trimLeft();
121+
const isBase = remainder.startsWith('*');
122+
123+
// Parsing `path`
124+
// If `*` is the first then we can skip that character. Trim left again,
125+
// don't do trim() or trimRight(), since paths can end with a space.
126+
remainder = (isBase ? remainder.substring(1) : remainder).trimLeft();
127+
128+
return { name, path: remainder, isBase };
129+
}
130+
/**
131+
* Does the given string match a known Anaconda identifier.
132+
*/
133+
function isIdentifiableAsAnaconda(value: string) {
134+
const valueToSearch = value.toLowerCase();
135+
return AnacondaIdentifiers.some((item) => valueToSearch.indexOf(item.toLowerCase()) !== -1);
91136
}

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: 32 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,36 @@ 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+
with*star /Users/donjayamanne/anaconda3/envs/with*star
60+
/Users/donjayamanne/anaconda3/envs/seven `; // note the space after seven
5761

5862
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' }
63+
{ name: 'base', path: '/Users/donjayamanne/anaconda3', isBase: true },
64+
{ name: '', path: '/Users/donjayamanne/anaconda3', isBase: true },
65+
{ name: 'one', path: '/Users/donjayamanne/anaconda3/envs/one', isBase: false },
66+
{ name: ' one', path: '/Users/donjayamanne/anaconda3/envs/ one', isBase: false },
67+
{ name: 'one two', path: '/Users/donjayamanne/anaconda3/envs/one two', isBase: false },
68+
{ name: 'three', path: '/Users/donjayamanne/anaconda3/envs/three', isBase: false },
69+
{ name: '', path: '/Users/donjayamanne/anaconda3/envs/four', isBase: false },
70+
{ name: '', path: '/Users/donjayamanne/anaconda3/envs/five six', isBase: false },
71+
{
72+
name: 'aaaa_bbbb_cccc_dddd_eeee_ffff_gggg',
73+
path: '/Users/donjayamanne/anaconda3/envs/aaaa_bbbb_cccc_dddd_eeee_ffff_gggg',
74+
isBase: false
75+
},
76+
{ name: 'with*star', path: '/Users/donjayamanne/anaconda3/envs/with*star', isBase: false },
77+
{ name: '', path: '/Users/donjayamanne/anaconda3/envs/seven ', isBase: false }
6578
];
6679

67-
const list = condaHelper.parseCondaEnvironmentNames(environments);
80+
const list = parseCondaEnvFileContents(environments);
6881
expect(list).deep.equal(expectedList);
6982
});
7083
});

0 commit comments

Comments
 (0)