From 66c64b31d90144592a01fbfda07f3f667f05bd6c Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Mon, 31 Aug 2026 22:16:14 +0200 Subject: [PATCH] Build the venv a Python test needs instead of imitating one The first CI run on three operating systems found four failures, all in the Python tests and none in what they were testing. The environment they set up was hand-built: a pyvenv.cfg beside a symlink at /usr/bin/python3, on the argument that a real one would take a minute and prove the same thing. It does not. Whether Python recognises that as a virtual environment depends on what the symlink resolves to, and on macOS /usr/bin/python3 is the Command Line Tools stub, which re-execs the real binary - so sys.executable is the framework's path, the pyvenv.cfg beside the symlink is never seen, and the environment's site-packages never reaches sys.path. Pyright was handed an interpreter that answers nothing ("Unable to get Python version from interpreter") and resolved the import to nothing. The layout was POSIX-only besides: a venv on Windows keeps its packages in Lib\site-packages. `python -m venv --without-pip` is right on every platform and costs a tenth of a second, and the interpreter reports where its own packages go, so nothing is guessed. Finding the Python to build it with goes through LanguageServers.OnPath, which already knows that a bare command name on Windows means PATHEXT; where there is none, the test says so and is ignored rather than throwing "no system python3", which is what every Windows machine got. The fourth failure was cleanup: deleting the workspace while the server is still letting go of it fails on Windows and fails a test whose assertions all passed. TempDirectory.Delete exists for that and is what every other test uses. Assisted-by: Claude:claude-opus-5:Claude Code --- .../PythonInterpreterTests.cs | 55 +++++-------------- tests/Stampeded.Core.Tests/PythonLspTests.cs | 2 +- .../PythonProjectConfigTests.cs | 39 +++++-------- tests/Stampeded.Core.Tests/PythonVenv.cs | 51 +++++++++++++++++ 4 files changed, 80 insertions(+), 67 deletions(-) create mode 100644 tests/Stampeded.Core.Tests/PythonVenv.cs diff --git a/tests/Stampeded.Core.Tests/PythonInterpreterTests.cs b/tests/Stampeded.Core.Tests/PythonInterpreterTests.cs index e823f3f..e4fe5c1 100644 --- a/tests/Stampeded.Core.Tests/PythonInterpreterTests.cs +++ b/tests/Stampeded.Core.Tests/PythonInterpreterTests.cs @@ -18,13 +18,17 @@ public void TheProjectsOwnEnvironmentIsPreferredToWhateverIsOnPath() string repo = NewTempDir(); try { - string interpreter = FakeEnvironment(Path.Combine(repo, ".venv")); + if (PythonVenv.Create(Path.Combine(repo, ".venv")) is not { } interpreter) + { + Assert.Ignore("no Python interpreter available"); + return; + } Assert.That(PythonEnvironment.InterpreterFor(repo), Is.EqualTo(interpreter)); } finally { - Directory.Delete(repo, recursive: true); + TempDirectory.Delete(repo); } } @@ -42,7 +46,7 @@ public void WithoutOneTheAnswerIsWhateverPythonMeansHere() } finally { - Directory.Delete(repo, recursive: true); + TempDirectory.Delete(repo); } } @@ -60,8 +64,12 @@ public async Task AnImportResolvesIntoAnEnvironmentThatIsNotInTheWorktree() try { // The clone: an environment with one package in it, and nothing else. - string interpreter = FakeEnvironment(Path.Combine(repo, ".venv")); - string package = Path.Combine(repo, ".venv", "lib", "python" + PythonVersion(), "site-packages", "mylib"); + if (PythonVenv.Create(Path.Combine(repo, ".venv")) is not { } interpreter) + { + Assert.Ignore("no Python interpreter available"); + return; + } + string package = Path.Combine(PythonVenv.SitePackages(interpreter), "mylib"); Directory.CreateDirectory(package); File.WriteAllText(Path.Combine(package, "__init__.py"), """ def hello(name): @@ -96,44 +104,11 @@ import mylib finally { connection?.Dispose(); - Directory.Delete(repo, recursive: true); - Directory.Delete(worktree, recursive: true); + TempDirectory.Delete(repo); + TempDirectory.Delete(worktree); } } - /// - /// A virtual environment as Python itself recognises one: a pyvenv.cfg beside a bin - /// directory whose python is the system's. Building a real one would take a minute and - /// prove the same thing, which is that an interpreter reports its own site-packages. - /// - static string FakeEnvironment(string root) - { - string bin = OperatingSystem.IsWindows() ? "Scripts" : "bin"; - Directory.CreateDirectory(Path.Combine(root, bin)); - File.WriteAllText(Path.Combine(root, "pyvenv.cfg"), - $"home = /usr/bin\nversion = {PythonVersion()}\n"); - string interpreter = Path.Combine(root, bin, OperatingSystem.IsWindows() ? "python.exe" : "python"); - File.CreateSymbolicLink(interpreter, SystemPython()); - return interpreter; - } - - static string SystemPython() - => new[] { "/usr/bin/python3", "/usr/local/bin/python3" }.FirstOrDefault(File.Exists) - ?? throw new InvalidOperationException("no system python3"); - - /// The system interpreter's major.minor, which is what names its site-packages - /// directory. - static string PythonVersion() - { - var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(SystemPython()) { - ArgumentList = { "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" }, - RedirectStandardOutput = true, - })!; - string version = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(); - return version; - } - static string NewTempDir() { string dir = Path.Combine(Path.GetTempPath(), "stampeded-pyenv-" + Guid.NewGuid().ToString("N")); diff --git a/tests/Stampeded.Core.Tests/PythonLspTests.cs b/tests/Stampeded.Core.Tests/PythonLspTests.cs index 7529e62..e99b23a 100644 --- a/tests/Stampeded.Core.Tests/PythonLspTests.cs +++ b/tests/Stampeded.Core.Tests/PythonLspTests.cs @@ -89,7 +89,7 @@ from greeting import greet finally { connection?.Dispose(); - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } diff --git a/tests/Stampeded.Core.Tests/PythonProjectConfigTests.cs b/tests/Stampeded.Core.Tests/PythonProjectConfigTests.cs index 9ebccc5..20d34e0 100644 --- a/tests/Stampeded.Core.Tests/PythonProjectConfigTests.cs +++ b/tests/Stampeded.Core.Tests/PythonProjectConfigTests.cs @@ -25,7 +25,11 @@ public async Task AProjectConfigNamingItsOwnVenvDoesNotBlindTheReview() LspConnection? connection = null; try { - string interpreter = FakeVenv(clone); + if (CloneWithPackage(clone) is not { } interpreter) + { + Assert.Ignore("no Python interpreter available"); + return; + } // The worktree is a checkout: it has the committed config, and no environment. File.WriteAllText(Path.Combine(worktree, "pyproject.toml"), """ [tool.pyright] @@ -55,43 +59,26 @@ import mylib finally { connection?.Dispose(); - Directory.Delete(clone, recursive: true); - Directory.Delete(worktree, recursive: true); + TempDirectory.Delete(clone); + TempDirectory.Delete(worktree); } } - static string FakeVenv(string root) + /// The reader's own clone: an environment with the package the project depends + /// on, which is the thing the worktree does not have. + static string? CloneWithPackage(string root) { - string version = PythonVersionOf(SystemPython()); - string bin = OperatingSystem.IsWindows() ? "Scripts" : "bin"; - Directory.CreateDirectory(Path.Combine(root, ".venv", bin)); - string packages = Path.Combine(root, ".venv", "lib", "python" + version, "site-packages", "mylib"); + if (PythonVenv.Create(Path.Combine(root, ".venv")) is not { } interpreter) + return null; + string packages = Path.Combine(PythonVenv.SitePackages(interpreter), "mylib"); Directory.CreateDirectory(packages); File.WriteAllText(Path.Combine(packages, "__init__.py"), """ def hello(name): return "hi " + name """); - File.WriteAllText(Path.Combine(root, ".venv", "pyvenv.cfg"), $"home = /usr/bin\nversion = {version}\n"); - string interpreter = Path.Combine(root, ".venv", bin, OperatingSystem.IsWindows() ? "python.exe" : "python"); - File.CreateSymbolicLink(interpreter, SystemPython()); return interpreter; } - static string SystemPython() - => new[] { "/usr/bin/python3", "/usr/local/bin/python3" }.FirstOrDefault(File.Exists) - ?? throw new InvalidOperationException("no system python3"); - - static string PythonVersionOf(string interpreter) - { - var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(interpreter) { - ArgumentList = { "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" }, - RedirectStandardOutput = true, - })!; - string version = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(); - return version; - } - static string NewTempDir() { string dir = Path.Combine(Path.GetTempPath(), "stampeded-pyproject-" + Guid.NewGuid().ToString("N")); diff --git a/tests/Stampeded.Core.Tests/PythonVenv.cs b/tests/Stampeded.Core.Tests/PythonVenv.cs new file mode 100644 index 0000000..07ed91f --- /dev/null +++ b/tests/Stampeded.Core.Tests/PythonVenv.cs @@ -0,0 +1,51 @@ +using Stampeded.Core.Lsp; + +namespace Stampeded.Core.Tests; + +/// +/// A real virtual environment for a test to point a language server at. +/// +/// Real because a hand-built one is not an interpreter: a pyvenv.cfg beside a symlink is how a +/// venv looks, but whether Python recognises it depends on what the symlink resolves to. On +/// macOS /usr/bin/python3 is the Command Line Tools stub, which re-execs the real binary, so +/// sys.executable is the framework's path, the planted pyvenv.cfg is never seen and the +/// environment's site-packages is never on sys.path. `python -m venv` gets this right on every +/// platform, takes a fraction of a second without pip, and lays the directories out the way the +/// platform actually does. +/// +static class PythonVenv +{ + /// The interpreter inside a new environment at , or null on + /// a machine with no Python - where a test that needs one has nothing to say. + public static string? Create(string root) + { + if ((LanguageServers.OnPath("python3") ?? LanguageServers.OnPath("python")) is not { } python) + return null; + // No pip: it is a download and a second or two, and nothing here installs a package. + if (Run(python, "-m", "venv", "--without-pip", root) is null) + return null; + string interpreter = Path.Combine(root, OperatingSystem.IsWindows() ? "Scripts" : "bin", + OperatingSystem.IsWindows() ? "python.exe" : "python"); + return File.Exists(interpreter) ? interpreter : null; + } + + /// Where a package has to be written for that interpreter to import it, asked of + /// the interpreter rather than guessed - the layout differs by platform and by version. + public static string SitePackages(string interpreter) + => Run(interpreter, "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])") + ?? throw new InvalidOperationException($"{interpreter} does not report its site-packages"); + + /// The command's standard output, or null if it did not run or failed. + static string? Run(string executable, params string[] arguments) + { + var start = new System.Diagnostics.ProcessStartInfo(executable) { RedirectStandardOutput = true }; + foreach (string argument in arguments) + start.ArgumentList.Add(argument); + using var process = System.Diagnostics.Process.Start(start); + if (process is null) + return null; + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(TimeSpan.FromMinutes(1)); + return process.HasExited && process.ExitCode == 0 ? output : null; + } +}