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/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/PyVenvRunner.cs b/StabilityMatrix.Core/Python/PyVenvRunner.cs index ff283fd99..a7d5ce106 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,17 @@ 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); + 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/UvManager.cs b/StabilityMatrix.Core/Python/UvManager.cs index 8c7cd9ebc..b2f044f80 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, @@ -289,33 +295,47 @@ public async Task> ListAvailablePythonsAsync( { var subdirectories = Directory.GetDirectories(uvPythonInstallPath); var potentialDirs = subdirectories - .Select(dir => new { Path = dir, DirInfo = new DirectoryInfo(dir) }) + .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.StartsWith("cpython-", StringComparison.OrdinalIgnoreCase) - || x.DirInfo.Name.StartsWith("pypy-", 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 ) - .Where(x => x.DirInfo.Name.Contains($"{version.Major}.{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 ); @@ -330,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.Core/Python/UvVenvRunner.cs b/StabilityMatrix.Core/Python/UvVenvRunner.cs index 6fa69fd6e..7cb7a13f5 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,17 @@ 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); + 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/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.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); + } + } +} 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")); + } +} 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 @@ -