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;