diff --git a/PyPreferences.jl/src/PyPreferences.jl b/PyPreferences.jl/src/PyPreferences.jl index 26f3a760..109a0a72 100644 --- a/PyPreferences.jl/src/PyPreferences.jl +++ b/PyPreferences.jl/src/PyPreferences.jl @@ -20,7 +20,7 @@ module Implementations module PythonUtils include("python_utils.jl") end - + include("which.jl") include("core.jl") include("api.jl") end diff --git a/PyPreferences.jl/src/core.jl b/PyPreferences.jl/src/core.jl index 72db2727..805f42a0 100644 --- a/PyPreferences.jl/src/core.jl +++ b/PyPreferences.jl/src/core.jl @@ -100,7 +100,7 @@ get_default_python() = get(ENV,"PYTHON", "python3") function get_python_fullpath(python) python_fullpath = nothing if python !== nothing - python_fullpath = Sys.which(python) + python_fullpath = _which(python) if python_fullpath === nothing @error "Failed to find a binary named `$(python)` in PATH." else @@ -136,7 +136,7 @@ function setup_non_failing() try if python !== nothing - python_fullpath = Sys.which(python) + python_fullpath = _which(python) if python_fullpath === nothing @error "Failed to find a binary named `$(python)` in PATH." else diff --git a/PyPreferences.jl/src/which.jl b/PyPreferences.jl/src/which.jl new file mode 100644 index 00000000..94539d3c --- /dev/null +++ b/PyPreferences.jl/src/which.jl @@ -0,0 +1,61 @@ +@static if VERSION >= v"1.7.0" + const _which = Sys.which +else + function _which(program_name::String) + if isempty(program_name) + return nothing + end + # Build a list of program names that we're going to try + program_names = String[] + base_pname = basename(program_name) + if Sys.iswindows() + # If the file already has an extension, try that name first + if !isempty(splitext(base_pname)[2]) + push!(program_names, base_pname) + end + + # But also try appending .exe and .com` + for pe in (".exe", ".com") + push!(program_names, string(base_pname, pe)) + end + else + # On non-windows, we just always search for what we've been given + push!(program_names, base_pname) + end + + path_dirs = String[] + program_dirname = dirname(program_name) + # If we've been given a path that has a directory name in it, then we + # check to see if that path exists. Otherwise, we search the PATH. + if isempty(program_dirname) + # If we have been given just a program name (not a relative or absolute + # path) then we should search `PATH` for it here: + pathsep = Sys.iswindows() ? ';' : ':' + path_dirs = abspath.(split(get(ENV, "PATH", ""), pathsep)) + + # On windows we always check the current directory as well + if Sys.iswindows() + pushfirst!(path_dirs, pwd()) + end + else + push!(path_dirs, abspath(program_dirname)) + end + + # Here we combine our directories with our program names, searching for the + # first match among all combinations. + for path_dir in path_dirs + for pname in program_names + program_path = joinpath(path_dir, pname) + # If we find something that matches our name and we can execute + if isfile(program_path) && Sys.isexecutable(program_path) + return program_path + end + end + end + + # If we couldn't find anything, don't return anything + nothing + end + + _which(program_name::AbstractString) = _which(String(program_name)) +end \ No newline at end of file diff --git a/PyPreferences.jl/test/test_venv.jl b/PyPreferences.jl/test/test_venv.jl new file mode 100644 index 00000000..01255462 --- /dev/null +++ b/PyPreferences.jl/test/test_venv.jl @@ -0,0 +1,123 @@ +using PyCall, Test + + +function test_venv_has_python(path) + newpython = PyCall.python_cmd(venv=path).exec[1] + if !isfile(newpython) + @info """ + Python executable $newpython does not exists. + This directory contains only the following files: + $(join(readdir(dirname(newpython)), '\n')) + """ + end + @test isfile(newpython) +end + + +function test_venv_activation(path) + newpython = PyCall.python_cmd(venv=path).exec[1] + + # Run a fresh Julia process with new Python environment + code = """ + $(Base.load_path_setup_code()) + using PyCall + println(PyCall.pyimport("sys").executable) + println(PyCall.pyimport("sys").exec_prefix) + println(PyCall.pyimport("pip").__file__) + """ + # Note that `pip` is just some arbitrary non-standard + # library. Using standard library like `os` does not work + # because those files are not created. + env = copy(ENV) + env["PYCALL_JL_RUNTIME_PYTHON"] = newpython + jlcmd = setenv(`$(Base.julia_cmd()) --startup-file=no -e $code`, env) + if Sys.iswindows() + # Marking the test broken in Windows. It seems that + # venv copies .dll on Windows and libpython check in + # PyCall.__init__ detects that. + @test_broken begin + output = read(jlcmd, String) + sys_executable, exec_prefix, mod_file = split(output, "\n") + newpython == sys_executable + end + else + output = read(jlcmd, String) + sys_executable, exec_prefix, mod_file = split(output, "\n") + @test newpython == sys_executable + @test startswith(exec_prefix, path) + @test startswith(mod_file, path) + end +end + + +@testset "virtualenv activation" begin + pyname = "python$(pyversion.major).$(pyversion.minor)" + if Sys.which("virtualenv") === nothing + @info "No virtualenv command. Skipping the test..." + elseif Sys.which(pyname) === nothing + @info "No $pyname command. Skipping the test..." + else + mktempdir() do tmppath + if PyCall.pyversion.major == 2 + path = joinpath(tmppath, "kind") + else + path = joinpath(tmppath, "ϵνιℓ") + end + run(`virtualenv --python=$pyname $path`) + test_venv_has_python(path) + + newpython = PyCall.python_cmd(venv=path).exec[1] + venv_libpython = PyCall.find_libpython(newpython) + if venv_libpython != PyCall.libpython + @info """ + virtualenv created an environment with incompatible libpython: + $venv_libpython + """ + return + end + + test_venv_activation(path) + end + end +end + + +@testset "venv activation" begin + # In case PyCall is built with a Python executable created by + # `virtualenv`, let's try to find the original Python executable. + # Otherwise, `venv` does not work with this Python executable: + # https://bugs.python.org/issue30811 + sys = PyCall.pyimport("sys") + if hasproperty(sys, :real_prefix) + # sys.real_prefix is set by virtualenv and does not exist in + # standard Python: + # https://github.com/pypa/virtualenv/blob/16.0.0/virtualenv_embedded/site.py#L554 + candidates = [ + PyCall.venv_python(sys.real_prefix, "$(pyversion.major).$(pyversion.minor)"), + PyCall.venv_python(sys.real_prefix, "$(pyversion.major)"), + PyCall.venv_python(sys.real_prefix), + PyCall.pyprogramname, # must exists + ] + python = candidates[findfirst(isfile, candidates)] + else + python = PyCall.pyprogramname + end + + if PyCall.conda + @info "Skip venv test with conda." + elseif !success(PyCall.python_cmd(`-c "import venv"`, python=python)) + @info "Skip venv test since venv package is missing." + else + mktempdir() do tmppath + if PyCall.pyversion.major == 2 + path = joinpath(tmppath, "kind") + else + path = joinpath(tmppath, "ϵνιℓ") + end + # Create a new virtual environment + run(PyCall.python_cmd(`-m venv $path`, python=python)) + test_venv_has_python(path) + test_venv_activation(path) + end + end +end