diff --git a/README.md b/README.md index c233bee0b..2d18989bf 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/doc/mason.txt b/doc/mason.txt index 091982a20..37108e877 100644 --- a/doc/mason.txt +++ b/doc/mason.txt @@ -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. diff --git a/lua/mason-core/installer/compiler/compilers/pypi.lua b/lua/mason-core/installer/compiler/compilers/pypi.lua index 3e2ca141a..3addf7d47 100644 --- a/lua/mason-core/installer/compiler/compilers/pypi.lua +++ b/lua/mason-core/installer/compiler/compilers/pypi.lua @@ -7,6 +7,7 @@ local M = {} ---@class PypiSource : RegistryPackageSource ---@field extra_packages? string[] +---@field supported_platforms? string[] ---@param source PypiSource ---@param purl Purl @@ -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, }, } diff --git a/lua/mason-core/installer/managers/pypi.lua b/lua/mason-core/installer/managers/pypi.lua index 72b1b5037..bf49dffc6 100644 --- a/lua/mason-core/installer/managers/pypi.lua +++ b/lua/mason-core/installer/managers/pypi.lua @@ -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" @@ -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 @@ -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 @@ -85,6 +96,9 @@ 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) @@ -92,6 +106,9 @@ local function create_venv(pkg) -- 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) @@ -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 @@ -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 @@ -154,6 +177,7 @@ 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 @@ -161,25 +185,50 @@ end ---@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 @@ -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 @@ -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 diff --git a/lua/mason/settings.lua b/lua/mason/settings.lua index ebff1e0b4..bdc46699d 100644 --- a/lua/mason/settings.lua +++ b/lua/mason/settings.lua @@ -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. diff --git a/tests/mason-core/installer/compiler/compilers/pypi_spec.lua b/tests/mason-core/installer/compiler/compilers/pypi_spec.lua index 03c57a9e6..7af2d1193 100644 --- a/tests/mason-core/installer/compiler/compilers/pypi_spec.lua +++ b/tests/mason-core/installer/compiler/compilers/pypi_spec.lua @@ -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()) diff --git a/tests/mason-core/installer/managers/pypi_spec.lua b/tests/mason-core/installer/managers/pypi_spec.lua index ef57411ba..668b353c9 100644 --- a/tests/mason-core/installer/managers/pypi_spec.lua +++ b/tests/mason-core/installer/managers/pypi_spec.lua @@ -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" }) @@ -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" }) @@ -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 @@ -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" }) @@ -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 ) @@ -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 { @@ -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",