Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Exceptions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;

namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;

public partial class PythonPackagesItemViewModel(ISettingsManager settingsManager) : ViewModelBase
public partial class PythonPackagesItemViewModel : ViewModelBase
{
[ObservableProperty]
private PipPackageInfo package;
Expand Down Expand Up @@ -81,9 +79,9 @@ value is null
/// <summary>
/// Loads the pip show result if not already loaded
/// </summary>
public async Task LoadExtraInfo(DirectoryPath venvPath, PyBaseInstall pyBaseInstall)
public async Task LoadExtraInfo(Func<Task<IPyVenvRunner>> createVenvRunner)
{
if (PipShowResult is not null)
if (PipShowResult is not null || IsLoading)
{
return;
}
Expand All @@ -98,11 +96,7 @@ public async Task LoadExtraInfo(DirectoryPath venvPath, PyBaseInstall pyBaseInst
}
else
{
await using var venvRunner = await pyBaseInstall.CreateVenvRunnerAsync(
venvPath,
workingDirectory: venvPath.Parent,
environmentVariables: settingsManager.Settings.EnvironmentVariables
);
await using var venvRunner = await createVenvRunner();

PipShowResult = await venvRunner.PipShow(Package.Name);

Expand Down
109 changes: 84 additions & 25 deletions StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ public partial class PythonPackagesViewModel : ContentDialogViewModelBase
private readonly IPrerequisiteHelper prerequisiteHelper;
private PyBaseInstall? pyBaseInstall;

/// <summary>
/// Lets only one package's details load at a time. Typing in the search box moves the
/// selection on almost every keystroke, which would otherwise start overlapping pip
/// processes against the same venv (each one rewriting its pyvenv.cfg).
/// </summary>
private readonly SemaphoreSlim extraInfoLock = new(1, 1);

private CancellationTokenSource? extraInfoCts;

/// <summary>
/// Replaces how venv runners are created, so tests can run without a real Python install.
/// </summary>
internal Func<Task<IPyVenvRunner>>? VenvRunnerFactory { get; set; }

public DirectoryPath? VenvPath { get; set; }

public PyVersion? PythonVersion { get; set; }
Expand Down Expand Up @@ -85,7 +99,7 @@ private void PostConstruct()
.Connect()
.DeferUntilLoaded()
.Filter(searchPredicate)
.Transform(p => new PythonPackagesItemViewModel(settingsManager) { Package = p })
.Transform(p => new PythonPackagesItemViewModel { Package = p })
.SortBy(vm => vm.Package.Name)
.ObserveOn(SynchronizationContext.Current!)
.Bind(Packages)
Expand All @@ -107,17 +121,7 @@ private async Task Refresh()
}
else
{
pyBaseInstall ??= new PyBaseInstall(
await pyInstallationManager.GetInstallationAsync(
PythonVersion ?? PyInstallationManager.Python_3_10_11
)
);

await using var venvRunner = await pyBaseInstall.CreateVenvRunnerAsync(
VenvPath,
workingDirectory: VenvPath.Parent,
environmentVariables: settingsManager.Settings.EnvironmentVariables
);
await using var venvRunner = await CreateVenvRunnerAsync();

var packages = await venvRunner.PipList();

Expand All @@ -143,16 +147,7 @@ private async Task RefreshBackground()
if (VenvPath is null)
return;

pyBaseInstall ??= new PyBaseInstall(
await pyInstallationManager.GetInstallationAsync(
PythonVersion ?? PyInstallationManager.Python_3_10_11
)
);
await using var venvRunner = await pyBaseInstall.CreateVenvRunnerAsync(
VenvPath,
workingDirectory: VenvPath.Parent,
environmentVariables: settingsManager.Settings.EnvironmentVariables
);
await using var venvRunner = await CreateVenvRunnerAsync();

var packages = await venvRunner.PipList();

Expand All @@ -169,19 +164,83 @@ await pyInstallationManager.GetInstallationAsync(
});
}

private async Task<IPyVenvRunner> CreateVenvRunnerAsync()
{
if (VenvRunnerFactory is not null)
{
return await VenvRunnerFactory();
}

ArgumentNullException.ThrowIfNull(VenvPath);

pyBaseInstall ??= new PyBaseInstall(
await pyInstallationManager.GetInstallationAsync(
PythonVersion ?? PyInstallationManager.Python_3_10_11
)
);

return await pyBaseInstall.CreateVenvRunnerAsync(
VenvPath,
workingDirectory: VenvPath.Parent,
environmentVariables: settingsManager.Settings.EnvironmentVariables
);
}

/// <summary>
/// Load the selected package's show info if not already loaded
/// </summary>
partial void OnSelectedPackageChanged(PythonPackagesItemViewModel? value)
{
if (value is null)
// A load still waiting for its turn is no longer wanted once the selection moves on
extraInfoCts?.Cancel();
extraInfoCts?.Dispose();
extraInfoCts = null;

if (value is null || (VenvPath is null && !Design.IsDesignMode))
{
return;
}

if (value.PipShowResult is not null || value.IsLoading)
{
return;
}

var cts = new CancellationTokenSource();
extraInfoCts = cts;

LoadExtraInfoAsync(value, cts.Token)
.SafeFireAndForget(ex =>
logger.LogWarning(ex, "Failed to load details for Python package {Name}", value.Package.Name)
);
}

private async Task LoadExtraInfoAsync(
PythonPackagesItemViewModel item,
CancellationToken cancellationToken
)
{
try
{
await extraInfoLock.WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
return;
}

if (value.PipShowResult is null)
try
{
if (!ReferenceEquals(SelectedPackage, item) || item.PipShowResult is not null)
{
return;
}

await item.LoadExtraInfo(CreateVenvRunnerAsync);
}
finally
{
value.LoadExtraInfo(VenvPath!, pyBaseInstall!).SafeFireAndForget();
extraInfoLock.Release();
}
}

Expand Down
18 changes: 12 additions & 6 deletions StabilityMatrix.Core/Python/PipIndexResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,20 @@ public partial record PipIndexResult

public static PipIndexResult Parse(string output)
{
var match = AvailableVersionsRegex().Matches(output);

var versions = output
var versionsMatch = output
.SplitLines()
.Select(line => AvailableVersionsRegex().Match(line))
.First(m => m.Success)
.Groups["versions"].Value
.Split(
.FirstOrDefault(m => m.Success);

// Output without an "Available versions:" line (e.g. only warnings) has no versions to offer
if (versionsMatch is null)
{
return new PipIndexResult { AvailableVersions = [] };
}

var versions = versionsMatch
.Groups["versions"]
.Value.Split(
new[] { ',' },
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries
)
Expand Down
9 changes: 6 additions & 3 deletions StabilityMatrix.Core/Python/PipShowResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@ public static PipShowResult Parse(string output)
var indexOfLicense = GetIndexBySubstring(lines, "License:");
var indexOfLocation = GetIndexBySubstring(lines, "Location:");

var licenseText =
indexOfLicense == -1 ? null : string.Join('\n', lines[indexOfLicense..indexOfLocation]);
// A multi-line license runs up to the "Location:" line. If that line is missing or comes
// first, only take the "License:" line itself.
var licenseEnd = indexOfLocation > indexOfLicense ? indexOfLocation : indexOfLicense + 1;

var licenseText = indexOfLicense == -1 ? null : string.Join('\n', lines[indexOfLicense..licenseEnd]);

if (indexOfLicense != -1)
{
lines.RemoveRange(indexOfLicense, indexOfLocation - indexOfLicense);
lines.RemoveRange(indexOfLicense, licenseEnd - indexOfLicense);
}

var linesDict = new Dictionary<string, string>();
Expand Down
35 changes: 35 additions & 0 deletions StabilityMatrix.Tests/Core/PipIndexResultTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using StabilityMatrix.Core.Python;

namespace StabilityMatrix.Tests.Core;

[TestClass]
public class PipIndexResultTests
{
[TestMethod]
public void Parse_ReadsAvailableVersions()
{
var output = """
jax (0.4.30)
Available versions: 0.4.30, 0.4.29, 0.4.28
INSTALLED: 0.4.26
LATEST: 0.4.30
""";

var result = PipIndexResult.Parse(output);

CollectionAssert.AreEqual(new[] { "0.4.30", "0.4.29", "0.4.28" }, result.AvailableVersions.ToArray());
}

[TestMethod]
public void Parse_WithoutAvailableVersionsLine_ReturnsNoVersions()
{
var output = """
WARNING: pip index is currently an experimental command. It may be removed/changed in a future release without prior warning.
""";

var result = PipIndexResult.Parse(output);

Assert.AreEqual(0, result.AvailableVersions.Count);
}
}
36 changes: 36 additions & 0 deletions StabilityMatrix.Tests/Core/PipShowResultsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,40 @@ public void TestEmptyInputThrowsFormatException()
var input = "";
Assert.ThrowsException<FormatException>(() => PipShowResult.Parse(input));
}

[TestMethod]
public void TestLicenseWithoutLocation()
{
var input = """
Name: package-a
Version: 1.0.0
License: MIT
Requires: numpy
""";

var result = PipShowResult.Parse(input);

Assert.AreEqual("package-a", result.Name);
Assert.AreEqual("License: MIT", result.License);
Assert.IsNull(result.Location);
CollectionAssert.AreEqual(new[] { "numpy" }, result.Requires);
}

[TestMethod]
public void TestLocationBeforeLicense()
{
var input = """
Name: package-a
Version: 1.0.0
Location: /path/to/package
License: MIT
Requires: numpy
""";

var result = PipShowResult.Parse(input);

Assert.AreEqual("License: MIT", result.License);
Assert.AreEqual("/path/to/package", result.Location);
CollectionAssert.AreEqual(new[] { "numpy" }, result.Requires);
}
}
Loading
Loading