diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesItemViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesItemViewModel.cs index 378eb1657..a3783e00f 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesItemViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesItemViewModel.cs @@ -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; @@ -81,9 +79,9 @@ value is null /// /// Loads the pip show result if not already loaded /// - public async Task LoadExtraInfo(DirectoryPath venvPath, PyBaseInstall pyBaseInstall) + public async Task LoadExtraInfo(Func> createVenvRunner) { - if (PipShowResult is not null) + if (PipShowResult is not null || IsLoading) { return; } @@ -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); diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesViewModel.cs index 3d19f6a2a..ae907b518 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesViewModel.cs @@ -44,6 +44,20 @@ public partial class PythonPackagesViewModel : ContentDialogViewModelBase private readonly IPrerequisiteHelper prerequisiteHelper; private PyBaseInstall? pyBaseInstall; + /// + /// 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). + /// + private readonly SemaphoreSlim extraInfoLock = new(1, 1); + + private CancellationTokenSource? extraInfoCts; + + /// + /// Replaces how venv runners are created, so tests can run without a real Python install. + /// + internal Func>? VenvRunnerFactory { get; set; } + public DirectoryPath? VenvPath { get; set; } public PyVersion? PythonVersion { get; set; } @@ -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) @@ -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(); @@ -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(); @@ -169,19 +164,83 @@ await pyInstallationManager.GetInstallationAsync( }); } + private async Task 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 + ); + } + /// /// Load the selected package's show info if not already loaded /// 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(); } } diff --git a/StabilityMatrix.Core/Python/PipIndexResult.cs b/StabilityMatrix.Core/Python/PipIndexResult.cs index ea9e62f70..60b73efad 100644 --- a/StabilityMatrix.Core/Python/PipIndexResult.cs +++ b/StabilityMatrix.Core/Python/PipIndexResult.cs @@ -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 ) diff --git a/StabilityMatrix.Core/Python/PipShowResult.cs b/StabilityMatrix.Core/Python/PipShowResult.cs index 3df5ed388..8a2f6f865 100644 --- a/StabilityMatrix.Core/Python/PipShowResult.cs +++ b/StabilityMatrix.Core/Python/PipShowResult.cs @@ -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(); diff --git a/StabilityMatrix.Tests/Core/PipIndexResultTests.cs b/StabilityMatrix.Tests/Core/PipIndexResultTests.cs new file mode 100644 index 000000000..b84c705a0 --- /dev/null +++ b/StabilityMatrix.Tests/Core/PipIndexResultTests.cs @@ -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); + } +} diff --git a/StabilityMatrix.Tests/Core/PipShowResultsTests.cs b/StabilityMatrix.Tests/Core/PipShowResultsTests.cs index 08ebf4d56..8e30508ab 100644 --- a/StabilityMatrix.Tests/Core/PipShowResultsTests.cs +++ b/StabilityMatrix.Tests/Core/PipShowResultsTests.cs @@ -181,4 +181,40 @@ public void TestEmptyInputThrowsFormatException() var input = ""; Assert.ThrowsException(() => 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); + } } diff --git a/StabilityMatrix.UITests/PythonPackagesDialogTests.cs b/StabilityMatrix.UITests/PythonPackagesDialogTests.cs new file mode 100644 index 000000000..097d0cd32 --- /dev/null +++ b/StabilityMatrix.UITests/PythonPackagesDialogTests.cs @@ -0,0 +1,160 @@ +using Avalonia.Controls; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using StabilityMatrix.Avalonia.ViewModels.Dialogs; +using StabilityMatrix.Avalonia.Views.Dialogs; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Python; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.UITests; + +/// +/// The Python Packages dialog loads a package's details (pip show / pip index) whenever the +/// selection changes, and searching moves the selection on almost every keystroke. +/// +public class PythonPackagesDialogTests +{ + private static readonly string[] PackageNames = + [ + "absl-py", + "accelerate", + "aiohttp", + "certifi", + "einops", + "filelock", + "idna", + "jax", + "jaxlib", + "jinja2", + "jsonmerge", + "jsonschema", + "kornia", + "lark", + "ml-dtypes", + "numpy", + "opt-einsum", + "pillow", + "pip", + "safetensors", + "scipy", + "torch", + "torchvision", + "transformers", + ]; + + [AvaloniaFact] + public async Task TypingInSearch_WhenPackageDetailsFailToLoad_DoesNotCrash() + { + var dialog = await PackagesDialog.OpenAsync(pipShow: name => + name.StartsWith('j') + ? throw new FileNotFoundException("uv not found") + : Task.FromResult(new PipShowResult { Name = name, Version = "1.0.0" }) + ); + + // Any exception escaping the details load is rethrown on the UI thread, which ends the app + await dialog.TypeSearchAsync("j", "ja", "jax", "ja", "j", ""); + + Assert.Equal("jax", dialog.Vm.SelectedPackage?.Package.Name); + Assert.All(dialog.Vm.Packages, p => Assert.False(p.IsLoading)); + dialog.Close(); + } + + [AvaloniaFact] + public async Task TypingInSearch_LoadsOnePackagesDetailsAtATime() + { + var running = 0; + var maxRunning = 0; + var dialog = await PackagesDialog.OpenAsync(pipShow: async name => + { + maxRunning = Math.Max(maxRunning, ++running); + await Task.Delay(150); + running--; + return new PipShowResult { Name = name, Version = "1.0.0" }; + }); + + await dialog.TypeSearchAsync("j", "js", "t", "to", "a"); + await dialog.SettleAsync(rounds: 20); + + Assert.Equal(1, maxRunning); + Assert.NotNull(dialog.Vm.SelectedPackage?.PipShowResult); + dialog.Close(); + } + + private sealed class PackagesDialog + { + private readonly Window window; + private readonly TextBox searchBox; + + public PythonPackagesViewModel Vm { get; } + + private PackagesDialog(PythonPackagesViewModel vm, Window window, TextBox searchBox) + { + Vm = vm; + this.window = window; + this.searchBox = searchBox; + } + + public static async Task OpenAsync(Func> pipShow) + { + var runner = Substitute.For(); + runner.PipList().Returns(PackageNames.Select(n => new PipPackageInfo(n, "1.0.0")).ToList()); + runner.PipShow(Arg.Any()).Returns(call => pipShow(call.Arg())); + runner + .PipIndex(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new PipIndexResult { AvailableVersions = ["1.0.0"] }); + + var vm = new PythonPackagesViewModel( + NullLogger.Instance, + Substitute.For(), + Substitute.For(), + Substitute.For() + ) + { + VenvPath = new DirectoryPath(Path.GetTempPath(), "venv"), + VenvRunnerFactory = () => Task.FromResult(runner), + }; + + var view = new PythonPackagesDialog { DataContext = vm }; + var window = new Window + { + Width = 1000, + Height = 700, + Content = view, + }; + window.Show(); + await vm.OnLoadedAsync(); + + var dialog = new PackagesDialog( + vm, + window, + view.GetVisualDescendants().OfType().First() + ); + await dialog.SettleAsync(); + return dialog; + } + + public async Task TypeSearchAsync(params string[] texts) + { + foreach (var text in texts) + { + searchBox.Text = text; + await SettleAsync(); + } + } + + public async Task SettleAsync(int rounds = 6) + { + for (var i = 0; i < rounds; i++) + { + await Task.Delay(50); + Dispatcher.UIThread.RunJobs(); + } + } + + public void Close() => window.Close(); + } +}