Skip to content
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