Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 15 additions & 40 deletions tests/Stampeded.Core.Tests/PythonInterpreterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand All @@ -42,7 +46,7 @@ public void WithoutOneTheAnswerIsWhateverPythonMeansHere()
}
finally
{
Directory.Delete(repo, recursive: true);
TempDirectory.Delete(repo);
}
}

Expand All @@ -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):
Expand Down Expand Up @@ -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);
}
}

/// <summary>
/// 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.
/// </summary>
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");

/// <summary>The system interpreter's major.minor, which is what names its site-packages
/// directory.</summary>
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"));
Expand Down
2 changes: 1 addition & 1 deletion tests/Stampeded.Core.Tests/PythonLspTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ from greeting import greet
finally
{
connection?.Dispose();
Directory.Delete(dir, recursive: true);
TempDirectory.Delete(dir);
}
}

Expand Down
39 changes: 13 additions & 26 deletions tests/Stampeded.Core.Tests/PythonProjectConfigTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
/// <summary>The reader's own clone: an environment with the package the project depends
/// on, which is the thing the worktree does not have.</summary>
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"));
Expand Down
51 changes: 51 additions & 0 deletions tests/Stampeded.Core.Tests/PythonVenv.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using Stampeded.Core.Lsp;

namespace Stampeded.Core.Tests;

/// <summary>
/// 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.
/// </summary>
static class PythonVenv
{
/// <summary>The interpreter inside a new environment at <paramref name="root"/>, or null on
/// a machine with no Python - where a test that needs one has nothing to say.</summary>
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;
}

/// <summary>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.</summary>
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");

/// <summary>The command's standard output, or null if it did not run or failed.</summary>
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;
}
}