diff --git a/StabilityMatrix.Core/Python/PyVenvConfigHelper.cs b/StabilityMatrix.Core/Python/PyVenvConfigHelper.cs new file mode 100644 index 000000000..02897edd5 --- /dev/null +++ b/StabilityMatrix.Core/Python/PyVenvConfigHelper.cs @@ -0,0 +1,96 @@ +using System.Text; +using NLog; + +namespace StabilityMatrix.Core.Python; + +/// +/// 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. +/// +public static class PyVenvConfigHelper +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + /// + /// Write or update the path keys in a pyvenv.cfg file. + /// Sets home, base-prefix, base-exec-prefix to + /// and base-executable to . + /// Other existing keys are preserved in their original order. + /// + 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 + ); + } +} diff --git a/StabilityMatrix.Core/Python/PyVenvRunner.cs b/StabilityMatrix.Core/Python/PyVenvRunner.cs index ff283fd99..5ad4662b7 100644 --- a/StabilityMatrix.Core/Python/PyVenvRunner.cs +++ b/StabilityMatrix.Core/Python/PyVenvRunner.cs @@ -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; @@ -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; diff --git a/StabilityMatrix.Core/Python/UvManager.cs b/StabilityMatrix.Core/Python/UvManager.cs index 8c7cd9ebc..08ca7a2b6 100644 --- a/StabilityMatrix.Core/Python/UvManager.cs +++ b/StabilityMatrix.Core/Python/UvManager.cs @@ -149,15 +149,21 @@ public async Task> 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, @@ -287,6 +293,10 @@ public async Task> 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) }) @@ -294,7 +304,13 @@ public async Task> ListAvailablePythonsAsync( 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(); diff --git a/StabilityMatrix.Core/Python/UvVenvRunner.cs b/StabilityMatrix.Core/Python/UvVenvRunner.cs index 6fa69fd6e..8e640a1de 100644 --- a/StabilityMatrix.Core/Python/UvVenvRunner.cs +++ b/StabilityMatrix.Core/Python/UvVenvRunner.cs @@ -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; @@ -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;