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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
29 changes: 20 additions & 9 deletions src/py.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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">>).
Expand All @@ -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}};
Expand All @@ -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;
Expand Down
44 changes: 43 additions & 1 deletion test/py_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -87,6 +88,7 @@ all() ->
test_subinterp_supported,
test_parallel_execution,
test_venv,
test_venv_pth,
%% Scalability tests
test_execution_mode,
test_num_executors,
Expand Down Expand Up @@ -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">>),
Expand All @@ -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 = <<TmpDir/binary, "/lib/", PyVer/binary, "/site-packages">>,

%% 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 = <<SitePackages/binary, "/test_editable.pth">>,
{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 = <<PkgSrc/binary, "/ep_test_editable_mod.py">>,
{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
%%% ============================================================================
Expand Down