From 4dccdf17abd56c86ba8e1cfacb2919dd64a97b12 Mon Sep 17 00:00:00 2001 From: NeuralFault Date: Wed, 29 Jul 2026 13:00:13 +0000 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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, From c565a152b409ce5636c33a067bbc88d528578f09 Mon Sep 17 00:00:00 2001 From: NeuralFault Date: Mon, 17 Aug 2026 22:40:20 +0000 Subject: [PATCH 5/7] build: remove Salaros.ConfigParser dependency - Removed the unused Salaros.ConfigParser package references from StabilityMatrix.Core.csproj, StabilityMatrix.csproj, and Directory.Packages.props - The last code usages (ConfigParser-based pyvenv.cfg writing) were already removed, so this completes the drop of the third-party dependency --- Directory.Packages.props | 1 - StabilityMatrix.Core/StabilityMatrix.Core.csproj | 1 - StabilityMatrix/StabilityMatrix.csproj | 1 - 3 files changed, 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 14459b259..e8e30d9a6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -67,7 +67,6 @@ - diff --git a/StabilityMatrix.Core/StabilityMatrix.Core.csproj b/StabilityMatrix.Core/StabilityMatrix.Core.csproj index 7501518ed..05042ba64 100644 --- a/StabilityMatrix.Core/StabilityMatrix.Core.csproj +++ b/StabilityMatrix.Core/StabilityMatrix.Core.csproj @@ -62,7 +62,6 @@ - diff --git a/StabilityMatrix/StabilityMatrix.csproj b/StabilityMatrix/StabilityMatrix.csproj index b3e447f83..090dbc886 100644 --- a/StabilityMatrix/StabilityMatrix.csproj +++ b/StabilityMatrix/StabilityMatrix.csproj @@ -39,7 +39,6 @@ - From 0f004d7b86c4f0a111cf520adde6943e13205ccd Mon Sep 17 00:00:00 2001 From: NeuralFault Date: Wed, 19 Aug 2026 19:17:28 +0000 Subject: [PATCH 6/7] refactor: replace pyvenv.cfg writer with reusable PyVenvCfg type - Add PyVenvCfg: ordered, sectionless key=value parser/writer with a case-insensitive indexer; setting a key rewrites every duplicate, fixing stale home/base-* when pyvenv.cfg contains duplicate keys - Update PyVenvRunner and UvVenvRunner SetPyvenvCfg to set home/base-prefix/base-exec-prefix/base-executable through PyVenvCfg - Fail loudly on UTF-16/NUL-encoded files instead of silently mangling them - Remove the superseded PyVenvConfigHelper - Add unit tests: duplicate keys, in-place update, append, order preservation, no-space syntax, '=' in values, last-wins getter, UTF-16 rejection --- StabilityMatrix.Core/Python/PyVenvCfg.cs | 136 ++++++++++++++++++ .../Python/PyVenvConfigHelper.cs | 96 ------------- StabilityMatrix.Core/Python/PyVenvRunner.cs | 7 +- StabilityMatrix.Core/Python/UvVenvRunner.cs | 7 +- StabilityMatrix.Tests/Core/PyVenvCfgTests.cs | 128 +++++++++++++++++ 5 files changed, 276 insertions(+), 98 deletions(-) create mode 100644 StabilityMatrix.Core/Python/PyVenvCfg.cs delete mode 100644 StabilityMatrix.Core/Python/PyVenvConfigHelper.cs create mode 100644 StabilityMatrix.Tests/Core/PyVenvCfgTests.cs diff --git a/StabilityMatrix.Core/Python/PyVenvCfg.cs b/StabilityMatrix.Core/Python/PyVenvCfg.cs new file mode 100644 index 000000000..9322ee6aa --- /dev/null +++ b/StabilityMatrix.Core/Python/PyVenvCfg.cs @@ -0,0 +1,136 @@ +using System.Text; + +namespace StabilityMatrix.Core.Python; + +/// +/// Ordered, sectionless key = value configuration, as used by pyvenv.cfg. +/// Keys are case-insensitive. Duplicate keys are preserved in order; setting a +/// key rewrites every occurrence (fixing stale duplicates) rather than only the +/// first, which matches how CPython's site.py actually reads the file. +/// +public sealed class PyVenvCfg +{ + private readonly List _entries; + + private PyVenvCfg(List entries) => _entries = entries; + + /// Parses pyvenv.cfg text without touching the disk. + public static PyVenvCfg Parse(string content) + { + var entries = new List(); + + var segments = content.Split('\n'); + // A trailing empty segment is the artifact of a final newline, not a real line. + var lineCount = + segments.Length > 0 && segments[^1].Length == 0 ? segments.Length - 1 : segments.Length; + + for (var i = 0; i < lineCount; i++) + { + var text = segments[i].TrimEnd('\r'); + var trimmed = text.Trim(); + var eqIdx = trimmed.IndexOf('='); + + // Lines without '=' are comments/blank lines and are preserved as-is. + if (eqIdx < 0) + { + entries.Add(new Entry(text, null, null)); + continue; + } + + var key = trimmed[..eqIdx].Trim(); + var value = trimmed[(eqIdx + 1)..].Trim(); + entries.Add(new Entry(text, key, value)); + } + + return new PyVenvCfg(entries); + } + + /// + /// Loads a pyvenv.cfg file. Fails loudly on non-UTF-8 encodings instead of + /// silently mangling the file. + /// + public static PyVenvCfg Load(string path) + { + var bytes = File.ReadAllBytes(path); + + // pyvenv.cfg is UTF-8/ASCII; reject UTF-16 BOMs and NUL bytes, which + // indicate the file was read with the wrong encoding. + if ( + bytes.Length >= 2 + && ((bytes[0] == 0xFF && bytes[1] == 0xFE) || (bytes[0] == 0xFE && bytes[1] == 0xFF)) + ) + { + throw new InvalidDataException($"pyvenv.cfg is UTF-16 encoded; expected UTF-8/ASCII: {path}"); + } + + var content = new UTF8Encoding(false).GetString(bytes); + if (content.Contains('\0')) + { + throw new InvalidDataException($"pyvenv.cfg contains NUL bytes; expected UTF-8/ASCII: {path}"); + } + + return Parse(content); + } + + /// + /// Gets the value of the last matching key (CPython is last-wins), or null. + /// Setting rewrites every matching key, appending a new key when absent. + /// + public string? this[string key] + { + get + { + for (var i = _entries.Count - 1; i >= 0; i--) + { + if (_entries[i].Key is { } k && k.Equals(key, StringComparison.OrdinalIgnoreCase)) + { + return _entries[i].Value; + } + } + + return null; + } + set + { + ArgumentNullException.ThrowIfNull(value); + + var updated = false; + for (var i = 0; i < _entries.Count; i++) + { + if (_entries[i].Key is { } k && k.Equals(key, StringComparison.OrdinalIgnoreCase)) + { + _entries[i].Text = $"{key} = {value}"; + _entries[i].Value = value; + updated = true; + } + } + + if (!updated) + { + _entries.Add(new Entry($"{key} = {value}", key, value)); + } + } + } + + /// Serializes back to pyvenv.cfg text, preserving order and untouched lines. + public override string ToString() => string.Join(Environment.NewLine, _entries.Select(e => e.Text)); + + /// Writes the config back to disk. + public void Save(string path) => File.WriteAllText(path, ToString()); + + private sealed class Entry + { + public Entry(string text, string? key, string? value) + { + Text = text; + Key = key; + Value = value; + } + + public string Text { get; set; } + + public string? Key { get; } + + public string? Value { get; set; } + } +} diff --git a/StabilityMatrix.Core/Python/PyVenvConfigHelper.cs b/StabilityMatrix.Core/Python/PyVenvConfigHelper.cs deleted file mode 100644 index 02897edd5..000000000 --- a/StabilityMatrix.Core/Python/PyVenvConfigHelper.cs +++ /dev/null @@ -1,96 +0,0 @@ -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 5ad4662b7..a7d5ce106 100644 --- a/StabilityMatrix.Core/Python/PyVenvRunner.cs +++ b/StabilityMatrix.Core/Python/PyVenvRunner.cs @@ -206,7 +206,12 @@ private void SetPyvenvCfg(string pythonDirectory, bool force = false) Compat.IsWindows ? "python.exe" : RelativePythonPath ); - PyVenvConfigHelper.WritePyVenvCfg(cfgPath, pythonDirectory, baseExecutable); + var cfg = PyVenvCfg.Load(cfgPath); + cfg["home"] = pythonDirectory; + cfg["base-prefix"] = pythonDirectory; + cfg["base-exec-prefix"] = pythonDirectory; + cfg["base-executable"] = baseExecutable; + cfg.Save(cfgPath); // Update last set path lastSetPyvenvCfgPath = pythonDirectory; diff --git a/StabilityMatrix.Core/Python/UvVenvRunner.cs b/StabilityMatrix.Core/Python/UvVenvRunner.cs index 8e640a1de..7cb7a13f5 100644 --- a/StabilityMatrix.Core/Python/UvVenvRunner.cs +++ b/StabilityMatrix.Core/Python/UvVenvRunner.cs @@ -212,7 +212,12 @@ private void SetPyvenvCfg(string pythonDirectory, bool force = false) Compat.IsWindows ? "python.exe" : RelativePythonPath ); - PyVenvConfigHelper.WritePyVenvCfg(cfgPath, pythonDirectory, baseExecutable); + var cfg = PyVenvCfg.Load(cfgPath); + cfg["home"] = pythonDirectory; + cfg["base-prefix"] = pythonDirectory; + cfg["base-exec-prefix"] = pythonDirectory; + cfg["base-executable"] = baseExecutable; + cfg.Save(cfgPath); // Update last set path lastSetPyvenvCfgPath = pythonDirectory; diff --git a/StabilityMatrix.Tests/Core/PyVenvCfgTests.cs b/StabilityMatrix.Tests/Core/PyVenvCfgTests.cs new file mode 100644 index 000000000..0af2a6261 --- /dev/null +++ b/StabilityMatrix.Tests/Core/PyVenvCfgTests.cs @@ -0,0 +1,128 @@ +using System.Text; +using StabilityMatrix.Core.Python; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class PyVenvCfgTests +{ + private static string[] Lines(string content) => + content.Split('\n').Select(l => l.TrimEnd('\r')).ToArray(); + + [TestMethod] + public void Set_WithDuplicateKeys_RewritesEveryMatch() + { + var cfg = PyVenvCfg.Parse( + "home = cpython-3.12.10\nbase-prefix = cpython-3.12.10\nhome = cpython-3.13.12" + ); + + cfg["home"] = "/new/python"; + + CollectionAssert.AreEqual( + new[] { "home = /new/python", "base-prefix = cpython-3.12.10", "home = /new/python" }, + Lines(cfg.ToString()) + ); + Assert.AreEqual("/new/python", cfg["home"]); + } + + [TestMethod] + public void Set_ExistingKey_UpdatesInPlace() + { + var cfg = PyVenvCfg.Parse("home = old\nbase-prefix = x"); + + cfg["home"] = "new"; + + CollectionAssert.AreEqual(new[] { "home = new", "base-prefix = x" }, Lines(cfg.ToString())); + } + + [TestMethod] + public void Set_MissingKey_Appends() + { + var cfg = PyVenvCfg.Parse("home = /py"); + + cfg["base-executable"] = "/py/bin/python"; + + CollectionAssert.AreEqual( + new[] { "home = /py", "base-executable = /py/bin/python" }, + Lines(cfg.ToString()) + ); + } + + [TestMethod] + public void Set_MissingKey_WhenContentEndsWithNewline_NoBlankLine() + { + var cfg = PyVenvCfg.Parse("home = /py\n"); + + cfg["base-executable"] = "/py/bin/python"; + + CollectionAssert.AreEqual( + new[] { "home = /py", "base-executable = /py/bin/python" }, + Lines(cfg.ToString()) + ); + } + + [TestMethod] + public void Set_PreservesUnrelatedKeysInOrder() + { + var cfg = PyVenvCfg.Parse( + "home = a\nbase-prefix = b\nprompt = c\nbase-exec-prefix = d\nbase-executable = e" + ); + + cfg["home"] = "z"; + + CollectionAssert.AreEqual( + new[] + { + "home = z", + "base-prefix = b", + "prompt = c", + "base-exec-prefix = d", + "base-executable = e", + }, + Lines(cfg.ToString()) + ); + } + + [TestMethod] + public void Parse_KeyWithoutSpaces_ReadsAndUpdates() + { + var cfg = PyVenvCfg.Parse("home=3.12"); + + Assert.AreEqual("3.12", cfg["home"]); + + cfg["home"] = "3.14"; + Assert.AreEqual("home = 3.14", Lines(cfg.ToString())[0]); + } + + [TestMethod] + public void Parse_ValueContainingEquals_KeepsWholeValue() + { + var cfg = PyVenvCfg.Parse("home=C:\\Program Files=Python"); + + Assert.AreEqual("C:\\Program Files=Python", cfg["home"]); + } + + [TestMethod] + public void Get_DuplicateKeys_IsLastWins() + { + var cfg = PyVenvCfg.Parse("home = first\nhome = second"); + + Assert.AreEqual("second", cfg["home"]); + } + + [TestMethod] + public void Load_Utf16Encoded_Throws() + { + var path = Path.Combine(Path.GetTempPath(), $"pyvenv-{Guid.NewGuid():N}.cfg"); + try + { + File.WriteAllText(path, "home = x\n", Encoding.Unicode); + + Assert.ThrowsException(() => PyVenvCfg.Load(path)); + } + finally + { + File.Delete(path); + } + } +} From d7716e02f064094fe892204e06953cb881030a5f Mon Sep 17 00:00:00 2001 From: NeuralFault Date: Wed, 19 Aug 2026 19:51:12 +0000 Subject: [PATCH 7/7] fix: resolve uv Python fallback version structurally - Replace substring matching with parsing the version from the install directory name (segment [1]) and requiring Major/Minor to match, avoiding 3.12 matching 3.13.12 or 3.121 - Report the actual parsed version in the returned UvPythonInfo instead of the requested version - Remove the dead EndsWith("-major.minor") fallback branch - Add ParseUvInstallDirVersion helper that tolerates prerelease and freethreaded suffixes - Add unit tests for the directory-name version parsing --- StabilityMatrix.Core/Python/UvManager.cs | 71 ++++++++++++++----- .../Core/UvManagerVersionParseTests.cs | 71 +++++++++++++++++++ 2 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 StabilityMatrix.Tests/Core/UvManagerVersionParseTests.cs diff --git a/StabilityMatrix.Core/Python/UvManager.cs b/StabilityMatrix.Core/Python/UvManager.cs index 08ca7a2b6..b2f044f80 100644 --- a/StabilityMatrix.Core/Python/UvManager.cs +++ b/StabilityMatrix.Core/Python/UvManager.cs @@ -293,45 +293,49 @@ 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) }) - .Where(x => - x.DirInfo.Name.StartsWith("cpython-", StringComparison.OrdinalIgnoreCase) - || x.DirInfo.Name.StartsWith("pypy-", StringComparison.OrdinalIgnoreCase) - ) + .Select(dir => + { + var info = new DirectoryInfo(dir); + return new + { + Path = dir, + Name = info.Name, + CreationTimeUtc = info.CreationTimeUtc, + Version = ParseUvInstallDirVersion(info.Name), + }; + }) .Where(x => - x.DirInfo.Name.Contains(versionPrefix) - || x.DirInfo.Name.EndsWith( - $"-{version.Major}.{version.Minor}", - StringComparison.OrdinalIgnoreCase + ( + x.Name.StartsWith("cpython-", StringComparison.OrdinalIgnoreCase) + || x.Name.StartsWith("pypy-", StringComparison.OrdinalIgnoreCase) ) + && x.Version is { } parsedVersion + && parsedVersion.Major == version.Major + && parsedVersion.Minor == version.Minor ) - .OrderByDescending(x => x.DirInfo.CreationTimeUtc) + .OrderByDescending(x => x.CreationTimeUtc) .ToList(); foreach (var potentialDir in potentialDirs) { var actualInstallPath = potentialDir.Path; - var pyInstallCheck = new PyInstallation(version, actualInstallPath); + var actualVersion = potentialDir.Version!.Value; + var pyInstallCheck = new PyInstallation(actualVersion, actualInstallPath); if (!pyInstallCheck.Exists()) continue; Logger.Info($"Fallback discovery found likely installation at: {actualInstallPath}"); - var inferredKey = Path.GetFileName(actualInstallPath); - var inferredSource = inferredKey.Split('-')[0]; + var inferredSource = potentialDir.Name.Split('-')[0]; return new UvPythonInfo( - version, + actualVersion, actualInstallPath, true, inferredSource, null, null, - inferredKey, + potentialDir.Name, null, null ); @@ -346,6 +350,35 @@ public async Task> ListAvailablePythonsAsync( return null; } + /// + /// Parses the version out of a uv Python install directory name + /// (e.g. "cpython-3.12.10-windows-x86_64-none"), or null if it doesn't match the expected shape. + /// + public static PyVersion? ParseUvInstallDirVersion(string dirName) + { + var parts = dirName.Split('-'); + if (parts.Length < 2) + { + return null; + } + + // The version is segment [1]; take its leading "major.minor[.micro]" numeric prefix + // so suffixes like "rc1" or "+freethreaded" are tolerated. + var segment = parts[1]; + var prefixLength = 0; + while ( + prefixLength < segment.Length + && (char.IsDigit(segment[prefixLength]) || segment[prefixLength] == '.') + ) + { + prefixLength++; + } + + return prefixLength > 0 && PyVersion.TryParse(segment[..prefixLength], out var parsed) + ? parsed + : null; + } + [GeneratedRegex( @"^\s*(?[a-zA-Z0-9_.-]+(?:[\+\-][a-zA-Z0-9_.-]+)?)\s+(?.+)\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled, diff --git a/StabilityMatrix.Tests/Core/UvManagerVersionParseTests.cs b/StabilityMatrix.Tests/Core/UvManagerVersionParseTests.cs new file mode 100644 index 000000000..1cddc6124 --- /dev/null +++ b/StabilityMatrix.Tests/Core/UvManagerVersionParseTests.cs @@ -0,0 +1,71 @@ +using StabilityMatrix.Core.Python; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class UvManagerVersionParseTests +{ + [TestMethod] + public void ParseUvInstallDirVersion_CpythonRelease_ReturnsVersion() + { + var version = UvManager.ParseUvInstallDirVersion("cpython-3.12.10-windows-x86_64-none"); + + Assert.IsNotNull(version); + var v = version.Value; + Assert.AreEqual(3, v.Major); + Assert.AreEqual(12, v.Minor); + Assert.AreEqual(10, v.Micro); + } + + [TestMethod] + public void ParseUvInstallDirVersion_Pypy_ReturnsVersion() + { + var version = UvManager.ParseUvInstallDirVersion("pypy-3.10.14-linux-x86_64-gnu"); + + Assert.IsNotNull(version); + var v = version.Value; + Assert.AreEqual(3, v.Major); + Assert.AreEqual(10, v.Minor); + } + + [TestMethod] + public void ParseUvInstallDirVersion_NoMicro_DefaultsToZero() + { + var version = UvManager.ParseUvInstallDirVersion("cpython-3.12"); + + Assert.IsNotNull(version); + var v = version.Value; + Assert.AreEqual(3, v.Major); + Assert.AreEqual(12, v.Minor); + Assert.AreEqual(0, v.Micro); + } + + [TestMethod] + public void ParseUvInstallDirVersion_PrereleaseSuffix_ReturnsBaseVersion() + { + var version = UvManager.ParseUvInstallDirVersion("cpython-3.13.0rc1-linux-x86_64-gnu"); + + Assert.IsNotNull(version); + var v = version.Value; + Assert.AreEqual(3, v.Major); + Assert.AreEqual(13, v.Minor); + } + + [TestMethod] + public void ParseUvInstallDirVersion_FreethreadedSuffix_ReturnsBaseVersion() + { + var version = UvManager.ParseUvInstallDirVersion("cpython-3.13.0+freethreaded-linux-x86_64-gnu"); + + Assert.IsNotNull(version); + var v = version.Value; + Assert.AreEqual(3, v.Major); + Assert.AreEqual(13, v.Minor); + } + + [TestMethod] + public void ParseUvInstallDirVersion_UnexpectedName_ReturnsNull() + { + Assert.IsNull(UvManager.ParseUvInstallDirVersion("cpython-unknown")); + Assert.IsNull(UvManager.ParseUvInstallDirVersion("not-a-uv-dir")); + } +}