Skip to content
Draft
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
96 changes: 96 additions & 0 deletions StabilityMatrix.Core/Python/PyVenvConfigHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System.Text;
using NLog;

namespace StabilityMatrix.Core.Python;

/// <summary>
/// Helper for reading and writing pyvenv.cfg files.
/// pyvenv.cfg is a simple key = value format without INI sections,
/// so we manipulate it directly instead of using a section-based INI parser.
/// </summary>
public static class PyVenvConfigHelper
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();

/// <summary>
/// Write or update the path keys in a pyvenv.cfg file.
/// Sets home, base-prefix, base-exec-prefix to <paramref name="pythonDirectory"/>
/// and base-executable to <paramref name="baseExecutable"/>.
/// Other existing keys are preserved in their original order.
/// </summary>
public static void WritePyVenvCfg(string cfgPath, string pythonDirectory, string baseExecutable)
{
var lines = File.ReadAllLines(cfgPath);
var sb = new StringBuilder();
var hasHome = false;
var hasBasePrefix = false;
var hasBaseExecPrefix = false;
var hasBaseExecutable = false;

foreach (var line in lines)
{
var trimmed = line.Trim();
var eqIdx = trimmed.IndexOf('=');

// Preserve lines without an = sign (comments, blank lines, etc.)
if (eqIdx < 0)
{
sb.AppendLine(line);
continue;
}

var key = trimmed.Substring(0, eqIdx).TrimEnd();

if (key.Equals("home", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine($"home = {pythonDirectory}");
hasHome = true;
}
else if (key.Equals("base-prefix", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine($"base-prefix = {pythonDirectory}");
hasBasePrefix = true;
}
else if (key.Equals("base-exec-prefix", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine($"base-exec-prefix = {pythonDirectory}");
hasBaseExecPrefix = true;
}
else if (key.Equals("base-executable", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine($"base-executable = {baseExecutable}");
hasBaseExecutable = true;
}
else
{
sb.AppendLine(line);
}
}

// Append any missing keys
if (!hasHome)
{
sb.AppendLine($"home = {pythonDirectory}");
}
if (!hasBasePrefix)
{
sb.AppendLine($"base-prefix = {pythonDirectory}");
}
if (!hasBaseExecPrefix)
{
sb.AppendLine($"base-exec-prefix = {pythonDirectory}");
}
if (!hasBaseExecutable)
{
sb.AppendLine($"base-executable = {baseExecutable}");
}

File.WriteAllText(cfgPath, sb.ToString());

Logger.Debug(
"Wrote pyvenv.cfg: home={PyDir}, base-executable={PyExe}",
pythonDirectory,
baseExecutable
);
}
}
22 changes: 4 additions & 18 deletions StabilityMatrix.Core/Python/PyVenvRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using System.Text;
using System.Text.Json;
using NLog;
using Salaros.Configuration;
using StabilityMatrix.Core.Exceptions;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
Expand Down Expand Up @@ -202,25 +201,12 @@ private void SetPyvenvCfg(string pythonDirectory, bool force = false)

Logger.Info("Updating pyvenv.cfg with embedded Python directory {PyDir}", pythonDirectory);

// Insert a top section
var topSection = "[top]" + Environment.NewLine;
var cfg = new ConfigParser(topSection + File.ReadAllText(cfgPath));

// Need to set all path keys - home, base-prefix, base-exec-prefix, base-executable
cfg.SetValue("top", "home", pythonDirectory);
cfg.SetValue("top", "base-prefix", pythonDirectory);

cfg.SetValue("top", "base-exec-prefix", pythonDirectory);

cfg.SetValue(
"top",
"base-executable",
Path.Combine(pythonDirectory, Compat.IsWindows ? "python.exe" : RelativePythonPath)
var baseExecutable = Path.Combine(
pythonDirectory,
Compat.IsWindows ? "python.exe" : RelativePythonPath
);

// Convert to string for writing, strip the top section
var cfgString = cfg.ToString()!.Replace(topSection, "");
File.WriteAllText(cfgPath, cfgString);
PyVenvConfigHelper.WritePyVenvCfg(cfgPath, pythonDirectory, baseExecutable);

// Update last set path
lastSetPyvenvCfgPath = pythonDirectory;
Expand Down
22 changes: 19 additions & 3 deletions StabilityMatrix.Core/Python/UvManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,21 @@ public async Task<IReadOnlyList<UvPythonInfo>> ListAvailablePythonsAsync(
return pythons.AsReadOnly();
}

// When only installed Pythons are requested, exclude entries with no path (not installed).
// Also guard against null paths reaching PyInstallation constructor which throws ArgumentException.
var filteredPythons = uvPythonListEntries
.Where(e => e.Path == null || e.Path.StartsWith(uvPythonInstallPath))
.Where(e =>
installedOnly
? e.Path != null && e.Path.StartsWith(uvPythonInstallPath)
: e.Path == null || e.Path.StartsWith(uvPythonInstallPath)
)
.Where(e =>
settingsManager.Settings.ShowAllAvailablePythonVersions
|| (!e.Version.Contains("a") && !e.Version.Contains("b"))
)
.Select(e => new UvPythonInfo
{
InstallPath = Path.GetDirectoryName(e.Path) ?? string.Empty,
InstallPath = e.Path != null ? (Path.GetDirectoryName(e.Path) ?? string.Empty) : string.Empty,
Version = e.VersionParts,
Architecture = e.Arch,
IsInstalled = e.Path != null,
Expand Down Expand Up @@ -287,14 +293,24 @@ public async Task<IReadOnlyList<UvPythonInfo>> ListAvailablePythonsAsync(
Logger.Debug($"Attempting fallback path discovery in central directory: {uvPythonInstallPath}");
try
{
// Build a version prefix that won't accidentally match higher minor/patch versions.
// e.g. "3.12." so that "cpython-3.12.10" matches but "cpython-3.13.12" does not.
var versionPrefix = $"{version.Major}.{version.Minor}.";

var subdirectories = Directory.GetDirectories(uvPythonInstallPath);
var potentialDirs = subdirectories
.Select(dir => new { Path = dir, DirInfo = new DirectoryInfo(dir) })
.Where(x =>
x.DirInfo.Name.StartsWith("cpython-", StringComparison.OrdinalIgnoreCase)
|| x.DirInfo.Name.StartsWith("pypy-", StringComparison.OrdinalIgnoreCase)
)
.Where(x => x.DirInfo.Name.Contains($"{version.Major}.{version.Minor}"))
.Where(x =>
x.DirInfo.Name.Contains(versionPrefix)
|| x.DirInfo.Name.EndsWith(
$"-{version.Major}.{version.Minor}",
StringComparison.OrdinalIgnoreCase
)
)
.OrderByDescending(x => x.DirInfo.CreationTimeUtc)
.ToList();

Expand Down
22 changes: 4 additions & 18 deletions StabilityMatrix.Core/Python/UvVenvRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using System.Text;
using System.Text.Json;
using NLog;
using Salaros.Configuration;
using StabilityMatrix.Core.Exceptions;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
Expand Down Expand Up @@ -208,25 +207,12 @@ private void SetPyvenvCfg(string pythonDirectory, bool force = false)

Logger.Info("Updating pyvenv.cfg with embedded Python directory {PyDir}", pythonDirectory);

// Insert a top section
var topSection = "[top]" + Environment.NewLine;
var cfg = new ConfigParser(topSection + File.ReadAllText(cfgPath));

// Need to set all path keys - home, base-prefix, base-exec-prefix, base-executable
cfg.SetValue("top", "home", pythonDirectory);
cfg.SetValue("top", "base-prefix", pythonDirectory);

cfg.SetValue("top", "base-exec-prefix", pythonDirectory);

cfg.SetValue(
"top",
"base-executable",
Path.Combine(pythonDirectory, Compat.IsWindows ? "python.exe" : RelativePythonPath)
var baseExecutable = Path.Combine(
pythonDirectory,
Compat.IsWindows ? "python.exe" : RelativePythonPath
);

// Convert to string for writing, strip the top section
var cfgString = cfg.ToString()!.Replace(topSection, "");
File.WriteAllText(cfgPath, cfgString);
PyVenvConfigHelper.WritePyVenvCfg(cfgPath, pythonDirectory, baseExecutable);

// Update last set path
lastSetPyvenvCfgPath = pythonDirectory;
Expand Down
Loading