Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ local DEFAULT_SETTINGS = {
-- Whether to upgrade pip to the latest version in the virtual environment before installing packages.
upgrade_pip = false,

---@since 1.8.0
-- Whether to use uv to install packages instead of pip
use_uv = false,

---@since 1.0.0
-- These args will be added to `pip install` calls. Note that setting extra args might impact intended behavior
-- and is not recommended.
Expand Down
4 changes: 4 additions & 0 deletions doc/mason.txt
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,10 @@ Example:
-- Whether to upgrade pip to the latest version in the virtual environment before installing packages.
upgrade_pip = false,

---@since 2.2.1
-- Whether to use uv to install packages instead of pip
use_uv = false,

---@since 1.0.0
-- These args will be added to `pip install` calls. Note that setting extra args might impact intended behavior
-- and is not recommended.
Expand Down
2 changes: 2 additions & 0 deletions lua/mason-core/installer/compiler/compilers/pypi.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ local M = {}

---@class PypiSource : RegistryPackageSource
---@field extra_packages? string[]
---@field supported_platforms? string[]

---@param source PypiSource
---@param purl Purl
Expand All @@ -20,6 +21,7 @@ function M.parse(source, purl)
pip = {
upgrade = settings.current.pip.upgrade_pip,
extra_args = settings.current.pip.install_args,
use_uv = settings.current.pip.use_uv,
},
}

Expand Down
113 changes: 83 additions & 30 deletions lua/mason-core/installer/managers/pypi.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ local pep440 = require "mason-core.pep440"
local platform = require "mason-core.platform"
local providers = require "mason-core.providers"
local semver = require "mason-core.semver"
local settings = require "mason.settings"
local spawn = require "mason-core.spawn"

local use_uv = settings.current.pip.use_uv

local M = {}

local VENV_DIR = "venv"
Expand All @@ -30,11 +33,20 @@ local function resolve_python3(candidates)
a.scheduler()
local available_candidates = _.filter(is_executable, candidates)
for __, candidate in ipairs(available_candidates) do
---@type string
local version_output = spawn[candidate]({ "--version" }):map(_.prop "stdout"):get_or_else ""
local ok, version = pcall(semver.new, version_output:match "Python (3%.%d+.%d+)")
if ok then
return { executable = candidate, version = version }
if use_uv and candidate == "uv" then
---@type string
local version_output = spawn[candidate]({ "--version" }):map(_.prop "stdout"):get_or_else ""
local ok, version = pcall(semver.new, version_output:match "uv (%d+.%d+.%d+).*")
if ok then
return { executable = candidate, version = version }
end
elseif not use_uv then
---@type string
local version_output = spawn[candidate]({ "--version" }):map(_.prop "stdout"):get_or_else ""
local ok, version = pcall(semver.new, version_output:match "Python (3%.%d+.%d+)")
if ok then
return { executable = candidate, version = version }
end
end
end
return nil
Expand Down Expand Up @@ -66,13 +78,12 @@ local function get_versioned_candidates(supported_python_versions)
end
return Optional.of(executable)
end, {
{ semver.new "3.14.0", "python3.14" },
{ semver.new "3.13.0", "python3.13" },
{ semver.new "3.12.0", "python3.12" },
{ semver.new "3.11.0", "python3.11" },
{ semver.new "3.10.0", "python3.10" },
{ semver.new "3.9.0", "python3.9" },
{ semver.new "3.8.0", "python3.8" },
{ semver.new "3.7.0", "python3.7" },
{ semver.new "3.6.0", "python3.6" },
-- the rest are EOL
})
end

Expand All @@ -85,13 +96,19 @@ local function create_venv(pkg)

-- 1. Resolve stock python3 installation.
local stock_candidates = platform.is.win and { "python", "python3" } or { "python3", "python" }
if use_uv then
table.insert(stock_candidates, 1, "uv")
end
local stock_target = resolve_python3(stock_candidates)
if stock_target then
log.fmt_debug("Resolved stock python3 installation version %s", stock_target.version)
end

-- 2. Resolve suitable versioned python3 installation (python3.12, python3.11, etc.).
local versioned_candidates = {}
if use_uv then
table.insert(versioned_candidates, "uv")
end
if supported_python_versions ~= nil then
if stock_target and not pep440_check_version(tostring(stock_target.version), supported_python_versions) then
log.fmt_debug("Finding versioned candidates for %s", supported_python_versions)
Expand All @@ -111,7 +128,8 @@ local function create_venv(pkg)
-- 3. If a versioned python3 installation was not found, warn the user if the stock python3 installation is outside
-- the supported version range.
if
target == stock_target
use_uv == false
and target == stock_target
and supported_python_versions ~= nil
and not pep440_check_version(tostring(target.version), supported_python_versions)
then
Expand All @@ -133,9 +151,14 @@ local function create_venv(pkg)
end
end

log.fmt_debug("Found python3 installation version=%s, executable=%s", target.version, target.executable)
ctx.stdio_sink:stdout "Creating virtual environment…\n"
return ctx.spawn[target.executable] { "-m", "venv", "--system-site-packages", VENV_DIR }
if use_uv then
log.fmt_debug("Found uv installation version=%s, executable=%s", target.version, target.executable)
return ctx.spawn[target.executable] { "venv", VENV_DIR }
else
log.fmt_debug("Found python3 installation version=%s, executable=%s", target.version, target.executable)
return ctx.spawn[target.executable] { "-m", "venv", "--system-site-packages", VENV_DIR }
end
end

---@param ctx InstallContext
Expand All @@ -154,32 +177,58 @@ local function find_venv_executable(ctx, executable)
return Result.success(candidate)
end
end

return Result.failure(("Failed to find executable %q in Python virtual environment."):format(executable))
end

---@async
---@param args SpawnArgs
local function venv_python(args)
local ctx = installer.context()
if use_uv then
return ctx.spawn["uv"](args)
end
return find_venv_executable(ctx, "python"):and_then(function(python_path)
return ctx.spawn[path.concat { ctx.cwd:get(), python_path }](args)
end)
end

---@async
---@param args SpawnArgs
local function uv_python(args)
local ctx = installer.context()
local global_uv = vim.fn.exepath "uv"
return ctx.spawn[global_uv](args)
end

---@async
---@param pkgs string[]
---@param extra_args? string[]
local function pip_install(pkgs, extra_args)
return venv_python {
"-m",
"pip",
"--disable-pip-version-check",
"install",
"--no-user",
"--ignore-installed",
extra_args or vim.NIL,
pkgs,
}
if use_uv then
return uv_python {
"pip",
"install",
"--color",
"never",
"--directory",
"venv",
"-U",
extra_args or vim.NIL,
pkgs,
}
else
return venv_python {
"-m",
"pip",
"--disable-pip-version-check",
"install",
"--no-user",
"--ignore-installed",
extra_args or vim.NIL,
pkgs,
}
end
end

---@async
Expand All @@ -193,7 +242,7 @@ function M.init(opts)
ctx:promote_cwd()
try(create_venv(opts.package))

if opts.upgrade_pip then
if opts.upgrade_pip and not use_uv then
ctx.stdio_sink:stdout "Upgrading pip inside the virtual environment…\n"
try(pip_install({ "pip" }, opts.install_extra_args))
end
Expand All @@ -219,13 +268,17 @@ end
---@param pkg string
function M.uninstall(pkg)
log.fmt_debug("pypi: uninstall %s", pkg)
return venv_python {
"-m",
"pip",
"uninstall",
"-y",
pkg,
}
if use_uv then
return venv_python { "pip", "uninstall", "-y", pkg }
else
return venv_python {
"-m",
"pip",
"uninstall",
"-y",
pkg,
}
end
end

---@param executable string
Expand Down
4 changes: 4 additions & 0 deletions lua/mason/settings.lua
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ local DEFAULT_SETTINGS = {
-- Whether to upgrade pip to the latest version in the virtual environment before installing packages.
upgrade_pip = false,

---@since 2.2.1
-- Whether to use uv to install packages instead of pip
use_uv = false,

---@since 1.0.0
-- These args will be added to `pip install` calls. Note that setting extra args might impact intended behavior
-- and is not recommended.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe("pypi compiler :: parsing", function()
pip = {
upgrade = true,
extra_args = { "--proxy", "http://localghost" },
use_uv = false,
},
},
pypi.parse({ extra_packages = { "extra" } }, purl())
Expand Down
26 changes: 14 additions & 12 deletions tests/mason-core/installer/managers/pypi_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ describe("pypi manager", function()
stub(ctx.fs, "file_exists")
stub(providers.pypi, "get_supported_python_versions", mockx.returns(Result.success ">=3.12"))
stub(vim.fn, "executable")
vim.fn.executable.on_call_with("python3.14").returns(0)
vim.fn.executable.on_call_with("python3.13").returns(0)
vim.fn.executable.on_call_with("python3.12").returns(1)
stub(spawn, "python3.12")
spawn["python3.12"].on_call_with({ "--version" }).returns(Result.success { stdout = "Python 3.12.0" })
Expand Down Expand Up @@ -120,13 +122,13 @@ describe("pypi manager", function()
spy.on(ctx.stdio_sink, "stderr")
stub(ctx, "promote_cwd")
stub(ctx.fs, "file_exists")
stub(providers.pypi, "get_supported_python_versions", mockx.returns(Result.success ">=3.8"))
stub(providers.pypi, "get_supported_python_versions", mockx.returns(Result.success ">=3.10"))
stub(vim.fn, "executable")
vim.fn.executable.on_call_with("python3.14").returns(0)
vim.fn.executable.on_call_with("python3.13").returns(0)
vim.fn.executable.on_call_with("python3.12").returns(0)
vim.fn.executable.on_call_with("python3.11").returns(0)
vim.fn.executable.on_call_with("python3.10").returns(0)
vim.fn.executable.on_call_with("python3.9").returns(0)
vim.fn.executable.on_call_with("python3.8").returns(0)
stub(spawn, "python3", mockx.returns(Result.success()))
spawn.python3.on_call_with({ "--version" }).returns(Result.success { stdout = "Python 3.5.0" })

Expand All @@ -139,7 +141,7 @@ describe("pypi manager", function()
end)

assert.same(
Result.failure "Failed to find a python3 installation in PATH that meets the required versions (>=3.8). Found version: 3.5.0.",
Result.failure "Failed to find a python3 installation in PATH that meets the required versions (>=3.10). Found version: 3.5.0.",
result
)
assert
Expand All @@ -154,13 +156,13 @@ describe("pypi manager", function()
spy.on(ctx.stdio_sink, "stderr")
stub(ctx, "promote_cwd")
stub(ctx.fs, "file_exists")
stub(providers.pypi, "get_supported_python_versions", mockx.returns(Result.success ">=3.8"))
stub(providers.pypi, "get_supported_python_versions", mockx.returns(Result.success ">=3.10"))
stub(vim.fn, "executable")
vim.fn.executable.on_call_with("python3.14").returns(0)
vim.fn.executable.on_call_with("python3.13").returns(0)
vim.fn.executable.on_call_with("python3.12").returns(0)
vim.fn.executable.on_call_with("python3.11").returns(0)
vim.fn.executable.on_call_with("python3.10").returns(0)
vim.fn.executable.on_call_with("python3.9").returns(0)
vim.fn.executable.on_call_with("python3.8").returns(0)
stub(spawn, "python3", mockx.returns(Result.success()))
spawn.python3.on_call_with({ "--version" }).returns(Result.success { stdout = "Python 3.5.0" })

Expand All @@ -182,7 +184,7 @@ describe("pypi manager", function()
}
assert.spy(ctx.stdio_sink.stderr).was_called_with(
match.is_ref(ctx.stdio_sink),
"Warning: The resolved python3 version 3.5.0 is not compatible with the required Python versions: >=3.8.\n"
"Warning: The resolved python3 version 3.5.0 is not compatible with the required Python versions: >=3.10.\n"
)
end
)
Expand All @@ -192,11 +194,11 @@ describe("pypi manager", function()
spy.on(ctx.stdio_sink, "stderr")
stub(ctx, "promote_cwd")
stub(ctx.fs, "file_exists")
stub(providers.pypi, "get_supported_python_versions", mockx.returns(Result.success ">=3.8"))
stub(providers.pypi, "get_supported_python_versions", mockx.returns(Result.success ">=3.10"))
stub(vim.fn, "executable")
vim.fn.executable.on_call_with("python3.12").returns(1)
vim.fn.executable.on_call_with("python3.14").returns(1)
stub(spawn, "python3", mockx.returns(Result.success()))
spawn.python3.on_call_with({ "--version" }).returns(Result.success { stdout = "Python 3.8.0" })
spawn.python3.on_call_with({ "--version" }).returns(Result.success { stdout = "Python 3.10.0" })

ctx:execute(function()
pypi.init {
Expand All @@ -208,7 +210,7 @@ describe("pypi manager", function()

assert.spy(ctx.promote_cwd).was_called(1)
assert.spy(ctx.spawn.python3).was_called(1)
assert.spy(ctx.spawn["python3.12"]).was_called(0)
assert.spy(ctx.spawn["python3.14"]).was_called(0)
assert.spy(ctx.spawn.python3).was_called_with {
"-m",
"venv",
Expand Down
Loading