Skip to content

Commit dfe1f8e

Browse files
committed
fix: process .pth files in activate_venv/1 and fix deactivate_venv/0
1 parent d188ca5 commit dfe1f8e

File tree

3 files changed

+75
-10
lines changed

3 files changed

+75
-10
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Fixed
6+
7+
- **`activate_venv/1` now processes `.pth` files** - Uses `site.addsitedir()` instead of
8+
`sys.path.insert()` so that editable installs (uv, pip -e, poetry) work correctly.
9+
New paths are moved to the front of `sys.path` for proper priority.
10+
11+
- **`deactivate_venv/0` now restores `sys.path`** - The previous implementation used
12+
`py:eval` with semicolon-separated statements which silently failed (eval only accepts
13+
expressions). Switched to `py:exec` for correct statement execution.
14+
315
## 1.8.1 (2026-02-25)
416

517
### Fixed

src/py.erl

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,11 @@ parallel(Calls) when is_list(Calls) ->
490490
%% This modifies sys.path to use packages from the specified venv.
491491
%% The venv path should be the root directory (containing bin/lib folders).
492492
%%
493+
%% `.pth' files in the venv's site-packages directory are processed, so
494+
%% editable installs created by uv, pip, or any PEP 517/660 compliant tool
495+
%% work correctly. New paths are inserted at the front of sys.path so that
496+
%% venv packages take priority over system packages.
497+
%%
493498
%% Example:
494499
%% ```
495500
%% ok = py:activate_venv(<<"/path/to/myenv">>).
@@ -504,12 +509,16 @@ activate_venv(VenvPath) ->
504509
case eval(<<"__import__('os').path.isdir(sp)">>, #{sp => SitePackages}) of
505510
{ok, true} ->
506511
%% Save original path if not already saved
507-
_ = eval(<<"setattr(__import__('sys'), '_original_path', __import__('sys').path.copy()) if not hasattr(__import__('sys'), '_original_path') else None">>),
512+
{ok, _} = eval(<<"setattr(__import__('sys'), '_original_path', __import__('sys').path.copy()) if not hasattr(__import__('sys'), '_original_path') else None">>),
508513
%% Set venv info
509-
_ = eval(<<"setattr(__import__('sys'), '_active_venv', vp)">>, #{vp => VenvBin}),
510-
_ = eval(<<"setattr(__import__('sys'), '_venv_site_packages', sp)">>, #{sp => SitePackages}),
511-
%% Add to sys.path
512-
_ = eval(<<"__import__('sys').path.insert(0, sp) if sp not in __import__('sys').path else None">>, #{sp => SitePackages}),
514+
{ok, _} = eval(<<"setattr(__import__('sys'), '_active_venv', vp)">>, #{vp => VenvBin}),
515+
{ok, _} = eval(<<"setattr(__import__('sys'), '_venv_site_packages', sp)">>, #{sp => SitePackages}),
516+
%% Add site-packages and process .pth files (editable installs)
517+
ok = exec(<<"import site as _site, sys as _sys\n"
518+
"_b = frozenset(_sys.path)\n"
519+
"_site.addsitedir(_sys._venv_site_packages)\n"
520+
"_sys.path[:] = [p for p in _sys.path if p not in _b] + [p for p in _sys.path if p in _b]\n"
521+
"del _site, _sys, _b\n">>),
513522
ok;
514523
{ok, false} ->
515524
{error, {invalid_venv, SitePackages}};
@@ -523,10 +532,12 @@ activate_venv(VenvPath) ->
523532
deactivate_venv() ->
524533
case eval(<<"hasattr(__import__('sys'), '_original_path')">>) of
525534
{ok, true} ->
526-
_ = eval(<<"__import__('sys').path.clear(); __import__('sys').path.extend(__import__('sys')._original_path)">>),
527-
_ = eval(<<"delattr(__import__('sys'), '_original_path')">>),
528-
_ = eval(<<"delattr(__import__('sys'), '_active_venv') if hasattr(__import__('sys'), '_active_venv') else None">>),
529-
_ = eval(<<"delattr(__import__('sys'), '_venv_site_packages') if hasattr(__import__('sys'), '_venv_site_packages') else None">>),
535+
ok = exec(<<"import sys as _sys\n"
536+
"_sys.path[:] = _sys._original_path\n"
537+
"del _sys\n">>),
538+
{ok, _} = eval(<<"delattr(__import__('sys'), '_original_path')">>),
539+
{ok, _} = eval(<<"delattr(__import__('sys'), '_active_venv') if hasattr(__import__('sys'), '_active_venv') else None">>),
540+
{ok, _} = eval(<<"delattr(__import__('sys'), '_venv_site_packages') if hasattr(__import__('sys'), '_venv_site_packages') else None">>),
530541
ok;
531542
{ok, false} ->
532543
ok;

test/py_SUITE.erl

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
test_subinterp_supported/1,
3838
test_parallel_execution/1,
3939
test_venv/1,
40+
test_venv_pth/1,
4041
%% New scalability tests
4142
test_execution_mode/1,
4243
test_num_executors/1,
@@ -87,6 +88,7 @@ all() ->
8788
test_subinterp_supported,
8889
test_parallel_execution,
8990
test_venv,
91+
test_venv_pth,
9092
%% Scalability tests
9193
test_execution_mode,
9294
test_num_executors,
@@ -634,11 +636,16 @@ test_venv(_Config) ->
634636
true = is_binary(maps:get(<<"venv_path">>, Info)),
635637
true = is_binary(maps:get(<<"site_packages">>, Info)),
636638

639+
%% site-packages must be in sys.path and at position 0
640+
{ok, true} = py:eval(<<"sp in __import__('sys').path">>, #{sp => SitePackages}),
641+
{ok, 0} = py:eval(<<"__import__('sys').path.index(sp)">>, #{sp => SitePackages}),
642+
637643
%% Deactivate
638644
ok = py:deactivate_venv(),
639645

640-
%% Verify deactivated
646+
%% Verify deactivated: venv_info and sys.path both restored
641647
{ok, #{<<"active">> := false}} = py:venv_info(),
648+
{ok, false} = py:eval(<<"sp in __import__('sys').path">>, #{sp => SitePackages}),
642649

643650
%% Test error case - invalid path
644651
{error, _} = py:activate_venv(<<"/nonexistent/path">>),
@@ -648,6 +655,41 @@ test_venv(_Config) ->
648655

649656
ok.
650657

658+
test_venv_pth(_Config) ->
659+
%% Verify .pth files are processed (editable installs)
660+
TmpDir = <<"/tmp/erlang_python_test_venv_pth">>,
661+
PkgSrc = <<"/tmp/erlang_python_test_pth_src">>,
662+
663+
{ok, PyVer} = py:eval(<<"f'python{__import__(\"sys\").version_info.major}.{__import__(\"sys\").version_info.minor}'">>),
664+
SitePackages = <<TmpDir/binary, "/lib/", PyVer/binary, "/site-packages">>,
665+
666+
%% Create fake venv with a .pth file pointing at PkgSrc
667+
{ok, _} = py:call(os, makedirs, [SitePackages], #{exist_ok => true}),
668+
{ok, _} = py:call(os, makedirs, [PkgSrc], #{exist_ok => true}),
669+
PthFile = <<SitePackages/binary, "/test_editable.pth">>,
670+
{ok, _} = py:eval(<<"open(pf, 'w').write(pd + '\\n')">>, #{pf => PthFile, pd => PkgSrc}),
671+
672+
%% Drop a module in PkgSrc so we can verify it's importable
673+
ModFile = <<PkgSrc/binary, "/ep_test_editable_mod.py">>,
674+
{ok, _} = py:eval(<<"open(f, 'w').write('answer = 42\\n')">>, #{f => ModFile}),
675+
676+
{ok, false} = py:eval(<<"pd in __import__('sys').path">>, #{pd => PkgSrc}),
677+
678+
%% Activate and verify paths and import
679+
ok = py:activate_venv(TmpDir),
680+
{ok, 0} = py:eval(<<"__import__('sys').path.index(sp)">>, #{sp => SitePackages}),
681+
{ok, 1} = py:eval(<<"__import__('sys').path.index(pd)">>, #{pd => PkgSrc}),
682+
{ok, 42} = py:eval(<<"__import__('ep_test_editable_mod').answer">>),
683+
684+
%% Deactivate and verify cleanup
685+
ok = py:deactivate_venv(),
686+
{ok, false} = py:eval(<<"pd in __import__('sys').path">>, #{pd => PkgSrc}),
687+
{ok, false} = py:eval(<<"sp in __import__('sys').path">>, #{sp => SitePackages}),
688+
689+
{ok, _} = py:call(shutil, rmtree, [TmpDir], #{ignore_errors => true}),
690+
{ok, _} = py:call(shutil, rmtree, [PkgSrc], #{ignore_errors => true}),
691+
ok.
692+
651693
%%% ============================================================================
652694
%%% Scalability Tests
653695
%%% ============================================================================

0 commit comments

Comments
 (0)