Skip to content

Commit 588130b

Browse files
committed
feat(pypi): improve resolving suitable python version
1 parent f8ce876 commit 588130b

File tree

9 files changed

+229
-16
lines changed

9 files changed

+229
-16
lines changed

lua/mason-core/installer/managers/pypi.lua

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,20 @@ local a = require "mason-core.async"
55
local installer = require "mason-core.installer"
66
local log = require "mason-core.log"
77
local path = require "mason-core.path"
8+
local pep440 = require "mason-core.pep440"
89
local platform = require "mason-core.platform"
10+
local providers = require "mason-core.providers"
911
local semver = require "mason-core.semver"
1012
local spawn = require "mason-core.spawn"
1113

1214
local M = {}
1315

1416
local VENV_DIR = "venv"
1517

16-
local is_executable = _.compose(_.equals(1), vim.fn.executable)
17-
1818
---@async
1919
---@param candidates string[]
2020
local function resolve_python3(candidates)
21+
local is_executable = _.compose(_.equals(1), vim.fn.executable)
2122
a.scheduler()
2223
local available_candidates = _.filter(is_executable, candidates)
2324
for __, candidate in ipairs(available_candidates) do
@@ -31,16 +32,33 @@ local function resolve_python3(candidates)
3132
return nil
3233
end
3334

34-
---@param min_version? Semver
35-
local function get_versioned_candidates(min_version)
35+
---@param version string
36+
---@param specifiers string
37+
local function pep440_check_version(version, specifiers)
38+
-- The version check only implements a subset of the PEP440 specification and may error with certain inputs.
39+
local ok, result = pcall(pep440.check_version, version, specifiers)
40+
if not ok then
41+
log.fmt_warn(
42+
"Failed to check version compatibility for version %s with specifiers %s: %s",
43+
version,
44+
specifiers,
45+
result
46+
)
47+
return false
48+
end
49+
return result
50+
end
51+
52+
---@param supported_python_versions string
53+
local function get_versioned_candidates(supported_python_versions)
3654
return _.filter_map(function(pair)
3755
local version, executable = unpack(pair)
38-
if not min_version or version > min_version then
39-
return Optional.of(executable)
40-
else
56+
if not pep440_check_version(tostring(version), supported_python_versions) then
4157
return Optional.empty()
4258
end
59+
return Optional.of(executable)
4360
end, {
61+
{ semver.new "3.12.0", "python3.12" },
4462
{ semver.new "3.11.0", "python3.11" },
4563
{ semver.new "3.10.0", "python3.10" },
4664
{ semver.new "3.9.0", "python3.9" },
@@ -51,16 +69,27 @@ local function get_versioned_candidates(min_version)
5169
end
5270

5371
---@async
54-
local function create_venv()
72+
---@param pkg { name: string, version: string }
73+
local function create_venv(pkg)
74+
local ctx = installer.context()
75+
---@type string?
76+
local supported_python_versions = providers.pypi.get_supported_python_versions(pkg.name, pkg.version):get_or_nil()
77+
78+
-- 1. Resolve stock python3 installation.
5579
local stock_candidates = platform.is.win and { "python", "python3" } or { "python3", "python" }
5680
local stock_target = resolve_python3(stock_candidates)
5781
if stock_target then
5882
log.fmt_debug("Resolved stock python3 installation version %s", stock_target.version)
5983
end
60-
local versioned_candidates = get_versioned_candidates(stock_target and stock_target.version)
61-
log.debug("Resolving versioned python3 candidates", versioned_candidates)
84+
85+
-- 2. Resolve suitable versioned python3 installation (python3.12, python3.11, etc.).
86+
local versioned_candidates = {}
87+
if supported_python_versions ~= nil then
88+
log.fmt_debug("Finding versioned candidates for %s", supported_python_versions)
89+
versioned_candidates = get_versioned_candidates(supported_python_versions)
90+
end
6291
local target = resolve_python3(versioned_candidates) or stock_target
63-
local ctx = installer.context()
92+
6493
if not target then
6594
ctx.stdio_sink.stderr(
6695
("Unable to find python3 installation. Tried the following candidates: %s.\n"):format(
@@ -69,6 +98,22 @@ local function create_venv()
6998
)
7099
return Result.failure "Failed to find python3 installation."
71100
end
101+
102+
-- 3. If a versioned python3 installation was not found, warn the user if the stock python3 installation is outside
103+
-- the supported version range.
104+
if
105+
target == stock_target
106+
and supported_python_versions ~= nil
107+
and not pep440_check_version(tostring(target.version), supported_python_versions)
108+
then
109+
ctx.stdio_sink.stderr(
110+
("Warning: The resolved Python version %s is not compatible with the required Python versions: %s.\n"):format(
111+
target.version,
112+
supported_python_versions
113+
)
114+
)
115+
end
116+
72117
log.fmt_debug("Found python3 installation version=%s, executable=%s", target.version, target.executable)
73118
ctx.stdio_sink.stdout "Creating virtual environment…\n"
74119
return ctx.spawn[target.executable] { "-m", "venv", VENV_DIR }
@@ -118,15 +163,15 @@ local function pip_install(pkgs, extra_args)
118163
end
119164

120165
---@async
121-
---@param opts { upgrade_pip: boolean, install_extra_args?: string[] }
166+
---@param opts { package: { name: string, version: string }, upgrade_pip: boolean, install_extra_args?: string[] }
122167
function M.init(opts)
123168
return Result.try(function(try)
124169
log.fmt_debug("pypi: init", opts)
125170
local ctx = installer.context()
126171

127172
-- pip3 will hardcode the full path to venv executables, so we need to promote cwd to make sure pip uses the final destination path.
128173
ctx:promote_cwd()
129-
try(create_venv())
174+
try(create_venv(opts.package))
130175

131176
if opts.upgrade_pip then
132177
ctx.stdio_sink.stdout "Upgrading pip inside the virtual environment…\n"

lua/mason-core/installer/registry/providers/pypi.lua

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function M.parse(source, purl)
2121
---@class ParsedPypiSource : ParsedPackageSource
2222
local parsed_source = {
2323
package = purl.name,
24-
version = purl.version,
24+
version = purl.version --[[ @as string ]],
2525
extra = _.path({ "qualifiers", "extra" }, purl),
2626
extra_packages = source.extra_packages,
2727
pip = {
@@ -42,6 +42,10 @@ function M.install(ctx, source)
4242

4343
return Result.try(function(try)
4444
try(pypi.init {
45+
package = {
46+
name = source.package,
47+
version = source.version,
48+
},
4549
upgrade_pip = source.pip.upgrade,
4650
install_extra_args = source.pip.extra_args,
4751
})

lua/mason-core/pep440/init.lua

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
-- Function to split a version string into its components
2+
local function split_version(version)
3+
local parts = {}
4+
for part in version:gmatch "[^.]+" do
5+
table.insert(parts, tonumber(part) or part)
6+
end
7+
return parts
8+
end
9+
10+
-- Function to compare two versions
11+
local function compare_versions(version1, version2)
12+
local v1_parts = split_version(version1)
13+
local v2_parts = split_version(version2)
14+
local len = math.max(#v1_parts, #v2_parts)
15+
16+
for i = 1, len do
17+
local v1_part = v1_parts[i] or 0
18+
local v2_part = v2_parts[i] or 0
19+
20+
if v1_part < v2_part then
21+
return -1
22+
elseif v1_part > v2_part then
23+
return 1
24+
end
25+
end
26+
27+
return 0
28+
end
29+
30+
-- Function to check a version against a single specifier
31+
local function check_single_specifier(version, specifier)
32+
local operator, spec_version = specifier:match "^([<>=!]+)%s*(.+)$"
33+
local comp_result = compare_versions(version, spec_version)
34+
35+
if operator == "==" then
36+
return comp_result == 0
37+
elseif operator == "!=" then
38+
return comp_result ~= 0
39+
elseif operator == "<=" then
40+
return comp_result <= 0
41+
elseif operator == "<" then
42+
return comp_result < 0
43+
elseif operator == ">=" then
44+
return comp_result >= 0
45+
elseif operator == ">" then
46+
return comp_result > 0
47+
else
48+
error("Invalid operator in version specifier: " .. operator)
49+
end
50+
end
51+
52+
-- Function to check a version against multiple specifiers
53+
local function check_version(version, specifiers)
54+
for specifier in specifiers:gmatch "[^,]+" do
55+
if not check_single_specifier(version, specifier:match "^%s*(.-)%s*$") then
56+
return false
57+
end
58+
end
59+
return true
60+
end
61+
62+
return {
63+
check_version = check_version,
64+
}

lua/mason-core/providers/init.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ local settings = require "mason.settings"
2222
---@class PyPiProvider
2323
---@field get_latest_version? async fun(pkg: string): Result # Result<PyPiPackage>
2424
---@field get_all_versions? async fun(pkg: string): Result # Result<string[]> # Sorting should not be relied upon due to "proprietary" sorting algo in pip that is difficult to replicate in mason-registry-api.
25+
---@field get_supported_python_versions? async fun(pkg: string, version: string): Result # Result<string> # Returns a version specifier as provided by the PyPI API (see PEP440).
2526

2627
---@alias RubyGem { name: string, version: string }
2728

lua/mason-registry/api.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ api.pypi = {
8383
latest = get "/api/pypi/{package}/versions/latest",
8484
---@type ApiSignature<{ package: string }>
8585
all = get "/api/pypi/{package}/versions/all",
86+
---@type ApiSignature<{ package: string, version: string }>
87+
get = get "/api/pypi/{package}/versions/{version}",
8688
},
8789
}
8890

lua/mason/providers/client/pypi.lua

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
local Optional = require "mason-core.optional"
2+
local Result = require "mason-core.result"
23
local _ = require "mason-core.functional"
34
local a = require "mason-core.async"
5+
local fetch = require "mason-core.fetch"
46
local fs = require "mason-core.fs"
57
local platform = require "mason-core.platform"
68
local spawn = require "mason-core.spawn"
@@ -50,4 +52,16 @@ return {
5052
return get_all_versions(pkg):map(_.compose(Optional.of_nilable, _.last)):and_then(synthesize_pkg(pkg))
5153
end,
5254
get_all_versions = get_all_versions,
55+
get_supported_python_versions = function(pkg, version)
56+
return fetch(("https://pypi.org/pypi/%s/%s/json"):format(pkg, version))
57+
:map_catching(vim.json.decode)
58+
:map(_.path { "info", "requires_python" })
59+
:and_then(function(requires_python)
60+
if type(requires_python) ~= "string" then
61+
return Result.failure "Package does not specify supported Python versions."
62+
else
63+
return Result.success(requires_python)
64+
end
65+
end)
66+
end,
5367
}

lua/mason/providers/registry-api/init.lua

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
local Result = require "mason-core.result"
2+
local _ = require "mason-core.functional"
13
local api = require "mason-registry.api"
24

35
---@type Provider
@@ -31,6 +33,18 @@ return {
3133
get_all_versions = function(pkg)
3234
return api.pypi.versions.all { package = pkg }
3335
end,
36+
get_supported_python_versions = function(pkg, version)
37+
return api.pypi.versions
38+
.get({ package = pkg, version = version })
39+
:map(_.prop "requires_python")
40+
:and_then(function(requires_python)
41+
if type(requires_python) ~= "string" then
42+
return Result.failure "Package does not specify supported Python versions."
43+
else
44+
return Result.success(requires_python)
45+
end
46+
end)
47+
end,
3448
},
3549
rubygems = {
3650
get_latest_version = function(gem)

tests/mason-core/installer/managers/pypi_spec.lua

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ local Result = require "mason-core.result"
22
local installer = require "mason-core.installer"
33
local match = require "luassert.match"
44
local path = require "mason-core.path"
5+
local providers = require "mason-core.providers"
56
local pypi = require "mason-core.installer.managers.pypi"
67
local spawn = require "mason-core.spawn"
78
local spy = require "luassert.spy"
@@ -26,9 +27,10 @@ describe("pypi manager", function()
2627
it("should init venv without upgrading pip", function()
2728
local ctx = create_dummy_context()
2829
stub(ctx, "promote_cwd")
30+
stub(providers.pypi, "get_supported_python_versions", mockx.returns(Result.failure()))
2931

3032
installer.exec_in_context(ctx, function()
31-
pypi.init { upgrade_pip = false }
33+
pypi.init { package = { name = "cmake-language-server", version = "0.1.10" }, upgrade_pip = false }
3234
end)
3335

3436
assert.spy(ctx.promote_cwd).was_called(1)
@@ -44,10 +46,15 @@ describe("pypi manager", function()
4446
local ctx = create_dummy_context()
4547
stub(ctx, "promote_cwd")
4648
stub(ctx.fs, "file_exists")
49+
stub(providers.pypi, "get_supported_python_versions", mockx.returns(Result.failure()))
4750
ctx.fs.file_exists.on_call_with(match.ref(ctx.fs), "venv/bin/python").returns(true)
4851

4952
installer.exec_in_context(ctx, function()
50-
pypi.init { upgrade_pip = true, install_extra_args = { "--proxy", "http://localhost" } }
53+
pypi.init {
54+
package = { name = "cmake-language-server", version = "0.1.10" },
55+
upgrade_pip = true,
56+
install_extra_args = { "--proxy", "http://localhost" },
57+
}
5158
end)
5259

5360
assert.spy(ctx.promote_cwd).was_called(1)
@@ -69,6 +76,67 @@ describe("pypi manager", function()
6976
}
7077
end)
7178

79+
it("should find versioned candidates during init", function()
80+
local ctx = create_dummy_context()
81+
stub(ctx, "promote_cwd")
82+
stub(ctx.fs, "file_exists")
83+
stub(providers.pypi, "get_supported_python_versions", mockx.returns(Result.success ">=3.12"))
84+
stub(spawn, "python3.12")
85+
spawn["python3.12"].on_call_with({ "--version" }).returns(Result.success { stdout = "Python 3.12.0" })
86+
ctx.fs.file_exists.on_call_with(match.ref(ctx.fs), "venv/bin/python").returns(true)
87+
88+
installer.exec_in_context(ctx, function()
89+
pypi.init {
90+
package = { name = "cmake-language-server", version = "0.1.10" },
91+
upgrade_pip = true,
92+
install_extra_args = { "--proxy", "http://localhost" },
93+
}
94+
end)
95+
96+
assert.spy(ctx.promote_cwd).was_called(1)
97+
assert.spy(ctx.spawn["python3.12"]).was_called(1)
98+
assert.spy(ctx.spawn["python3.12"]).was_called_with {
99+
"-m",
100+
"venv",
101+
"venv",
102+
}
103+
end)
104+
105+
it("should default to stock version if unable to find suitable versioned candidate during init", function()
106+
local ctx = create_dummy_context()
107+
spy.on(ctx.stdio_sink, "stderr")
108+
stub(ctx, "promote_cwd")
109+
stub(ctx.fs, "file_exists")
110+
stub(providers.pypi, "get_supported_python_versions", mockx.returns(Result.success ">=3.8"))
111+
stub(vim.fn, "executable")
112+
vim.fn.executable.on_call_with("python3.12").returns(0)
113+
vim.fn.executable.on_call_with("python3.11").returns(0)
114+
vim.fn.executable.on_call_with("python3.10").returns(0)
115+
vim.fn.executable.on_call_with("python3.9").returns(0)
116+
vim.fn.executable.on_call_with("python3.8").returns(0)
117+
stub(spawn, "python3", mockx.returns(Result.success()))
118+
spawn.python3.on_call_with({ "--version" }).returns(Result.success { stdout = "Python 3.5.0" })
119+
120+
installer.exec_in_context(ctx, function()
121+
pypi.init {
122+
package = { name = "cmake-language-server", version = "0.1.10" },
123+
upgrade_pip = true,
124+
install_extra_args = { "--proxy", "http://localhost" },
125+
}
126+
end)
127+
128+
assert.spy(ctx.promote_cwd).was_called(1)
129+
assert.spy(ctx.spawn.python3).was_called(1)
130+
assert.spy(ctx.spawn.python3).was_called_with {
131+
"-m",
132+
"venv",
133+
"venv",
134+
}
135+
assert
136+
.spy(ctx.stdio_sink.stderr)
137+
.was_called_with "Warning: The resolved Python version 3.5.0 is not compatible with the required Python versions: >=3.8.\n"
138+
end)
139+
72140
it("should install", function()
73141
local ctx = create_dummy_context()
74142
stub(ctx.fs, "file_exists")

0 commit comments

Comments
 (0)