Skip to content

Commit 9d0d851

Browse files
Properly parse multi-digit Python "versions". (#14878)
The main focus of the PR is the helpers needed to fix the situation where the version for Anaconda is getting reported as "38.0.0". There isn't much information about the original failure, so more changes may be needed.
1 parent b3b1799 commit 9d0d851

File tree

6 files changed

+308
-64
lines changed

6 files changed

+308
-64
lines changed

src/client/pythonEnvironments/base/info/pythonVersion.ts

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

4-
import { cloneDeep } from 'lodash';
54
import * as path from 'path';
6-
import { PythonReleaseLevel, PythonVersion, UNKNOWN_PYTHON_VERSION } from '.';
75
import { traceError } from '../../../common/logger';
86
import {
97
EMPTY_VERSION,
108
isVersionInfoEmpty,
119
parseBasicVersionInfo,
1210
} from '../../../common/utils/version';
1311

12+
import {
13+
PythonReleaseLevel,
14+
PythonVersion,
15+
PythonVersionRelease,
16+
UNKNOWN_PYTHON_VERSION,
17+
} from '.';
18+
1419
export function getPythonVersionFromPath(exe:string): PythonVersion {
1520
let version = UNKNOWN_PYTHON_VERSION;
1621
try {
@@ -23,84 +28,100 @@ export function getPythonVersionFromPath(exe:string): PythonVersion {
2328

2429
/**
2530
* Convert the given string into the corresponding Python version object.
31+
*
2632
* Example:
2733
* 3.9.0
2834
* 3.9.0a1
2935
* 3.9.0b2
3036
* 3.9.0rc1
31-
*
32-
* Does not parse:
37+
* 3.9.0-beta2
38+
* 3.9.0.beta.2
3339
* 3.9.0.final.0
40+
* 39
3441
*/
3542
export function parseVersion(versionStr: string): PythonVersion {
36-
const parsed = parseBasicVersionInfo<PythonVersion>(versionStr);
37-
if (!parsed) {
38-
if (versionStr === '') {
39-
return EMPTY_VERSION as PythonVersion;
40-
}
41-
throw Error(`invalid version ${versionStr}`);
43+
const [version, after] = parseBasicVersion(versionStr);
44+
if (version.micro === -1) {
45+
return version;
4246
}
43-
const { version, after } = parsed;
44-
const match = after.match(/^(a|b|rc)(\d+)$/);
47+
const [release] = parseRelease(after);
48+
version.release = release;
49+
return version;
50+
}
51+
52+
export function parseRelease(text: string): [PythonVersionRelease | undefined, string] {
53+
let after: string;
54+
55+
let alpha: string | undefined;
56+
let beta: string | undefined;
57+
let rc: string | undefined;
58+
let fin: string | undefined;
59+
let serialStr: string;
60+
61+
let match = text.match(/^(?:-?final|\.final(?:\.0)?)(.*)$/);
4562
if (match) {
46-
const [, levelStr, serialStr] = match;
47-
let level: PythonReleaseLevel;
48-
if (levelStr === 'a') {
49-
level = PythonReleaseLevel.Alpha;
50-
} else if (levelStr === 'b') {
51-
level = PythonReleaseLevel.Beta;
52-
} else if (levelStr === 'rc') {
53-
level = PythonReleaseLevel.Candidate;
54-
} else {
55-
throw Error('unreachable!');
63+
[, after] = match;
64+
fin = 'final';
65+
serialStr = '0';
66+
} else {
67+
for (const regex of [
68+
/^(?:(a)|(b)|(rc))([1-9]\d*)(.*)$/,
69+
/^-(?:(?:(alpha)|(beta)|(candidate))([1-9]\d*))(.*)$/,
70+
/^\.(?:(?:(alpha)|(beta)|(candidate))\.([1-9]\d*))(.*)$/,
71+
]) {
72+
match = text.match(regex);
73+
if (match) {
74+
[, alpha, beta, rc, serialStr, after] = match;
75+
break;
76+
}
5677
}
57-
version.release = {
58-
level,
59-
serial: parseInt(serialStr, 10),
60-
};
6178
}
62-
return version;
79+
80+
let level: PythonReleaseLevel;
81+
if (fin) {
82+
level = PythonReleaseLevel.Final;
83+
} else if (rc) {
84+
level = PythonReleaseLevel.Candidate;
85+
} else if (beta) {
86+
level = PythonReleaseLevel.Beta;
87+
} else if (alpha) {
88+
level = PythonReleaseLevel.Alpha;
89+
} else {
90+
// We didn't find release info.
91+
return [undefined, text];
92+
}
93+
const serial = parseInt(serialStr!, 10);
94+
return [{ level, serial }, after!];
6395
}
6496

6597
/**
6698
* Convert the given string into the corresponding Python version object.
67-
* Example:
68-
* 3.9.0.final.0
69-
* 3.9.0.alpha.1
70-
* 3.9.0.beta.2
71-
* 3.9.0.candidate.1
72-
*
73-
* Does not parse:
74-
* 3.9.0
75-
* 3.9.0a1
76-
* 3.9.0b2
77-
* 3.9.0rc1
7899
*/
79-
export function parseVersionInfo(versionInfoStr: string): PythonVersion {
80-
const parts = versionInfoStr.split('.');
81-
const version = cloneDeep(UNKNOWN_PYTHON_VERSION);
82-
if (parts.length >= 2) {
83-
version.major = parseInt(parts[0], 10);
84-
version.minor = parseInt(parts[1], 10);
85-
}
86-
87-
if (parts.length >= 3) {
88-
version.micro = parseInt(parts[2], 10);
89-
}
90-
91-
if (parts.length >= 4 && version.release) {
92-
const levels = ['alpha', 'beta', 'candidate', 'final'];
93-
const level = parts[3].toLowerCase();
94-
if (levels.includes(level)) {
95-
version.release.level = level as PythonReleaseLevel;
100+
export function parseBasicVersion(versionStr: string): [PythonVersion, string] {
101+
// We set a prefix (which will be ignored) to make sure "plain"
102+
// versions are fully parsed.
103+
const parsed = parseBasicVersionInfo<PythonVersion>(`ignored-${versionStr}`);
104+
if (!parsed) {
105+
if (versionStr === '') {
106+
return [getEmptyVersion(), ''];
96107
}
108+
throw Error(`invalid version ${versionStr}`);
97109
}
110+
// We ignore any "before" text.
111+
const { version, after } = parsed;
112+
version.release = undefined;
98113

99-
if (parts.length >= 5 && version.release) {
100-
version.release.serial = parseInt(parts[4], 10);
114+
if (version.minor === -1) {
115+
// We trust that the major version is always single-digit.
116+
if (version.major > 9) {
117+
const numdigits = version.major.toString().length - 1;
118+
const factor = 10 ** numdigits;
119+
version.minor = version.major % factor;
120+
version.major = Math.floor(version.major / factor);
121+
}
101122
}
102123

103-
return version;
124+
return [version, after];
104125
}
105126

106127
/**

src/client/pythonEnvironments/discovery/locators/services/virtualEnvironmentIdentifier.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ import * as fsapi from 'fs-extra';
55
import * as path from 'path';
66
import '../../../../common/extensions';
77
import {
8-
getEnvironmentVariable, getOSType, getUserHomeDir, OSType
8+
getEnvironmentVariable, getOSType, getUserHomeDir, OSType,
99
} from '../../../../common/utils/platform';
1010
import { PythonVersion, UNKNOWN_PYTHON_VERSION } from '../../../base/info';
1111
import { comparePythonVersionSpecificity } from '../../../base/info/env';
12-
import { parseVersion, parseVersionInfo } from '../../../base/info/pythonVersion';
12+
import {
13+
parseBasicVersion,
14+
parseRelease,
15+
parseVersion,
16+
} from '../../../base/info/pythonVersion';
1317
import { pathExists, readFile } from '../../../common/externalDependencies';
1418

1519
function getPyvenvConfigPathsFrom(interpreterPath:string): string[] {
@@ -178,3 +182,32 @@ export async function getPythonVersionFromPyvenvCfg(interpreterPath:string): Pro
178182

179183
return version;
180184
}
185+
186+
/**
187+
* Convert the given string into the corresponding Python version object.
188+
* Example:
189+
* 3.9.0.final.0
190+
* 3.9.0.alpha.1
191+
* 3.9.0.beta.2
192+
* 3.9.0.candidate.1
193+
*
194+
* Does not parse:
195+
* 3.9.0
196+
* 3.9.0a1
197+
* 3.9.0b2
198+
* 3.9.0rc1
199+
*/
200+
function parseVersionInfo(versionInfoStr: string): PythonVersion {
201+
let version: PythonVersion;
202+
let after: string;
203+
try {
204+
[version, after] = parseBasicVersion(versionInfoStr);
205+
} catch {
206+
// XXX Use getEmptyVersion().
207+
return UNKNOWN_PYTHON_VERSION;
208+
}
209+
if (version.micro !== -1 && after.startsWith('.')) {
210+
[version.release] = parseRelease(after);
211+
}
212+
return version;
213+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as assert from 'assert';
5+
6+
import { PythonReleaseLevel, PythonVersion } from '../../../../client/pythonEnvironments/base/info';
7+
import {
8+
getEmptyVersion,
9+
parseVersion,
10+
} from '../../../../client/pythonEnvironments/base/info/pythonVersion';
11+
12+
export function ver(
13+
major: number,
14+
minor: number | undefined,
15+
micro: number | undefined,
16+
level?: string,
17+
serial?: number,
18+
): PythonVersion {
19+
const version: PythonVersion = {
20+
major,
21+
minor: minor === undefined ? -1 : minor,
22+
micro: micro === undefined ? -1 : micro,
23+
release: undefined,
24+
};
25+
if (level !== undefined) {
26+
version.release = {
27+
serial: serial!,
28+
level: level as PythonReleaseLevel,
29+
};
30+
}
31+
return version;
32+
}
33+
34+
const VERSION_STRINGS: [string, PythonVersion][] = [
35+
['0.9.2b2', ver(0, 9, 2, 'beta', 2)],
36+
['3.3.1', ver(3, 3, 1)], // final
37+
['3.9.0rc1', ver(3, 9, 0, 'candidate', 1)],
38+
['2.7.11a3', ver(2, 7, 11, 'alpha', 3)],
39+
];
40+
41+
suite('pyenvs info - parseVersion', () => {
42+
suite('full versions (short)', () => {
43+
VERSION_STRINGS.forEach((data) => {
44+
const [text, expected] = data;
45+
test(`conversion works for '${text}'`, () => {
46+
const result = parseVersion(text);
47+
48+
assert.deepEqual(result, expected);
49+
});
50+
});
51+
});
52+
53+
suite('full versions (long)', () => {
54+
[
55+
['0.9.2-beta2', ver(0, 9, 2, 'beta', 2)],
56+
['3.3.1-final', ver(3, 3, 1, 'final', 0)],
57+
['3.3.1-final0', ver(3, 3, 1, 'final', 0)],
58+
['3.9.0-candidate1', ver(3, 9, 0, 'candidate', 1)],
59+
['2.7.11-alpha3', ver(2, 7, 11, 'alpha', 3)],
60+
['0.9.2.beta.2', ver(0, 9, 2, 'beta', 2)],
61+
['3.3.1.final.0', ver(3, 3, 1, 'final', 0)],
62+
['3.9.0.candidate.1', ver(3, 9, 0, 'candidate', 1)],
63+
['2.7.11.alpha.3', ver(2, 7, 11, 'alpha', 3)],
64+
].forEach((data) => {
65+
const [text, expected] = data as [string, PythonVersion];
66+
test(`conversion works for '${text}'`, () => {
67+
const result = parseVersion(text);
68+
69+
assert.deepEqual(result, expected);
70+
});
71+
});
72+
});
73+
74+
suite('partial versions', () => {
75+
[
76+
['3.7.1', ver(3, 7, 1)],
77+
['3.7', ver(3, 7, -1)],
78+
['3', ver(3, -1, -1)],
79+
['37', ver(3, 7, -1)], // not 37
80+
['371', ver(3, 71, -1)], // not 3.7.1
81+
['3102', ver(3, 102, -1)], // not 3.10.2
82+
['2.7', ver(2, 7, -1)],
83+
['2', ver(2, -1, -1)], // not 2.7
84+
['27', ver(2, 7, -1)],
85+
].forEach((data) => {
86+
const [text, expected] = data as [string, PythonVersion];
87+
test(`conversion works for '${text}'`, () => {
88+
const result = parseVersion(text);
89+
90+
assert.deepEqual(result, expected);
91+
});
92+
});
93+
});
94+
95+
suite('other forms', () => {
96+
[
97+
// prefixes
98+
['python3', ver(3, -1, -1)],
99+
['python3.8', ver(3, 8, -1)],
100+
['python3.8.1', ver(3, 8, 1)],
101+
['python3.8.1b2', ver(3, 8, 1, 'beta', 2)],
102+
['python-3', ver(3, -1, -1)],
103+
// release ignored (missing micro)
104+
['python3.8b2', ver(3, 8, -1)],
105+
['python38b2', ver(3, 8, -1)],
106+
['python381b2', ver(3, 81, -1)], // not 3.8.1
107+
// suffixes
108+
['python3.exe', ver(3, -1, -1)],
109+
['python3.8.exe', ver(3, 8, -1)],
110+
['python3.8.1.exe', ver(3, 8, 1)],
111+
['python3.8.1b2.exe', ver(3, 8, 1, 'beta', 2)],
112+
['3.8.1.build123.revDEADBEEF', ver(3, 8, 1)],
113+
['3.8.1b2.build123.revDEADBEEF', ver(3, 8, 1, 'beta', 2)],
114+
// dirnames
115+
['/x/y/z/python38/bin/python', ver(3, 8, -1)],
116+
['/x/y/z/python/38/bin/python', ver(3, 8, -1)],
117+
['/x/y/z/python/38/bin/python', ver(3, 8, -1)],
118+
].forEach((data) => {
119+
const [text, expected] = data as [string, PythonVersion];
120+
test(`conversion works for '${text}'`, () => {
121+
const result = parseVersion(text);
122+
123+
assert.deepEqual(result, expected);
124+
});
125+
});
126+
});
127+
128+
test('empty string results in empty version', () => {
129+
const expected = getEmptyVersion();
130+
131+
const result = parseVersion('');
132+
133+
assert.deepEqual(result, expected);
134+
});
135+
136+
suite('bogus input', () => {
137+
[
138+
// errant dots
139+
'py.3.7',
140+
'py3.7.',
141+
'python.3',
142+
// no version
143+
'spam',
144+
'python.exe',
145+
'python',
146+
].forEach((text) => {
147+
test(`conversion does not work for '${text}'`, () => {
148+
assert.throws(() => parseVersion(text));
149+
});
150+
});
151+
});
152+
});

0 commit comments

Comments
 (0)