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; + } +}