From bb2539c28ef63cd39fd005c3a35287c8a94f4c8c Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 7 Mar 2026 18:38:02 +0100 Subject: [PATCH 1/6] Add ensure_venv/2,3 for automatic venv creation and activation - ensure_venv(Path, RequirementsFile) creates venv if needed - Supports requirements.txt and pyproject.toml formats - Auto-detects uv or pip as installer - Options: extras, installer, python, force - Updated activate_venv to auto-detect site-packages directory (handles venv created with different Python version) - Added py_venv_SUITE with 7 tests --- src/py.erl | 191 +++++++++++++++++++++++++++++++++- test/py_venv_SUITE.erl | 231 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 test/py_venv_SUITE.erl diff --git a/src/py.erl b/src/py.erl index d4c361b..146d36c 100644 --- a/src/py.erl +++ b/src/py.erl @@ -91,6 +91,8 @@ subinterp_pool_ready/0, subinterp_pool_stats/0, %% Virtual environment + ensure_venv/2, + ensure_venv/3, activate_venv/1, deactivate_venv/0, venv_info/0, @@ -704,6 +706,179 @@ subinterp_pool_stats() -> %%% Virtual Environment Support %%% ============================================================================ +%% @doc Ensure a virtual environment exists and activate it. +%% +%% Creates a venv at `Path' if it doesn't exist, installs dependencies from +%% `RequirementsFile', and activates the venv. +%% +%% RequirementsFile can be: +%% - `"requirements.txt"' - standard pip requirements file +%% - `"pyproject.toml"' - PEP 621 project file (installs with -e .) +%% +%% Example: +%% ``` +%% ok = py:ensure_venv("priv/venv", "requirements.txt"). +%% ''' +-spec ensure_venv(string() | binary(), string() | binary()) -> ok | {error, term()}. +ensure_venv(Path, RequirementsFile) -> + ensure_venv(Path, RequirementsFile, []). + +%% @doc Ensure a virtual environment exists with options. +%% +%% Options: +%% - `{extras, [string()]}' - Install optional dependencies (pyproject.toml) +%% - `{installer, uv | pip}' - Package installer (default: auto-detect) +%% - `{python, string()}' - Python executable for venv creation +%% - `force' - Recreate venv even if it exists +%% +%% Example: +%% ``` +%% %% With pyproject.toml and dev extras +%% ok = py:ensure_venv("priv/venv", "pyproject.toml", [ +%% {extras, ["dev", "test"]} +%% ]). +%% +%% %% Force uv installer +%% ok = py:ensure_venv("priv/venv", "requirements.txt", [ +%% {installer, uv} +%% ]). +%% ''' +-spec ensure_venv(string() | binary(), string() | binary(), list()) -> ok | {error, term()}. +ensure_venv(Path, RequirementsFile, Opts) -> + PathStr = to_string(Path), + ReqFileStr = to_string(RequirementsFile), + Force = proplists:get_bool(force, Opts), + case venv_exists(PathStr) of + true when not Force -> + %% Venv exists, just activate + activate_venv(PathStr); + _ -> + %% Create venv + case create_venv(PathStr, Opts) of + ok -> + %% Install dependencies + case install_deps(PathStr, ReqFileStr, Opts) of + ok -> + activate_venv(PathStr); + {error, _} = Err -> + Err + end; + {error, _} = Err -> + Err + end + end. + +%% @private Check if venv exists by looking for pyvenv.cfg +-spec venv_exists(string()) -> boolean(). +venv_exists(Path) -> + filelib:is_file(filename:join(Path, "pyvenv.cfg")). + +%% @private Create a new virtual environment +-spec create_venv(string(), list()) -> ok | {error, term()}. +create_venv(Path, Opts) -> + Installer = detect_installer(Opts), + Python = proplists:get_value(python, Opts, "python3"), + Cmd = case Installer of + uv -> + %% uv venv is faster + case proplists:get_value(python, Opts, undefined) of + undefined -> + io_lib:format("uv venv ~s", [quote(Path)]); + PyVer -> + io_lib:format("uv venv --python ~s ~s", [quote(PyVer), quote(Path)]) + end; + pip -> + io_lib:format("~s -m venv ~s", [quote(Python), quote(Path)]) + end, + run_cmd(lists:flatten(Cmd)). + +%% @private Install dependencies from requirements file +-spec install_deps(string(), string(), list()) -> ok | {error, term()}. +install_deps(Path, RequirementsFile, Opts) -> + Installer = detect_installer(Opts), + PipPath = pip_path(Path, Installer), + Extras = proplists:get_value(extras, Opts, []), + + %% Determine file type and build install command + Cmd = case filename:extension(RequirementsFile) of + ".txt" -> + %% requirements.txt + io_lib:format("~s install -r ~s", [PipPath, quote(RequirementsFile)]); + ".toml" -> + %% pyproject.toml - install as editable + ReqDir = filename:dirname(RequirementsFile), + InstallPath = case ReqDir of + "." -> "."; + "" -> "."; + _ -> ReqDir + end, + case Extras of + [] -> + io_lib:format("~s install -e ~s", [PipPath, quote(InstallPath)]); + _ -> + ExtrasStr = string:join(Extras, ","), + io_lib:format("~s install -e \"~s[~s]\"", [PipPath, InstallPath, ExtrasStr]) + end; + _ -> + %% Assume requirements.txt format + io_lib:format("~s install -r ~s", [PipPath, quote(RequirementsFile)]) + end, + run_cmd(lists:flatten(Cmd)). + +%% @private Detect which installer to use (uv or pip) +-spec detect_installer(list()) -> uv | pip. +detect_installer(Opts) -> + case proplists:get_value(installer, Opts, auto) of + auto -> + case os:find_executable("uv") of + false -> pip; + _ -> uv + end; + Installer -> + Installer + end. + +%% @private Get pip/uv pip command path +-spec pip_path(string(), uv | pip) -> string(). +pip_path(VenvPath, uv) -> + %% uv pip uses venv from env var or --python flag + "VIRTUAL_ENV=" ++ quote(VenvPath) ++ " uv pip"; +pip_path(VenvPath, pip) -> + %% Use pip from the venv + case os:type() of + {win32, _} -> + filename:join([VenvPath, "Scripts", "pip"]); + _ -> + filename:join([VenvPath, "bin", "pip"]) + end. + +%% @private Quote a path for shell +-spec quote(string()) -> string(). +quote(S) -> + "'" ++ S ++ "'". + +%% @private Run a shell command and return ok or error +-spec run_cmd(string()) -> ok | {error, term()}. +run_cmd(Cmd) -> + %% Use os:cmd but check for errors + Result = os:cmd(Cmd ++ " 2>&1; echo \"::exitcode::$?\""), + %% Parse exit code from end of output + case string:split(Result, "::exitcode::", trailing) of + [Output, ExitCodeStr] -> + case string:trim(ExitCodeStr) of + "0" -> ok; + Code -> {error, {exit_code, list_to_integer(Code), string:trim(Output)}} + end; + _ -> + %% Fallback - assume success if no error marker + ok + end. + +%% @private Convert to string +-spec to_string(string() | binary()) -> string(). +to_string(B) when is_binary(B) -> binary_to_list(B); +to_string(S) when is_list(S) -> S. + %% @doc Activate a Python virtual environment. %% This modifies sys.path to use packages from the specified venv. %% The venv path should be the root directory (containing bin/lib folders). @@ -721,8 +896,20 @@ subinterp_pool_stats() -> -spec activate_venv(string() | binary()) -> ok | {error, term()}. activate_venv(VenvPath) -> VenvBin = ensure_binary(VenvPath), - %% Build site-packages path based on platform - {ok, SitePackages} = eval(<<"__import__('os').path.join(vp, 'Lib' if __import__('sys').platform == 'win32' else 'lib', '' if __import__('sys').platform == 'win32' else f'python{__import__(\"sys\").version_info.major}.{__import__(\"sys\").version_info.minor}', 'site-packages')">>, #{vp => VenvBin}), + %% Find site-packages directory dynamically (venv may use different Python version) + %% Uses a single expression to avoid multiline code issues + FindSitePackages = <<"(lambda vp: __import__('os').path.join(vp, 'Lib', 'site-packages') if __import__('os').path.exists(__import__('os').path.join(vp, 'Lib', 'site-packages')) else next((sp for name in (__import__('os').listdir(__import__('os').path.join(vp, 'lib')) if __import__('os').path.isdir(__import__('os').path.join(vp, 'lib')) else []) if name.startswith('python') for sp in [__import__('os').path.join(vp, 'lib', name, 'site-packages')] if __import__('os').path.isdir(sp)), None))(_venv_path)">>, + case eval(FindSitePackages, #{<<"_venv_path">> => VenvBin}) of + {ok, SitePackages} when SitePackages =/= none, SitePackages =/= null -> + activate_venv_with_site_packages(VenvBin, SitePackages); + {ok, _} -> + {error, {invalid_venv, no_site_packages_found}}; + Error -> + Error + end. + +%% @private Activate venv with known site-packages path +activate_venv_with_site_packages(VenvBin, SitePackages) -> %% Verify site-packages exists case eval(<<"__import__('os').path.isdir(sp)">>, #{sp => SitePackages}) of {ok, true} -> diff --git a/test/py_venv_SUITE.erl b/test/py_venv_SUITE.erl new file mode 100644 index 0000000..c95460f --- /dev/null +++ b/test/py_venv_SUITE.erl @@ -0,0 +1,231 @@ +%% Copyright 2026 Benoit Chesneau +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. + +-module(py_venv_SUITE). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("kernel/include/file.hrl"). + +-export([ + all/0, + groups/0, + init_per_suite/1, + end_per_suite/1, + init_per_group/2, + end_per_group/2, + init_per_testcase/2, + end_per_testcase/2 +]). + +-export([ + test_ensure_venv_creates_venv/1, + test_ensure_venv_activates_existing/1, + test_ensure_venv_with_requirements/1, + test_ensure_venv_force_recreate/1, + test_activate_venv/1, + test_deactivate_venv/1, + test_venv_info/1 +]). + +all() -> + [{group, venv_tests}]. + +groups() -> + [{venv_tests, [sequence], [ + test_ensure_venv_creates_venv, + test_ensure_venv_activates_existing, + test_ensure_venv_with_requirements, + test_ensure_venv_force_recreate, + test_activate_venv, + test_deactivate_venv, + test_venv_info + ]}]. + +init_per_suite(Config) -> + application:ensure_all_started(erlang_python), + Config. + +end_per_suite(_Config) -> + ok. + +init_per_group(_Group, Config) -> + Config. + +end_per_group(_Group, _Config) -> + ok. + +init_per_testcase(_TestCase, Config) -> + %% Create unique temp directory for each test + TempDir = filename:join(["/tmp", "py_venv_test_" ++ integer_to_list(erlang:unique_integer([positive]))]), + filelib:ensure_dir(filename:join(TempDir, "dummy")), + [{temp_dir, TempDir} | Config]. + +end_per_testcase(_TestCase, Config) -> + %% Clean up temp directory + TempDir = ?config(temp_dir, Config), + os:cmd("rm -rf " ++ TempDir), + %% Deactivate any active venv + py:deactivate_venv(), + ok. + +%%% ============================================================================ +%%% Test Cases +%%% ============================================================================ + +test_ensure_venv_creates_venv(Config) -> + TempDir = ?config(temp_dir, Config), + VenvPath = filename:join(TempDir, "venv"), + ReqFile = filename:join(TempDir, "requirements.txt"), + + %% Create empty requirements file + ok = file:write_file(ReqFile, <<"# empty\n">>), + + %% ensure_venv should create the venv and activate it + ok = py:ensure_venv(VenvPath, ReqFile, [{installer, pip}]), + + %% Verify venv was created + true = filelib:is_file(filename:join(VenvPath, "pyvenv.cfg")), + + %% Verify venv is active + {ok, Info} = py:venv_info(), + true = maps:get(<<"active">>, Info), + ok. + +test_ensure_venv_activates_existing(Config) -> + TempDir = ?config(temp_dir, Config), + VenvPath = filename:join(TempDir, "venv"), + ReqFile = filename:join(TempDir, "requirements.txt"), + + %% Create empty requirements file + ok = file:write_file(ReqFile, <<"# empty\n">>), + + %% Create venv first time + ok = py:ensure_venv(VenvPath, ReqFile, [{installer, pip}]), + + %% Deactivate + ok = py:deactivate_venv(), + {ok, Info1} = py:venv_info(), + false = maps:get(<<"active">>, Info1), + + %% ensure_venv again should just activate existing venv (not recreate) + ok = py:ensure_venv(VenvPath, ReqFile, [{installer, pip}]), + + %% Verify venv is active again + {ok, Info2} = py:venv_info(), + true = maps:get(<<"active">>, Info2), + ok. + +test_ensure_venv_with_requirements(Config) -> + TempDir = ?config(temp_dir, Config), + VenvPath = filename:join(TempDir, "venv"), + ReqFile = filename:join(TempDir, "requirements.txt"), + + %% Create requirements file with a simple package + ok = file:write_file(ReqFile, <<"six\n">>), + + %% ensure_venv should create venv and install six + ok = py:ensure_venv(VenvPath, ReqFile, [{installer, pip}]), + + %% Verify six is importable + {ok, Version} = py:eval(<<"__import__('six').__version__">>), + true = is_binary(Version), + ok. + +test_ensure_venv_force_recreate(Config) -> + TempDir = ?config(temp_dir, Config), + VenvPath = filename:join(TempDir, "venv"), + ReqFile = filename:join(TempDir, "requirements.txt"), + + %% Create empty requirements + ok = file:write_file(ReqFile, <<"# empty\n">>), + + %% Create venv first time + ok = py:ensure_venv(VenvPath, ReqFile, [{installer, pip}]), + + %% Get the pyvenv.cfg mtime + {ok, Info1} = file:read_file_info(filename:join(VenvPath, "pyvenv.cfg")), + Mtime1 = Info1#file_info.mtime, + + %% Wait a bit + timer:sleep(1100), + + %% Force recreate + ok = py:deactivate_venv(), + ok = py:ensure_venv(VenvPath, ReqFile, [{installer, pip}, force]), + + %% Verify mtime changed (venv was recreated) + {ok, Info2} = file:read_file_info(filename:join(VenvPath, "pyvenv.cfg")), + Mtime2 = Info2#file_info.mtime, + true = Mtime2 > Mtime1, + ok. + +test_activate_venv(Config) -> + TempDir = ?config(temp_dir, Config), + VenvPath = filename:join(TempDir, "venv"), + + %% Create venv manually + Cmd = "python3 -m venv " ++ VenvPath, + _ = os:cmd(Cmd), + + %% Activate it + ok = py:activate_venv(VenvPath), + + %% Verify active + {ok, Info} = py:venv_info(), + true = maps:get(<<"active">>, Info), + VenvBin = list_to_binary(VenvPath), + VenvBin = maps:get(<<"venv_path">>, Info), + ok. + +test_deactivate_venv(Config) -> + TempDir = ?config(temp_dir, Config), + VenvPath = filename:join(TempDir, "venv"), + + %% Create and activate venv + Cmd = "python3 -m venv " ++ VenvPath, + _ = os:cmd(Cmd), + ok = py:activate_venv(VenvPath), + + %% Verify active + {ok, Info1} = py:venv_info(), + true = maps:get(<<"active">>, Info1), + + %% Deactivate + ok = py:deactivate_venv(), + + %% Verify not active + {ok, Info2} = py:venv_info(), + false = maps:get(<<"active">>, Info2), + ok. + +test_venv_info(Config) -> + TempDir = ?config(temp_dir, Config), + VenvPath = filename:join(TempDir, "venv"), + + %% Before activation, should be inactive + {ok, Info1} = py:venv_info(), + false = maps:get(<<"active">>, Info1), + + %% Create and activate + Cmd = "python3 -m venv " ++ VenvPath, + _ = os:cmd(Cmd), + ok = py:activate_venv(VenvPath), + + %% After activation, should have all info + {ok, Info2} = py:venv_info(), + true = maps:get(<<"active">>, Info2), + true = is_binary(maps:get(<<"venv_path">>, Info2)), + true = is_binary(maps:get(<<"site_packages">>, Info2)), + true = is_list(maps:get(<<"sys_path">>, Info2)), + ok. From b30e01200c04160aeb1079faabd7a8c0dc7c97b3 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 7 Mar 2026 19:59:28 +0100 Subject: [PATCH 2/6] Fix dialyzer warning: remove unreachable pattern in install_deps --- src/py.erl | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/py.erl b/src/py.erl index 146d36c..f5306b7 100644 --- a/src/py.erl +++ b/src/py.erl @@ -806,12 +806,8 @@ install_deps(Path, RequirementsFile, Opts) -> io_lib:format("~s install -r ~s", [PipPath, quote(RequirementsFile)]); ".toml" -> %% pyproject.toml - install as editable - ReqDir = filename:dirname(RequirementsFile), - InstallPath = case ReqDir of - "." -> "."; - "" -> "."; - _ -> ReqDir - end, + %% filename:dirname returns "." for files without directory component + InstallPath = filename:dirname(RequirementsFile), case Extras of [] -> io_lib:format("~s install -e ~s", [PipPath, quote(InstallPath)]); From f3ec6cc31dce1fa900ce6049be8d2098eec47baa Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 7 Mar 2026 20:32:16 +0100 Subject: [PATCH 3/6] Fix Python executable detection for embedded Python - sys.executable returns beam.smp when embedded - Use sys.prefix to find actual Python binary - Search for pythonX.Y, python3, python in prefix/bin --- src/py.erl | 50 +++++++++++++++++++++++++++++++++++------- test/py_venv_SUITE.erl | 42 ++++++++++++++++++++++++++--------- 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/py.erl b/src/py.erl index f5306b7..76232a0 100644 --- a/src/py.erl +++ b/src/py.erl @@ -777,21 +777,55 @@ venv_exists(Path) -> -spec create_venv(string(), list()) -> ok | {error, term()}. create_venv(Path, Opts) -> Installer = detect_installer(Opts), - Python = proplists:get_value(python, Opts, "python3"), + Python = case proplists:get_value(python, Opts, undefined) of + undefined -> get_python_executable(); + P -> P + end, Cmd = case Installer of uv -> - %% uv venv is faster - case proplists:get_value(python, Opts, undefined) of - undefined -> - io_lib:format("uv venv ~s", [quote(Path)]); - PyVer -> - io_lib:format("uv venv --python ~s ~s", [quote(PyVer), quote(Path)]) - end; + %% uv venv is faster, use --python to match the running interpreter + io_lib:format("uv venv --python ~s ~s", [quote(Python), quote(Path)]); pip -> io_lib:format("~s -m venv ~s", [quote(Python), quote(Path)]) end, run_cmd(lists:flatten(Cmd)). +%% @private Get the Python executable path +%% When embedded, sys.executable returns the embedding app (beam.smp) +%% so we reconstruct the path from sys.prefix and version info +-spec get_python_executable() -> string(). +get_python_executable() -> + Code = <<" +import sys, os +# When embedded, sys.executable points to the embedding app +# Use sys.prefix to find the actual Python installation +if sys.platform == 'win32': + python = os.path.join(sys.prefix, 'python.exe') +else: + ver = f'python{sys.version_info.major}.{sys.version_info.minor}' + # Try common locations + for path in [ + os.path.join(sys.prefix, 'bin', ver), + os.path.join(sys.prefix, 'bin', 'python3'), + os.path.join(sys.prefix, 'bin', 'python'), + sys.executable # fallback + ]: + if os.path.isfile(path) and os.access(path, os.X_OK): + python = path + break + else: + python = 'python3' +python +">>, + case exec(Code) of + ok -> + case eval(<<"python">>) of + {ok, Path} when is_binary(Path) -> binary_to_list(Path); + _ -> "python3" + end; + _ -> "python3" + end. + %% @private Install dependencies from requirements file -spec install_deps(string(), string(), list()) -> ok | {error, term()}. install_deps(Path, RequirementsFile, Opts) -> diff --git a/test/py_venv_SUITE.erl b/test/py_venv_SUITE.erl index c95460f..97c81e7 100644 --- a/test/py_venv_SUITE.erl +++ b/test/py_venv_SUITE.erl @@ -60,11 +60,36 @@ end_per_suite(_Config) -> ok. init_per_group(_Group, Config) -> - Config. + %% Get Python executable path from the running interpreter + %% Note: sys.executable returns beam.smp when embedded, so we find the actual Python + Code = <<" +import sys, os +ver = f'python{sys.version_info.major}.{sys.version_info.minor}' +for path in [ + os.path.join(sys.prefix, 'bin', ver), + os.path.join(sys.prefix, 'bin', 'python3'), + os.path.join(sys.prefix, 'bin', 'python'), +]: + if os.path.isfile(path) and os.access(path, os.X_OK): + _python_path = path + break +else: + _python_path = 'python3' +">>, + ok = py:exec(Code), + {ok, PythonPath} = py:eval(<<"_python_path">>), + [{python_path, binary_to_list(PythonPath)} | Config]. end_per_group(_Group, _Config) -> ok. +%% @private Create venv using the Python from config +create_test_venv(VenvPath, Config) -> + PythonPath = ?config(python_path, Config), + Cmd = PythonPath ++ " -m venv " ++ VenvPath, + _ = os:cmd(Cmd), + ok. + init_per_testcase(_TestCase, Config) -> %% Create unique temp directory for each test TempDir = filename:join(["/tmp", "py_venv_test_" ++ integer_to_list(erlang:unique_integer([positive]))]), @@ -174,9 +199,8 @@ test_activate_venv(Config) -> TempDir = ?config(temp_dir, Config), VenvPath = filename:join(TempDir, "venv"), - %% Create venv manually - Cmd = "python3 -m venv " ++ VenvPath, - _ = os:cmd(Cmd), + %% Create venv manually using the same Python we're linked against + ok = create_test_venv(VenvPath, Config), %% Activate it ok = py:activate_venv(VenvPath), @@ -192,9 +216,8 @@ test_deactivate_venv(Config) -> TempDir = ?config(temp_dir, Config), VenvPath = filename:join(TempDir, "venv"), - %% Create and activate venv - Cmd = "python3 -m venv " ++ VenvPath, - _ = os:cmd(Cmd), + %% Create and activate venv using the same Python we're linked against + ok = create_test_venv(VenvPath, Config), ok = py:activate_venv(VenvPath), %% Verify active @@ -217,9 +240,8 @@ test_venv_info(Config) -> {ok, Info1} = py:venv_info(), false = maps:get(<<"active">>, Info1), - %% Create and activate - Cmd = "python3 -m venv " ++ VenvPath, - _ = os:cmd(Cmd), + %% Create and activate using the same Python we're linked against + ok = create_test_venv(VenvPath, Config), ok = py:activate_venv(VenvPath), %% After activation, should have all info From e40142c2b5d5306361c4b43b9021202224143472 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 7 Mar 2026 22:37:42 +0100 Subject: [PATCH 4/6] Fix Python path detection: initialize variable before loop --- src/py.erl | 20 +++++++++++--------- test/py_venv_SUITE.erl | 12 +++++++----- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/py.erl b/src/py.erl index 76232a0..6ffaba7 100644 --- a/src/py.erl +++ b/src/py.erl @@ -799,8 +799,11 @@ get_python_executable() -> import sys, os # When embedded, sys.executable points to the embedding app # Use sys.prefix to find the actual Python installation +_py_exe = 'python3' # default fallback if sys.platform == 'win32': - python = os.path.join(sys.prefix, 'python.exe') + path = os.path.join(sys.prefix, 'python.exe') + if os.path.isfile(path): + _py_exe = path else: ver = f'python{sys.version_info.major}.{sys.version_info.minor}' # Try common locations @@ -808,18 +811,17 @@ else: os.path.join(sys.prefix, 'bin', ver), os.path.join(sys.prefix, 'bin', 'python3'), os.path.join(sys.prefix, 'bin', 'python'), - sys.executable # fallback ]: - if os.path.isfile(path) and os.access(path, os.X_OK): - python = path - break - else: - python = 'python3' -python + try: + if os.path.isfile(path) and os.access(path, os.X_OK): + _py_exe = path + break + except: + pass ">>, case exec(Code) of ok -> - case eval(<<"python">>) of + case eval(<<"_py_exe">>) of {ok, Path} when is_binary(Path) -> binary_to_list(Path); _ -> "python3" end; diff --git a/test/py_venv_SUITE.erl b/test/py_venv_SUITE.erl index 97c81e7..078c3b8 100644 --- a/test/py_venv_SUITE.erl +++ b/test/py_venv_SUITE.erl @@ -64,17 +64,19 @@ init_per_group(_Group, Config) -> %% Note: sys.executable returns beam.smp when embedded, so we find the actual Python Code = <<" import sys, os +_python_path = 'python3' # default ver = f'python{sys.version_info.major}.{sys.version_info.minor}' for path in [ os.path.join(sys.prefix, 'bin', ver), os.path.join(sys.prefix, 'bin', 'python3'), os.path.join(sys.prefix, 'bin', 'python'), ]: - if os.path.isfile(path) and os.access(path, os.X_OK): - _python_path = path - break -else: - _python_path = 'python3' + try: + if os.path.isfile(path) and os.access(path, os.X_OK): + _python_path = path + break + except: + pass ">>, ok = py:exec(Code), {ok, PythonPath} = py:eval(<<"_python_path">>), From ab7bd1ef3012a8ae0856035a70c7f337abbf1bb4 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sun, 8 Mar 2026 00:58:04 +0100 Subject: [PATCH 5/6] Simplify Python path detection to single expression Avoid exec/eval pair that can fail in some environments. Use a single eval with lambda expression instead. --- src/py.erl | 36 ++++++------------------------------ test/py_venv_SUITE.erl | 21 +++------------------ 2 files changed, 9 insertions(+), 48 deletions(-) diff --git a/src/py.erl b/src/py.erl index 6ffaba7..95a7743 100644 --- a/src/py.erl +++ b/src/py.erl @@ -795,36 +795,12 @@ create_venv(Path, Opts) -> %% so we reconstruct the path from sys.prefix and version info -spec get_python_executable() -> string(). get_python_executable() -> - Code = <<" -import sys, os -# When embedded, sys.executable points to the embedding app -# Use sys.prefix to find the actual Python installation -_py_exe = 'python3' # default fallback -if sys.platform == 'win32': - path = os.path.join(sys.prefix, 'python.exe') - if os.path.isfile(path): - _py_exe = path -else: - ver = f'python{sys.version_info.major}.{sys.version_info.minor}' - # Try common locations - for path in [ - os.path.join(sys.prefix, 'bin', ver), - os.path.join(sys.prefix, 'bin', 'python3'), - os.path.join(sys.prefix, 'bin', 'python'), - ]: - try: - if os.path.isfile(path) and os.access(path, os.X_OK): - _py_exe = path - break - except: - pass -">>, - case exec(Code) of - ok -> - case eval(<<"_py_exe">>) of - {ok, Path} when is_binary(Path) -> binary_to_list(Path); - _ -> "python3" - end; + %% Use a single expression to find the Python executable + %% Searches for pythonX.Y, python3, python in sys.prefix/bin (Unix) + %% or python.exe in sys.prefix (Windows) + Expr = <<"(lambda: (__import__('os').path.join(__import__('sys').prefix, 'python.exe') if __import__('sys').platform == 'win32' and __import__('os').path.isfile(__import__('os').path.join(__import__('sys').prefix, 'python.exe')) else next((p for p in [__import__('os').path.join(__import__('sys').prefix, 'bin', f'python{__import__(\"sys\").version_info.major}.{__import__(\"sys\").version_info.minor}'), __import__('os').path.join(__import__('sys').prefix, 'bin', 'python3'), __import__('os').path.join(__import__('sys').prefix, 'bin', 'python')] if __import__('os').path.isfile(p)), 'python3')))()">>, + case eval(Expr) of + {ok, Path} when is_binary(Path) -> binary_to_list(Path); _ -> "python3" end. diff --git a/test/py_venv_SUITE.erl b/test/py_venv_SUITE.erl index 078c3b8..05bc1c0 100644 --- a/test/py_venv_SUITE.erl +++ b/test/py_venv_SUITE.erl @@ -62,24 +62,9 @@ end_per_suite(_Config) -> init_per_group(_Group, Config) -> %% Get Python executable path from the running interpreter %% Note: sys.executable returns beam.smp when embedded, so we find the actual Python - Code = <<" -import sys, os -_python_path = 'python3' # default -ver = f'python{sys.version_info.major}.{sys.version_info.minor}' -for path in [ - os.path.join(sys.prefix, 'bin', ver), - os.path.join(sys.prefix, 'bin', 'python3'), - os.path.join(sys.prefix, 'bin', 'python'), -]: - try: - if os.path.isfile(path) and os.access(path, os.X_OK): - _python_path = path - break - except: - pass -">>, - ok = py:exec(Code), - {ok, PythonPath} = py:eval(<<"_python_path">>), + %% Use a single expression to avoid any exec issues + Expr = <<"(lambda: next((p for p in [__import__('os').path.join(__import__('sys').prefix, 'bin', f'python{__import__(\"sys\").version_info.major}.{__import__(\"sys\").version_info.minor}'), __import__('os').path.join(__import__('sys').prefix, 'bin', 'python3'), __import__('os').path.join(__import__('sys').prefix, 'bin', 'python')] if __import__('os').path.isfile(p)), 'python3'))()">>, + {ok, PythonPath} = py:eval(Expr), [{python_path, binary_to_list(PythonPath)} | Config]. end_per_group(_Group, _Config) -> From ae4613abbf8a2d4d73b6f6bad0b1c594f6d8f3a0 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sun, 8 Mar 2026 01:04:01 +0100 Subject: [PATCH 6/6] Fix test_venv_info: deactivate before checking inactive state --- test/py_venv_SUITE.erl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/py_venv_SUITE.erl b/test/py_venv_SUITE.erl index 05bc1c0..0104130 100644 --- a/test/py_venv_SUITE.erl +++ b/test/py_venv_SUITE.erl @@ -223,6 +223,9 @@ test_venv_info(Config) -> TempDir = ?config(temp_dir, Config), VenvPath = filename:join(TempDir, "venv"), + %% Ensure no venv is active from previous tests + py:deactivate_venv(), + %% Before activation, should be inactive {ok, Info1} = py:venv_info(), false = maps:get(<<"active">>, Info1),