diff --git a/CHANGELOG.md b/CHANGELOG.md index c19c77a..4114c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +### Fixed + +- **`activate_venv/1` now processes `.pth` files** - Uses `site.addsitedir()` instead of + `sys.path.insert()` so that editable installs (uv, pip -e, poetry) work correctly. + New paths are moved to the front of `sys.path` for proper priority. + +- **`deactivate_venv/0` now restores `sys.path`** - The previous implementation used + `py:eval` with semicolon-separated statements which silently failed (eval only accepts + expressions). Switched to `py:exec` for correct statement execution. + ## 1.8.1 (2026-02-25) ### Fixed diff --git a/src/py.erl b/src/py.erl index 1db65ed..a23f532 100644 --- a/src/py.erl +++ b/src/py.erl @@ -490,6 +490,11 @@ parallel(Calls) when is_list(Calls) -> %% This modifies sys.path to use packages from the specified venv. %% The venv path should be the root directory (containing bin/lib folders). %% +%% `.pth' files in the venv's site-packages directory are processed, so +%% editable installs created by uv, pip, or any PEP 517/660 compliant tool +%% work correctly. New paths are inserted at the front of sys.path so that +%% venv packages take priority over system packages. +%% %% Example: %% ``` %% ok = py:activate_venv(<<"/path/to/myenv">>). @@ -504,12 +509,16 @@ activate_venv(VenvPath) -> case eval(<<"__import__('os').path.isdir(sp)">>, #{sp => SitePackages}) of {ok, true} -> %% Save original path if not already saved - _ = eval(<<"setattr(__import__('sys'), '_original_path', __import__('sys').path.copy()) if not hasattr(__import__('sys'), '_original_path') else None">>), + {ok, _} = eval(<<"setattr(__import__('sys'), '_original_path', __import__('sys').path.copy()) if not hasattr(__import__('sys'), '_original_path') else None">>), %% Set venv info - _ = eval(<<"setattr(__import__('sys'), '_active_venv', vp)">>, #{vp => VenvBin}), - _ = eval(<<"setattr(__import__('sys'), '_venv_site_packages', sp)">>, #{sp => SitePackages}), - %% Add to sys.path - _ = eval(<<"__import__('sys').path.insert(0, sp) if sp not in __import__('sys').path else None">>, #{sp => SitePackages}), + {ok, _} = eval(<<"setattr(__import__('sys'), '_active_venv', vp)">>, #{vp => VenvBin}), + {ok, _} = eval(<<"setattr(__import__('sys'), '_venv_site_packages', sp)">>, #{sp => SitePackages}), + %% Add site-packages and process .pth files (editable installs) + ok = exec(<<"import site as _site, sys as _sys\n" + "_b = frozenset(_sys.path)\n" + "_site.addsitedir(_sys._venv_site_packages)\n" + "_sys.path[:] = [p for p in _sys.path if p not in _b] + [p for p in _sys.path if p in _b]\n" + "del _site, _sys, _b\n">>), ok; {ok, false} -> {error, {invalid_venv, SitePackages}}; @@ -523,10 +532,12 @@ activate_venv(VenvPath) -> deactivate_venv() -> case eval(<<"hasattr(__import__('sys'), '_original_path')">>) of {ok, true} -> - _ = eval(<<"__import__('sys').path.clear(); __import__('sys').path.extend(__import__('sys')._original_path)">>), - _ = eval(<<"delattr(__import__('sys'), '_original_path')">>), - _ = eval(<<"delattr(__import__('sys'), '_active_venv') if hasattr(__import__('sys'), '_active_venv') else None">>), - _ = eval(<<"delattr(__import__('sys'), '_venv_site_packages') if hasattr(__import__('sys'), '_venv_site_packages') else None">>), + ok = exec(<<"import sys as _sys\n" + "_sys.path[:] = _sys._original_path\n" + "del _sys\n">>), + {ok, _} = eval(<<"delattr(__import__('sys'), '_original_path')">>), + {ok, _} = eval(<<"delattr(__import__('sys'), '_active_venv') if hasattr(__import__('sys'), '_active_venv') else None">>), + {ok, _} = eval(<<"delattr(__import__('sys'), '_venv_site_packages') if hasattr(__import__('sys'), '_venv_site_packages') else None">>), ok; {ok, false} -> ok; diff --git a/test/py_SUITE.erl b/test/py_SUITE.erl index 9ed99ad..9d75f2b 100644 --- a/test/py_SUITE.erl +++ b/test/py_SUITE.erl @@ -37,6 +37,7 @@ test_subinterp_supported/1, test_parallel_execution/1, test_venv/1, + test_venv_pth/1, %% New scalability tests test_execution_mode/1, test_num_executors/1, @@ -87,6 +88,7 @@ all() -> test_subinterp_supported, test_parallel_execution, test_venv, + test_venv_pth, %% Scalability tests test_execution_mode, test_num_executors, @@ -634,11 +636,16 @@ test_venv(_Config) -> true = is_binary(maps:get(<<"venv_path">>, Info)), true = is_binary(maps:get(<<"site_packages">>, Info)), + %% site-packages must be in sys.path and at position 0 + {ok, true} = py:eval(<<"sp in __import__('sys').path">>, #{sp => SitePackages}), + {ok, 0} = py:eval(<<"__import__('sys').path.index(sp)">>, #{sp => SitePackages}), + %% Deactivate ok = py:deactivate_venv(), - %% Verify deactivated + %% Verify deactivated: venv_info and sys.path both restored {ok, #{<<"active">> := false}} = py:venv_info(), + {ok, false} = py:eval(<<"sp in __import__('sys').path">>, #{sp => SitePackages}), %% Test error case - invalid path {error, _} = py:activate_venv(<<"/nonexistent/path">>), @@ -648,6 +655,41 @@ test_venv(_Config) -> ok. +test_venv_pth(_Config) -> + %% Verify .pth files are processed (editable installs) + TmpDir = <<"/tmp/erlang_python_test_venv_pth">>, + PkgSrc = <<"/tmp/erlang_python_test_pth_src">>, + + {ok, PyVer} = py:eval(<<"f'python{__import__(\"sys\").version_info.major}.{__import__(\"sys\").version_info.minor}'">>), + SitePackages = <>, + + %% Create fake venv with a .pth file pointing at PkgSrc + {ok, _} = py:call(os, makedirs, [SitePackages], #{exist_ok => true}), + {ok, _} = py:call(os, makedirs, [PkgSrc], #{exist_ok => true}), + PthFile = <>, + {ok, _} = py:eval(<<"open(pf, 'w').write(pd + '\\n')">>, #{pf => PthFile, pd => PkgSrc}), + + %% Drop a module in PkgSrc so we can verify it's importable + ModFile = <>, + {ok, _} = py:eval(<<"open(f, 'w').write('answer = 42\\n')">>, #{f => ModFile}), + + {ok, false} = py:eval(<<"pd in __import__('sys').path">>, #{pd => PkgSrc}), + + %% Activate and verify paths and import + ok = py:activate_venv(TmpDir), + {ok, 0} = py:eval(<<"__import__('sys').path.index(sp)">>, #{sp => SitePackages}), + {ok, 1} = py:eval(<<"__import__('sys').path.index(pd)">>, #{pd => PkgSrc}), + {ok, 42} = py:eval(<<"__import__('ep_test_editable_mod').answer">>), + + %% Deactivate and verify cleanup + ok = py:deactivate_venv(), + {ok, false} = py:eval(<<"pd in __import__('sys').path">>, #{pd => PkgSrc}), + {ok, false} = py:eval(<<"sp in __import__('sys').path">>, #{sp => SitePackages}), + + {ok, _} = py:call(shutil, rmtree, [TmpDir], #{ignore_errors => true}), + {ok, _} = py:call(shutil, rmtree, [PkgSrc], #{ignore_errors => true}), + ok. + %%% ============================================================================ %%% Scalability Tests %%% ============================================================================