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