From 4dccdf17abd56c86ba8e1cfacb2919dd64a97b12 Mon Sep 17 00:00:00 2001 From: NeuralFault Date: Wed, 29 Jul 2026 13:00:13 +0000 Subject: [PATCH 1/4] Replace ConfigParser-based pyvenv.cfg writing with direct line reader/writer - Remove dependency on Salaros.Configuration.ConfigParser for pyvenv.cfg serialization in both PyVenvRunner and UvVenvRunner SetPyvenvCfg methods - Adds PyVenvConfigHelper.WritePyVenvCfg that reads, updates, and writes the key=value lines directly without section-header round-tripping - Fixes silent failure where ConfigParser.SetValue would not update the existing "home" key in a sectionless INI file, while successfully adding new keys (base-prefix, base-exec-prefix, base-executable), producing a corrupt config with mixed Python distribution paths - Preserve all non-path keys (include-system-site-packages, version, executable, command, etc.) in their original line order - Append missing path keys if the venv was created by an older version that did not write them --- .../Python/PyVenvConfigHelper.cs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 StabilityMatrix.Core/Python/PyVenvConfigHelper.cs diff --git a/StabilityMatrix.Core/Python/PyVenvConfigHelper.cs b/StabilityMatrix.Core/Python/PyVenvConfigHelper.cs new file mode 100644 index 000000000..33d65d535 --- /dev/null +++ b/StabilityMatrix.Core/Python/PyVenvConfigHelper.cs @@ -0,0 +1,94 @@ +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(); + + if (trimmed.StartsWith("home", StringComparison.OrdinalIgnoreCase) && trimmed.Contains('=')) + { + sb.AppendLine($"home = {pythonDirectory}"); + hasHome = true; + } + else if ( + trimmed.StartsWith("base-prefix", StringComparison.OrdinalIgnoreCase) && trimmed.Contains('=') + ) + { + sb.AppendLine($"base-prefix = {pythonDirectory}"); + hasBasePrefix = true; + } + else if ( + trimmed.StartsWith("base-exec-prefix", StringComparison.OrdinalIgnoreCase) + && trimmed.Contains('=') + ) + { + sb.AppendLine($"base-exec-prefix = {pythonDirectory}"); + hasBaseExecPrefix = true; + } + else if ( + trimmed.StartsWith("base-executable", StringComparison.OrdinalIgnoreCase) + && trimmed.Contains('=') + ) + { + 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 + ); + } +} From 73c184607243a0efff0bcfad8671e8c3170280e9 Mon Sep 17 00:00:00 2001 From: NeuralFault Date: Wed, 29 Jul 2026 13:08:39 +0000 Subject: [PATCH 2/4] Fix Python version resolution conflicts when multiple distributions are installed - Fix fallback directory scanner in UvManager.InstallPythonVersionAsync using Contains("3.12") which also matched "3.13.12" directory names, causing the wrong Python distribution to be selected when UV listing failed and the newer 3.13 installation had a more recent creation timestamp - Switched to strict version prefix matching ("3.12.") with an EndsWith fallback for edge cases like "pypy-3.12" naming - Fix installedOnly parameter in ListAvailablePythonsAsync being ignored, causing uninstalled Python entries with null Path to reach the PyInstallation constructor and throw ArgumentException, which aborted the entire UV discovery loop via the catch-all in GetAllInstallationsAsync - Wire PyVenvConfigHelper.WritePyVenvCfg into PyVenvRunner and UvVenvRunner SetPyvenvCfg, replacing the Salaros.Configuration.ConfigParser round-trip that silently failed to update the existing "home" key - Remove unused Salaros.Configuration using directives from both runner files --- StabilityMatrix.Core/Python/PyVenvRunner.cs | 22 ++++----------------- StabilityMatrix.Core/Python/UvManager.cs | 22 ++++++++++++++++++--- StabilityMatrix.Core/Python/UvVenvRunner.cs | 22 ++++----------------- 3 files changed, 27 insertions(+), 39 deletions(-) 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..570ce1deb 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 = Path.GetDirectoryName(e.Path!) ?? 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; From 799431d43d9d3a0bd963d1f9d8db4b1178ade910 Mon Sep 17 00:00:00 2001 From: NeuralFault Date: Wed, 29 Jul 2026 13:18:20 +0000 Subject: [PATCH 3/4] Use exact key matching in PyVenvConfigHelper instead of StartsWith - Parse each line into key and value by splitting on '=', then compare the key with ordinal case-insensitive Equals rather than StartsWith - Preserve lines with no '=' delimiter as-is - Eliminates ordering dependency between key checks. Each key is now matched exactly and independently, so reordering the checks or adding a new key like "base" cannot silently swallow "base-prefix" or "base-executable" through prefix collision --- .../Python/PyVenvConfigHelper.cs | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/StabilityMatrix.Core/Python/PyVenvConfigHelper.cs b/StabilityMatrix.Core/Python/PyVenvConfigHelper.cs index 33d65d535..02897edd5 100644 --- a/StabilityMatrix.Core/Python/PyVenvConfigHelper.cs +++ b/StabilityMatrix.Core/Python/PyVenvConfigHelper.cs @@ -30,31 +30,33 @@ public static void WritePyVenvCfg(string cfgPath, string pythonDirectory, string foreach (var line in lines) { var trimmed = line.Trim(); + var eqIdx = trimmed.IndexOf('='); - if (trimmed.StartsWith("home", StringComparison.OrdinalIgnoreCase) && trimmed.Contains('=')) + // 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 ( - trimmed.StartsWith("base-prefix", StringComparison.OrdinalIgnoreCase) && trimmed.Contains('=') - ) + else if (key.Equals("base-prefix", StringComparison.OrdinalIgnoreCase)) { sb.AppendLine($"base-prefix = {pythonDirectory}"); hasBasePrefix = true; } - else if ( - trimmed.StartsWith("base-exec-prefix", StringComparison.OrdinalIgnoreCase) - && trimmed.Contains('=') - ) + else if (key.Equals("base-exec-prefix", StringComparison.OrdinalIgnoreCase)) { sb.AppendLine($"base-exec-prefix = {pythonDirectory}"); hasBaseExecPrefix = true; } - else if ( - trimmed.StartsWith("base-executable", StringComparison.OrdinalIgnoreCase) - && trimmed.Contains('=') - ) + else if (key.Equals("base-executable", StringComparison.OrdinalIgnoreCase)) { sb.AppendLine($"base-executable = {baseExecutable}"); hasBaseExecutable = true; From 390e9da5c39640805949bb4f0ac4fb6fe3892ce1 Mon Sep 17 00:00:00 2001 From: NeuralFault Date: Wed, 29 Jul 2026 13:53:11 +0000 Subject: [PATCH 4/4] Replace null-forgiving operator on e.Path with explicit null check in UvManager - When installedOnly is false the preceding Where clause allows e.Path to be null, making the null-forgiving operator (!) semantically incorrect and misleading - Replace with a conditional that uses Path.GetDirectoryName only when e.Path is non-null, falling back to string.Empty otherwise --- StabilityMatrix.Core/Python/UvManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StabilityMatrix.Core/Python/UvManager.cs b/StabilityMatrix.Core/Python/UvManager.cs index 570ce1deb..08ca7a2b6 100644 --- a/StabilityMatrix.Core/Python/UvManager.cs +++ b/StabilityMatrix.Core/Python/UvManager.cs @@ -163,7 +163,7 @@ public async Task> ListAvailablePythonsAsync( ) .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,