diff --git a/mempalace/__init__.py b/mempalace/__init__.py index a2ce132..9db4124 100644 --- a/mempalace/__init__.py +++ b/mempalace/__init__.py @@ -9,13 +9,18 @@ def _strip_leaked_pythonpath() -> None: # Venvs inherit PYTHONPATH; on multi-Python systems it can cause # transitive imports to load compiled extensions (pydantic_core, # chromadb_rust_bindings) from the wrong ABI. Drop the env var - # and remove the sys.path entries the interpreter populated from - # it before the consumer imports anything that ships compiled code. + # and remove sys.path entries the interpreter populated from it. + # Comparison normalizes case + separators so Windows paths and + # trailing-separator quirks do not slip through string equality. leaked = os.environ.pop("PYTHONPATH", None) if not leaked: return - leaked_entries = {p for p in leaked.split(os.pathsep) if p} - sys.path[:] = [p for p in sys.path if p not in leaked_entries] + + def _norm(path: str) -> str: + return os.path.normcase(os.path.normpath(path)) + + leaked_entries = {_norm(p.strip()) for p in leaked.split(os.pathsep) if p.strip()} + sys.path[:] = [p for p in sys.path if _norm(p) not in leaked_entries] _strip_leaked_pythonpath() diff --git a/tests/test_init.py b/tests/test_init.py index 643c067..cc47f8c 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -7,18 +7,25 @@ import sys import pytest +_LEAK_PREFIX = "/__mempalace_leak_test_sentinel__" + + @pytest.mark.parametrize( "pythonpath", [ - "/tmp/some/leaked/path", - f"/tmp/leak-a{os.pathsep}/tmp/leak-b", + f"{_LEAK_PREFIX}/single", + f"{_LEAK_PREFIX}/a{os.pathsep}{_LEAK_PREFIX}/b", + f"{_LEAK_PREFIX}/with-trailing{os.sep}", + f"{os.pathsep}{_LEAK_PREFIX}/leading-sep", + "", None, ], + ids=["single", "multi", "trailing-sep", "leading-pathsep", "empty", "unset"], ) def test_init_strips_leaked_pythonpath(pythonpath): - """Package init must clear PYTHONPATH (env) AND remove its entries - from sys.path so compiled-extension imports cannot resolve from a - leaked location.""" + """Package init must clear PYTHONPATH (env) AND remove all of its + leaked entries from sys.path, normalizing case and trailing + separators so Windows and POSIX behave alike.""" env = os.environ.copy() if pythonpath is None: env.pop("PYTHONPATH", None) @@ -28,8 +35,11 @@ def test_init_strips_leaked_pythonpath(pythonpath): "import mempalace, os, sys; " f"leaked = {pythonpath!r}; " "print('ENV:', repr(os.environ.get('PYTHONPATH'))); " - "entries = leaked.split(os.pathsep) if leaked else []; " - "leaked_in_path = any(e in sys.path for e in entries); " + "tokens = leaked.split(os.pathsep) if leaked else []; " + "entries = [t.strip() for t in tokens if t.strip()]; " + "norm = lambda p: os.path.normcase(os.path.normpath(p)); " + "leaked_norm = {norm(e) for e in entries}; " + "leaked_in_path = any(norm(p) in leaked_norm for p in sys.path); " "print('SYSPATH_LEAK:', leaked_in_path)" ) result = subprocess.run( @@ -37,10 +47,13 @@ def test_init_strips_leaked_pythonpath(pythonpath): env=env, capture_output=True, text=True, - check=True, + check=False, ) + diag = ( + f"input={pythonpath!r}; rc={result.returncode}; " + f"stdout={result.stdout!r}; stderr={result.stderr!r}" + ) + assert result.returncode == 0, f"subprocess failed: {diag}" out = result.stdout - assert "ENV: None" in out, f"PYTHONPATH not cleared (input={pythonpath!r}): {out!r}" - assert ( - "SYSPATH_LEAK: False" in out - ), f"sys.path retains leaked entry (input={pythonpath!r}): {out!r}" + assert "ENV: None" in out, f"PYTHONPATH not cleared: {diag}" + assert "SYSPATH_LEAK: False" in out, f"sys.path retains leaked entry: {diag}"