Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@
<PackageVersion Include="Refit.HttpClientFactory" Version="8.0.0" />
<PackageVersion Include="RockLib.Reflection.Optimized" Version="3.0.0" />
<PackageVersion Include="Nito.AsyncEx" Version="5.1.2" />
<PackageVersion Include="Salaros.ConfigParser" Version="0.3.8" />
<PackageVersion Include="Semi.Avalonia" Version="11.2.0" />
<PackageVersion Include="Semver" Version="3.0.0-beta.1" />
<PackageVersion Include="Sentry" Version="5.5.1" />
Expand Down
136 changes: 136 additions & 0 deletions StabilityMatrix.Core/Python/PyVenvCfg.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System.Text;

namespace StabilityMatrix.Core.Python;

/// <summary>
/// Ordered, sectionless <c>key = value</c> 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.
/// </summary>
public sealed class PyVenvCfg
{
private readonly List<Entry> _entries;

private PyVenvCfg(List<Entry> entries) => _entries = entries;

/// <summary>Parses pyvenv.cfg text without touching the disk.</summary>
public static PyVenvCfg Parse(string content)
{
var entries = new List<Entry>();

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);
}

/// <summary>
/// Loads a pyvenv.cfg file. Fails loudly on non-UTF-8 encodings instead of
/// silently mangling the file.
/// </summary>
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);
}

/// <summary>
/// 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.
/// </summary>
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));
}
}
}

/// <summary>Serializes back to pyvenv.cfg text, preserving order and untouched lines.</summary>
public override string ToString() => string.Join(Environment.NewLine, _entries.Select(e => e.Text));

/// <summary>Writes the config back to disk.</summary>
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; }
}
}
27 changes: 9 additions & 18 deletions StabilityMatrix.Core/Python/PyVenvRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
73 changes: 61 additions & 12 deletions StabilityMatrix.Core/Python/UvManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,21 @@ public async Task<IReadOnlyList<UvPythonInfo>> 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,
Expand Down Expand Up @@ -289,33 +295,47 @@ public async Task<IReadOnlyList<UvPythonInfo>> 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
);
Expand All @@ -330,6 +350,35 @@ public async Task<IReadOnlyList<UvPythonInfo>> ListAvailablePythonsAsync(
return null;
}

/// <summary>
/// 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.
/// </summary>
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*(?<key>[a-zA-Z0-9_.-]+(?:[\+\-][a-zA-Z0-9_.-]+)?)\s+(?<status_or_path>.+)\s*$",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
Expand Down
27 changes: 9 additions & 18 deletions StabilityMatrix.Core/Python/UvVenvRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
1 change: 0 additions & 1 deletion StabilityMatrix.Core/StabilityMatrix.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@
<PackageReference Include="Refit" />
<PackageReference Include="Refit.HttpClientFactory" />
<PackageReference Include="RockLib.Reflection.Optimized" />
<PackageReference Include="Salaros.ConfigParser" />
<PackageReference Include="Semver" />
<PackageReference Include="Sentry.NLog" />
<PackageReference Include="SharpCompress" />
Expand Down
Loading
Loading