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 @@
-