diff --git a/docs/PACKAGE_MANAGERS.md b/docs/PACKAGE_MANAGERS.md index 0515a2a4dc..4772582f1d 100644 --- a/docs/PACKAGE_MANAGERS.md +++ b/docs/PACKAGE_MANAGERS.md @@ -149,14 +149,18 @@ Install Python, which includes pip: - **macOS:** `brew install python`, or download it from - **Linux:** use your distribution's packages, for example `python3-pip` on Ubuntu. -On Windows, UniGetUI runs `python -m pip` with the first `python` it finds on `PATH`. If typing -`python` in a terminal opens the Microsoft Store or reports that the command is not found, -UniGetUI cannot use it either. Python's documentation covers this case: open Start, search for -**Manage app execution aliases**, and check the Python entries there -(). - -On macOS and Linux, UniGetUI uses `pip3` or `pip` on `PATH` when one is there, and otherwise runs -`python -m pip`, or `python3 -m pip` when there is no `python`. +UniGetUI always runs `python -m pip` with the Python it selects. On Windows it looks for `python` +on `PATH`, then for Pythons installed in Program Files, `%LOCALAPPDATA%\Programs\Python` and the +Python install manager's `%LOCALAPPDATA%\Python`. On macOS and Linux it looks for `python3`, then +`python`, on `PATH`. If typing `python` in a Windows terminal opens the Microsoft Store or reports +that the command is not found, UniGetUI ranks that alias below any installed Python. Python's +documentation covers this case: open Start, search for **Manage app execution aliases**, and check +the Python entries there (). + +Virtual environments, conda environments and embedded Pythons usually belong to another app, so +UniGetUI does not manage them unless you check them under **Python environments** in the Pip +settings. Checking one that is not managed by default requires the administrator setting that +allows changing package manager executables. Check: `python -m pip --version` (or `python3 -m pip --version`) diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index 65c5f02fe2..4226cae8b4 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -2261,5 +2261,16 @@ "WinGet is not available on this system": "WinGet is not available on this system", "Kill the processes of any search, package listing or maintenance command {0} is still running": "Kill the processes of any search, package listing or maintenance command {0} is still running", "{0} is still loading.": "{0} is still loading.", - "No package manager matches your search": "No package manager matches your search" + "No package manager matches your search": "No package manager matches your search", + "UniGetUI only manages the Python environments checked below. Virtual environments, conda environments and embedded Pythons usually belong to other apps, so they are not managed unless you check them.": "UniGetUI only manages the Python environments checked below. Virtual environments, conda environments and embedded Pythons usually belong to other apps, so they are not managed unless you check them.", + "No Python interpreters were found": "No Python interpreters were found", + "Python environments": "Python environments", + "Virtual environment": "Virtual environment", + "Conda environment": "Conda environment", + "Embedded Python": "Embedded Python", + "Microsoft Store alias": "Microsoft Store alias", + "System Python": "System Python", + "In use": "In use", + "Manage the Python environment at {0}": "Manage the Python environment at {0}", + "Unrecognized Python": "Unrecognized Python" } diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index d7e3c0f5e8..4411215e8f 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -1001,6 +1001,12 @@ public void ReloadPagesWithoutReadyManager() InstalledPage.ViewModel.ReloadIfManagerBecameReady(); } + public void ReloadPagesAfterManagerExecutableChanged() + { + _ = UpdatesPage.ViewModel.ReloadAfterManagerExecutableChangedAsync(); + _ = InstalledPage.ViewModel.ReloadAfterManagerExecutableChangedAsync(); + } + private async Task OpenManagerSettingsAsync(IPackageManager? manager) { if (!await NavigateToAsync(PageType.Managers)) diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PackageManagerViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PackageManagerViewModel.cs index c8d154641a..bb1e542027 100644 --- a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PackageManagerViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PackageManagerViewModel.cs @@ -76,6 +76,7 @@ public async Task ReloadManager() { IsLoading = true; RefreshState(); + string previousExecutable = GetEffectiveExecutable(); ApplyProbe(await Task.Run(() => { Manager.Initialize(); @@ -83,9 +84,16 @@ public async Task ReloadManager() })); IsLoading = false; RefreshState(showVersion: true); - (MainWindow.Instance?.DataContext as MainWindowViewModel)?.ReloadPagesWithoutReadyManager(); + var mainWindow = MainWindow.Instance?.DataContext as MainWindowViewModel; + if (GetEffectiveExecutable() != previousExecutable) + mainWindow?.ReloadPagesAfterManagerExecutableChanged(); + else + mainWindow?.ReloadPagesWithoutReadyManager(); } + private string GetEffectiveExecutable() => + Manager.IsReady() ? $"{Manager.Status.ExecutablePath} {Manager.Status.ExecutableCallArgs}" : ""; + [RelayCommand] public void ShowVersion() => RefreshState(showVersion: true); diff --git a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs index d684718e1f..1ceaded700 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs @@ -1285,6 +1285,19 @@ public void ReloadIfManagerBecameReady() _ = LoadPackages(ReloadReason.Automated); } + public async Task ReloadAfterManagerExecutableChangedAsync() + { + if (!LoadsOnStart) + return; + + if (Loader.IsLoading) + await Loader.WaitForCurrentLoadAsync(); + else if (Loader.HasPendingInitialLoad) + return; + + await Loader.ReloadPackages(); + } + // ─── Sort commands ──────────────────────────────────────────────────────── [RelayCommand] private void SortByName() => SortFieldIndex = 0; [RelayCommand] private void SortById() => SortFieldIndex = 1; diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs index ba2e8404d7..5039943134 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs @@ -14,6 +14,7 @@ using UniGetUI.Interface.Enums; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.ManagerClasses.Manager; +using UniGetUI.PackageEngine.Managers.PipManager; using UniGetUI.PackageEngine.Managers.VcpkgManager; using CoreSettings = UniGetUI.Core.SettingsEngine.Settings; using CornerRadius = global::Avalonia.CornerRadius; @@ -102,17 +103,8 @@ private void BuildPage() string savedPath = CoreSettings.GetDictionaryItem(CoreSettings.K.ManagerPaths, manager.Name) ?? ""; if (!string.IsNullOrEmpty(savedPath) && File.Exists(savedPath)) AddExecutablePathItem(execCombo, savedPath); - if (string.IsNullOrEmpty(savedPath)) - { - var (found, path) = manager.GetExecutableFile(); - savedPath = found ? path : ""; - } - else if (!File.Exists(savedPath)) - { - var (found, path) = manager.GetExecutableFile(); - savedPath = found ? path : ""; - } - execCombo.SelectedItem = savedPath; + var (executableFound, executablePath) = manager.GetExecutableFile(); + execCombo.SelectedItem = executableFound ? executablePath : ""; execCombo.IsEnabled = customPathsAllowed; execCombo.SelectionChanged += (s, _) => { @@ -155,26 +147,9 @@ private void BuildPage() if (!customPathsAllowed) { - var securityWarning = new TextBlock - { - Text = CoreTools.Translate("For security reasons, changing the executable file is disabled by default"), - FontSize = 12, - FontWeight = FontWeight.SemiBold, - Opacity = 0.7, - TextWrapping = TextWrapping.Wrap, - Classes = { "setting-warning-text" }, - }; + var (securityWarning, goToSecureBtn) = BuildCustomPathsWarning(); Grid.SetRow(securityWarning, 2); execGrid.Children.Add(securityWarning); - - var goToSecureBtn = new Button - { - Content = new TextBlock { Text = CoreTools.Translate("Change this"), FontSize = 12, Classes = { "hyperlink" } }, - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Padding = new Thickness(0), - }; - goToSecureBtn.Click += (_, _) => ViewModel.NavigateToAdministratorCommand.Execute(null); Grid.SetRow(goToSecureBtn, 2); Grid.SetColumn(goToSecureBtn, 1); execGrid.Children.Add(goToSecureBtn); @@ -591,6 +566,13 @@ private void BuildExtraControls(CheckboxCard_Dict disableNotifsCard) }); break; + case "Pip": + disableNotifsCard.CornerRadius = new CornerRadius(8, 8, 0, 0); + disableNotifsCard.BorderThickness = new Thickness(1, 1, 1, 0); + ExtraControls.Children.Add(disableNotifsCard); + ExtraControls.Children.Add(BuildPythonEnvironmentsCard()); + break; + case "vcpkg": disableNotifsCard.CornerRadius = new CornerRadius(8, 8, 0, 0); disableNotifsCard.BorderThickness = new Thickness(1, 1, 1, 0); @@ -618,6 +600,148 @@ private void BuildExtraControls(CheckboxCard_Dict disableNotifsCard) } } + private SettingsCard BuildPythonEnvironmentsCard() + { + var rows = new StackPanel { Spacing = 8, Margin = new Thickness(0, 8, 0, 0) }; + var description = new StackPanel { Spacing = 4 }; + description.Children.Add(new TextBlock + { + Text = CoreTools.Translate("UniGetUI only manages the Python environments checked below. Virtual environments, conda environments and embedded Pythons usually belong to other apps, so they are not managed unless you check them."), + FontSize = 12, + Opacity = 0.7, + TextWrapping = TextWrapping.Wrap, + }); + description.Children.Add(rows); + + if (!Pip.CanOptInEnvironments()) + { + var gate = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto") }; + var (warning, change) = BuildCustomPathsWarning(); + gate.Children.Add(warning); + Grid.SetColumn(change, 1); + gate.Children.Add(change); + description.Children.Add(gate); + } + + void Fill() + { + rows.Children.Clear(); + List interpreters = []; + string savedPath = CoreSettings.GetDictionaryItem(CoreSettings.K.ManagerPaths, ViewModel.Manager.Name) ?? ""; + foreach (string path in ViewModel.CandidateExecutables.Append(savedPath).Append(ViewModel.Manager.Status.ExecutablePath)) + { + if (path.Length > 0 && File.Exists(path) && !interpreters.Any(known => Pip.IsSameEnvironment(known, path))) + interpreters.Add(path); + } + + if (interpreters.Count == 0) + { + rows.Children.Add(new TextBlock + { + Text = CoreTools.Translate("No Python interpreters were found"), + FontSize = 12, + }); + return; + } + + foreach (string path in interpreters) + rows.Children.Add(BuildPythonEnvironmentRow(path)); + } + + Fill(); + rows.IsEnabled = !ViewModel.IsLoading; + ViewModel.PropertyChanged += (_, e) => + { + if (e.PropertyName != nameof(PackageManagerViewModel.IsLoading)) + return; + + rows.IsEnabled = !ViewModel.IsLoading; + if (!ViewModel.IsLoading) + Fill(); + }; + + return new SettingsCard + { + Header = CoreTools.Translate("Python environments"), + Description = description, + CornerRadius = new CornerRadius(0, 0, 8, 8), + BorderThickness = new Thickness(1, 0, 1, 1), + }; + } + + private Grid BuildPythonEnvironmentRow(string path) + { + PythonEnvironmentKind kind = Pip.ClassifyEnvironment(path); + string kindText = kind switch + { + PythonEnvironmentKind.VirtualEnvironment => CoreTools.Translate("Virtual environment"), + PythonEnvironmentKind.CondaEnvironment => CoreTools.Translate("Conda environment"), + PythonEnvironmentKind.Embedded => CoreTools.Translate("Embedded Python"), + PythonEnvironmentKind.StoreAlias => CoreTools.Translate("Microsoft Store alias"), + PythonEnvironmentKind.Unknown => CoreTools.Translate("Unrecognized Python"), + _ => CoreTools.Translate("System Python"), + }; + if (Pip.IsSameEnvironment(ViewModel.Manager.Status.ExecutablePath, path)) + kindText += " · " + CoreTools.Translate("In use"); + + var row = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto") }; + var labels = new StackPanel { Spacing = 2 }; + labels.Children.Add(new TextBlock + { + Text = path, + FontFamily = new FontFamily("Consolas,Cascadia Mono,Menlo,monospace"), + TextWrapping = TextWrapping.Wrap, + }); + labels.Children.Add(new TextBlock + { + Text = kindText, + FontSize = 12, + Opacity = 0.7, + }); + row.Children.Add(labels); + + var manage = new CheckBox + { + Content = CoreTools.Translate("Manage"), + IsChecked = Pip.IsEnvironmentManaged(path, kind) + || (ViewModel.Manager is Pip pip && pip.IsChosenExecutable(path)), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(8, 0, 0, 0), + }; + manage.IsEnabled = Pip.CanOptInEnvironments() || Pip.IsManagedByDefault(kind) || manage.IsChecked == true; + AutomationProperties.SetName(manage, CoreTools.Translate("Manage the Python environment at {0}", path)); + manage.IsCheckedChanged += (_, _) => + { + Pip.SetEnvironmentManaged(path, manage.IsChecked == true); + _ = ViewModel.ReloadManagerCommand.ExecuteAsync(null); + }; + Grid.SetColumn(manage, 1); + row.Children.Add(manage); + return row; + } + + private (TextBlock Warning, Button Change) BuildCustomPathsWarning() + { + var warning = new TextBlock + { + Text = CoreTools.Translate("For security reasons, changing the executable file is disabled by default"), + FontSize = 12, + FontWeight = FontWeight.SemiBold, + Opacity = 0.7, + TextWrapping = TextWrapping.Wrap, + Classes = { "setting-warning-text" }, + }; + var change = new Button + { + Content = new TextBlock { Text = CoreTools.Translate("Change this"), FontSize = 12, Classes = { "hyperlink" } }, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Padding = new Thickness(0), + }; + change.Click += (_, _) => ViewModel.NavigateToAdministratorCommand.Execute(null); + return (warning, change); + } + private ButtonCard BuildVcpkgRootCard() { var vcpkgRootCard = new ButtonCard diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs index 65f777a1de..7e460e711b 100644 --- a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs +++ b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs @@ -136,6 +136,7 @@ public enum K TuiTheme, BunPreferLatestVersions, DisablePipHttpUpdateCheck, + PipEnvironmentOverrides, RedactUsernameInLog, DisableReleaseNotesOnUpdate, LastKnownBuildNumber, @@ -297,6 +298,7 @@ public static string ResolveKey(K key) K.TuiTheme => "TuiTheme", K.BunPreferLatestVersions => "BunPreferLatestVersions", K.DisablePipHttpUpdateCheck => "DisablePipHttpUpdateCheck", + K.PipEnvironmentOverrides => "PipEnvironmentOverrides", K.RedactUsernameInLog => "RedactUsernameInLog", K.DisableReleaseNotesOnUpdate => "DisableReleaseNotesOnUpdate", K.LastKnownBuildNumber => "LastKnownBuildNumber", diff --git a/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgDetailsHelper.cs index 57f8e9d4fd..ef6792fbd4 100644 --- a/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgDetailsHelper.cs @@ -135,13 +135,12 @@ protected override IReadOnlyList GetScreenshots_UnSafe(IPackage package) protected override string? GetInstallLocation_UnSafe(IPackage package) { - var full_path = Path.Join( - Path.GetDirectoryName(Manager.Status.ExecutablePath), - "Lib", - "site-packages", - package.Id + return Pip.ResolveInstallLocation( + Manager.Status.Version, + Manager.Status.ExecutablePath, + package.Id, + Directory.Exists ); - return Directory.Exists(full_path) ? full_path : Path.GetDirectoryName(full_path); } protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage package) diff --git a/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs b/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs index 50a6d75e10..c460911826 100644 --- a/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs +++ b/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Globalization; using System.Net; using System.Text; using System.Text.Json; @@ -8,6 +9,7 @@ using UniGetUI.Core.Data; using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.SettingsEngine.SecureSettings; using UniGetUI.Core.Tools; using UniGetUI.Interface.Enums; using UniGetUI.PackageEngine.Classes.Manager; @@ -460,6 +462,42 @@ private bool HasCustomIndexConfigured() return ParseInterpreterVersion(Status.Version); } + [GeneratedRegex( + @"^pip \S+ from (?.+?)[\\/]pip[\\/]? \(python ", + RegexOptions.CultureInvariant + )] + private static partial Regex SitePackagesPattern(); + + internal static string? ParseSitePackagesDirectory(string? pipVersionOutput) + { + if (pipVersionOutput is null) + return null; + + Match match = SitePackagesPattern().Match(pipVersionOutput.Trim()); + return match.Success ? match.Groups["path"].Value : null; + } + + internal static string ResolveInstallLocation( + string? pipVersionOutput, + string executablePath, + string packageId, + Func directoryExists + ) + { + string sitePackages = + ParseSitePackagesDirectory(pipVersionOutput) + ?? Path.Join(Path.GetDirectoryName(executablePath), "Lib", "site-packages"); + + foreach (string name in new[] { packageId, packageId.Replace('-', '_') }) + { + string candidate = Path.Join(sitePackages, name); + if (directoryExists(candidate)) + return candidate; + } + + return sitePackages; + } + internal static string? ParseInterpreterVersion(string? pipVersionOutput) { if (pipVersionOutput is null) @@ -928,47 +966,269 @@ bool expectAvailableVersion public override IReadOnlyList FindCandidateExecutableFiles() { - var FoundPaths = CoreTools.WhichMultiple("python"); - if (!OperatingSystem.IsWindows() && !FoundPaths.Any()) - FoundPaths = CoreTools.WhichMultiple("python3"); - List Paths = []; + IEnumerable foundPaths = OperatingSystem.IsWindows() + ? CoreTools.WhichMultiple("python") + : CoreTools.WhichMultiple("python3").Concat(CoreTools.WhichMultiple("python")); - if (FoundPaths.Any()) - foreach (var Path in FoundPaths) - Paths.Add(Path); + IReadOnlyList installed = OperatingSystem.IsWindows() + ? FindInstalledPythons(GetPythonInstallRoots()) + : []; - try - { - List DirsToSearch = []; - string ProgramFiles = @"C:\Program Files"; - string? UserPythonInstallDir = null; - string? AppData = Environment.GetEnvironmentVariable("APPDATA"); + IReadOnlyList ranked = RankCandidates(foundPaths, installed); + _lastCandidates = ranked; + return ranked.Select(candidate => candidate.Path).ToList(); + } - if (AppData != null) - UserPythonInstallDir = Path.Combine(AppData, "Programs", "Python"); + internal readonly record struct PythonCandidate( + string Path, + PythonEnvironmentKind Kind, + bool Managed + ); - if (Directory.Exists(ProgramFiles)) - DirsToSearch.Add(ProgramFiles); - if (Directory.Exists(UserPythonInstallDir)) - DirsToSearch.Add(UserPythonInstallDir); + private IReadOnlyList _lastCandidates = []; + private readonly HashSet _reportedUnmanaged = []; + + private static string[] GetPythonInstallRoots() + { + string localAppData = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData + ); + return + [ + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + localAppData.Length > 0 ? Path.Join(localAppData, "Programs", "Python") : "", + localAppData.Length > 0 ? Path.Join(localAppData, "Python") : "", + ]; + } + + [GeneratedRegex( + @"^(?:Python|pythoncore-)(?[0-9])\.?(?[0-9]{1,3})(?:[^0-9]|$)", + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase + )] + private static partial Regex PythonInstallDirectoryPattern(); - foreach (var Dir in DirsToSearch) + internal static IReadOnlyList FindInstalledPythons(IEnumerable roots) + { + List<(Version Version, string Path)> found = []; + foreach (string root in roots.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!Path.IsPathRooted(root) || !Directory.Exists(root)) + continue; + + try + { + foreach (string directory in Directory.EnumerateDirectories(root)) + { + Match match = PythonInstallDirectoryPattern().Match(Path.GetFileName(directory)); + string executable = Path.Join(directory, "python.exe"); + if (!match.Success || !File.Exists(executable)) + continue; + + found.Add(( + new Version( + int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture), + int.Parse(match.Groups["minor"].Value, CultureInfo.InvariantCulture) + ), + executable + )); + } + } + catch (Exception e) { - string DirName = Path.GetFileName(Dir); - string PythonPath = Path.Join(Dir, "python.exe"); - if (DirName.StartsWith("Python") && File.Exists(PythonPath)) - Paths.Add(PythonPath); + Logger.Debug($"Pip: {root} could not be searched for Python installs ({e.Message})"); } } - catch (Exception) { } - return Paths; + return found.OrderByDescending(f => f.Version).Select(f => f.Path).ToList(); + } + + internal static IReadOnlyList OrderCandidates( + IEnumerable onPath, + IEnumerable installed + ) => RankCandidates(onPath, installed).Select(candidate => candidate.Path).ToList(); + + internal static IReadOnlyList RankCandidates( + IEnumerable onPath, + IEnumerable installed + ) => + onPath + .Concat(installed) + .DistinctBy(GetCandidateIdentity, StringComparer.Ordinal) + .Select(path => + { + PythonEnvironmentKind kind = ClassifyEnvironment(path); + return new PythonCandidate(path, kind, IsEnvironmentManaged(path, kind)); + }) + .OrderBy(GetCandidateRank) + .ToList(); + + private static string GetCandidateIdentity(string path) => + GetEnvironmentKey(path) + ?? (OperatingSystem.IsWindows() ? path.ToLowerInvariant() : path); + + private static int GetCandidateRank(PythonCandidate candidate) + { + if (!candidate.Managed) + return 2; + + return candidate.Kind is PythonEnvironmentKind.StoreAlias + && IsStoreInstallerStub(candidate.Path) + ? 1 + : 0; + } + + internal static bool IsAppExecutionAlias(string path) => + OperatingSystem.IsWindows() + && string.Equals( + Path.GetFileName(Path.GetDirectoryName(path)), + "WindowsApps", + StringComparison.OrdinalIgnoreCase + ); + + internal static bool IsStoreInstallerStub(string aliasPath) + { + try + { + string? directory = Path.GetDirectoryName(Path.GetFullPath(aliasPath)); + return directory is not null + && !Directory + .EnumerateDirectories(directory, "PythonSoftwareFoundation.*") + .Any(); + } + catch (Exception) + { + return false; + } + } + + public static PythonEnvironmentKind ClassifyEnvironment(string executablePath) + { + string? directory; + try + { + directory = Path.GetDirectoryName(Path.GetFullPath(executablePath)); + } + catch (Exception) + { + return PythonEnvironmentKind.Unknown; + } + + if (directory is null) + return PythonEnvironmentKind.Unknown; + + string? parent = Path.GetDirectoryName(directory); + if ( + File.Exists(Path.Join(directory, "pyvenv.cfg")) + || (parent is not null && File.Exists(Path.Join(parent, "pyvenv.cfg"))) + ) + return PythonEnvironmentKind.VirtualEnvironment; + + if ( + IsCondaNamedEnvironment(directory) + || (parent is not null && IsCondaNamedEnvironment(parent)) + ) + return PythonEnvironmentKind.CondaEnvironment; + + if (IsAppExecutionAlias(executablePath)) + return PythonEnvironmentKind.StoreAlias; + + try + { + if (Directory.EnumerateFiles(directory, "python*._pth").Any()) + return PythonEnvironmentKind.Embedded; + } + catch (Exception) + { + return PythonEnvironmentKind.Unknown; + } + + return PythonEnvironmentKind.System; } - public override bool HasExecutable() => - (!OperatingSystem.IsWindows() - && (CoreTools.WhichMultiple("pip3").Count > 0 || CoreTools.WhichMultiple("pip").Count > 0)) - || base.HasExecutable(); + private static bool IsCondaNamedEnvironment(string directory) => + Directory.Exists(Path.Join(directory, "conda-meta")) + && string.Equals( + Path.GetFileName(Path.GetDirectoryName(directory)), + "envs", + StringComparison.OrdinalIgnoreCase + ); + + public static bool IsManagedByDefault(PythonEnvironmentKind kind) => + kind is PythonEnvironmentKind.System or PythonEnvironmentKind.StoreAlias; + + public static bool CanOptInEnvironments() => + SecureSettings.Get(SecureSettings.K.AllowCustomManagerPaths); + + private static string? GetEnvironmentKey(string executablePath) + { + try + { + string directory = + Path.GetDirectoryName(Path.GetFullPath(executablePath)) ?? executablePath; + return OperatingSystem.IsWindows() ? directory.ToLowerInvariant() : directory; + } + catch (Exception) + { + return null; + } + } + + public static bool IsSameEnvironment(string executableA, string executableB) => + GetEnvironmentKey(executableA) is { } keyA + && GetEnvironmentKey(executableB) is { } keyB + && keyA == keyB; + + public static bool? GetEnvironmentChoice(string executablePath) + { + if (GetEnvironmentKey(executablePath) is not { } key) + return null; + + return Settings.GetDictionaryItem(Settings.K.PipEnvironmentOverrides, key) switch + { + "managed" when CanOptInEnvironments() => true, + "ignored" => false, + _ => null, + }; + } + + public static bool IsEnvironmentManaged(string executablePath) => + IsEnvironmentManaged(executablePath, ClassifyEnvironment(executablePath)); + + public static bool IsEnvironmentManaged(string executablePath, PythonEnvironmentKind kind) => + GetEnvironmentChoice(executablePath) ?? IsManagedByDefault(kind); + + public static void SetEnvironmentManaged(string executablePath, bool managed) + { + if (GetEnvironmentKey(executablePath) is not { } key) + return; + + if (!managed) + Settings.SetDictionaryItem(Settings.K.PipEnvironmentOverrides, key, "ignored"); + else if (IsManagedByDefault(ClassifyEnvironment(executablePath))) + Settings.RemoveDictionaryKey(Settings.K.PipEnvironmentOverrides, key); + else if (CanOptInEnvironments()) + Settings.SetDictionaryItem(Settings.K.PipEnvironmentOverrides, key, "managed"); + } + + protected override bool CanSelectAutomatically(string candidate) + { + foreach (PythonCandidate known in _lastCandidates) + { + if (known.Path == candidate) + return known.Managed; + } + + return IsEnvironmentManaged(candidate); + } + + protected override bool CanUseSelectedExecutable(string path) => + GetEnvironmentChoice(path) is not false; + + public bool IsChosenExecutable(string path) => + GetUserSelectedExecutablePath() is { } selected + && IsSameEnvironment(selected, path) + && CanUseSelectedExecutable(selected); protected override void _loadManagerExecutableFile( out bool found, @@ -976,24 +1236,26 @@ protected override void _loadManagerExecutableFile( out string callArguments ) { - // On non-Windows, prefer pip3/pip as standalone executables (avoids "No module named pip" - // errors on systems where pip is installed as a command but not as a Python module). - // Fall back to python/python3 + "-m pip" if no standalone pip is found. - if (!OperatingSystem.IsWindows()) + var (pythonFound, python) = GetExecutableFile(); + + if (!pythonFound) { - var pipPaths = CoreTools.WhichMultiple("pip3").Concat(CoreTools.WhichMultiple("pip")).ToList(); - if (pipPaths.Count > 0) + foreach (PythonCandidate skipped in _lastCandidates) { - found = true; - path = pipPaths[0]; - callArguments = ""; - return; + bool firstReport; + lock (_reportedUnmanaged) + firstReport = _reportedUnmanaged.Add(skipped.Path); + + if (firstReport) + Logger.Warn( + $"Pip: {skipped.Path} ({skipped.Kind}) is not managed, " + + "allow it in the Pip settings to manage it" + ); } } - var (_found, _path) = GetExecutableFile(); - found = _found; - path = _path; + found = pythonFound; + path = pythonFound ? python : ""; callArguments = "-m pip "; } diff --git a/src/UniGetUI.PackageEngine.Managers.Pip/PythonEnvironmentKind.cs b/src/UniGetUI.PackageEngine.Managers.Pip/PythonEnvironmentKind.cs new file mode 100644 index 0000000000..2037e16e25 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Pip/PythonEnvironmentKind.cs @@ -0,0 +1,12 @@ +namespace UniGetUI.PackageEngine.Managers.PipManager +{ + public enum PythonEnvironmentKind + { + System, + StoreAlias, + VirtualEnvironment, + CondaEnvironment, + Embedded, + Unknown, + } +} diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/PackageManager.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/PackageManager.cs index 1d69352e38..f75f0a35b7 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/PackageManager.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/PackageManager.cs @@ -258,9 +258,15 @@ private void _logManagerInfo() /// A tuple containing: a boolean that represents whether the path was found or not; the path to the file if found. public abstract IReadOnlyList FindCandidateExecutableFiles(); + protected virtual bool CanSelectAutomatically(string candidate) => true; + + protected virtual bool CanUseSelectedExecutable(string path) => true; + public Tuple GetExecutableFile() { - var candidates = FindCandidateExecutableFiles(); + var candidates = FindCandidateExecutableFiles() + .Where(CanSelectAutomatically) + .ToList(); // If custom package manager paths are DISABLED, get the first one (as old UniGetUI did) and return it. if (!SecureSettings.Get(SecureSettings.K.AllowCustomManagerPaths)) @@ -296,6 +302,12 @@ public Tuple GetExecutableFile() $"The selected executable path {exeSelection} for manager {Name} does not exist, the default one will be used if available..." ); } + else if (!CanUseSelectedExecutable(exeSelection)) + { + Logger.Warn( + $"The selected executable path {exeSelection} for manager {Name} has been excluded, the default one will be used if available..." + ); + } else { @@ -314,11 +326,14 @@ public Tuple GetExecutableFile() public virtual bool HasExecutable() => GetExecutableFile().Item1; - protected bool IsUserSelectedExecutablePath(string path) + protected bool IsUserSelectedExecutablePath(string path) => + GetUserSelectedExecutablePath()?.Equals(path, StringComparison.OrdinalIgnoreCase) == true; + + protected string? GetUserSelectedExecutablePath() { if (!SecureSettings.Get(SecureSettings.K.AllowCustomManagerPaths)) { - return false; + return null; } string? exeSelection = Settings.GetDictionaryItem( @@ -326,9 +341,9 @@ protected bool IsUserSelectedExecutablePath(string path) Name ); - return !string.IsNullOrEmpty(exeSelection) - && File.Exists(exeSelection) - && exeSelection.Equals(path, StringComparison.OrdinalIgnoreCase); + return !string.IsNullOrEmpty(exeSelection) && File.Exists(exeSelection) + ? exeSelection + : null; } /// diff --git a/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs index 18d5095802..5defb19848 100644 --- a/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs @@ -1,6 +1,8 @@ using UniGetUI.Core.Data; using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.SettingsEngine.SecureSettings; using UniGetUI.Core.Tools; +using UniGetUI.Core.Tools.Tests; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Managers.PipManager; using UniGetUI.PackageEngine.Serializable; @@ -31,6 +33,10 @@ public PipManagerTests() Directory.CreateDirectory(_testRoot); CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory); + string secureSettingsRoot = Path.Combine(_testRoot, "SecureSettings"); + Directory.CreateDirectory(secureSettingsRoot); + SecureSettings.TEST_SecureSettingsRootOverride = secureSettingsRoot; + AllowCustomManagerPaths(false); Settings.ResetSettings(); Settings.Set(Settings.K.EnableProxy, false); Settings.Set(Settings.K.EnableProxyAuth, false); @@ -39,6 +45,8 @@ public PipManagerTests() public void Dispose() { + AllowCustomManagerPaths(false); + SecureSettings.TEST_SecureSettingsRootOverride = null; Settings.ResetSettings(); CoreData.TEST_DataDirectoryOverride = null; if (Directory.Exists(_testRoot)) @@ -47,6 +55,322 @@ public void Dispose() } } + [Fact] + public void InterpretersAreClassifiedByTheEnvironmentTheyBelongTo() + { + string venvPython = CreateFile("app", "venv", "Scripts", "python.exe"); + CreateFile("app", "venv", "pyvenv.cfg"); + string flatVenvPython = CreateFile("flat", "python.exe"); + CreateFile("flat", "pyvenv.cfg"); + string condaEnvPython = CreateFile("miniconda3", "envs", "tools", "python.exe"); + Directory.CreateDirectory(Path.Combine(_testRoot, "miniconda3", "envs", "tools", "conda-meta")); + string condaUnixEnvPython = CreateFile("miniforge", "envs", "ml", "bin", "python"); + Directory.CreateDirectory(Path.Combine(_testRoot, "miniforge", "envs", "ml", "conda-meta")); + string condaBasePython = CreateFile("miniconda3", "python.exe"); + Directory.CreateDirectory(Path.Combine(_testRoot, "miniconda3", "conda-meta")); + string embeddedPython = CreateFile("someapp", "python", "python.exe"); + CreateFile("someapp", "python", "python313._pth"); + string systemPython = CreateFile("Python314", "python.exe"); + + Assert.Equal(PythonEnvironmentKind.VirtualEnvironment, Pip.ClassifyEnvironment(venvPython)); + Assert.Equal(PythonEnvironmentKind.VirtualEnvironment, Pip.ClassifyEnvironment(flatVenvPython)); + Assert.Equal(PythonEnvironmentKind.CondaEnvironment, Pip.ClassifyEnvironment(condaEnvPython)); + Assert.Equal(PythonEnvironmentKind.CondaEnvironment, Pip.ClassifyEnvironment(condaUnixEnvPython)); + Assert.Equal(PythonEnvironmentKind.System, Pip.ClassifyEnvironment(condaBasePython)); + Assert.Equal(PythonEnvironmentKind.Embedded, Pip.ClassifyEnvironment(embeddedPython)); + Assert.Equal(PythonEnvironmentKind.System, Pip.ClassifyEnvironment(systemPython)); + Assert.True(Pip.IsEnvironmentManaged(systemPython)); + Assert.False(Pip.IsEnvironmentManaged(venvPython)); + Assert.False(Pip.IsEnvironmentManaged(condaEnvPython)); + Assert.False(Pip.IsEnvironmentManaged(embeddedPython)); + } + + [Fact] + public void EnvironmentChoicesOverrideTheDefaultAndReorderTheCandidates() + { + string venvPython = CreateFile("app", "venv", "Scripts", "python.exe"); + CreateFile("app", "venv", "pyvenv.cfg"); + string systemPython = CreateFile("Python314", "python.exe"); + + Pip.SetEnvironmentManaged(venvPython, true); + Assert.Null(Pip.GetEnvironmentChoice(venvPython)); + + AllowCustomManagerPaths(true); + Pip.SetEnvironmentManaged(venvPython, true); + Pip.SetEnvironmentManaged(systemPython, false); + + Assert.True(Pip.GetEnvironmentChoice(venvPython)); + Assert.False(Pip.GetEnvironmentChoice(systemPython)); + Assert.Equal([venvPython, systemPython], Pip.OrderCandidates([systemPython, venvPython], [])); + + Pip.SetEnvironmentManaged(venvPython, false); + Pip.SetEnvironmentManaged(systemPython, true); + + Assert.False(Pip.GetEnvironmentChoice(venvPython)); + Assert.Null(Pip.GetEnvironmentChoice(systemPython)); + Assert.Equal([systemPython, venvPython], Pip.OrderCandidates([venvPython, systemPython], [])); + } + + [Fact] + public void AnOptInStopsCountingOnceCustomPathsAreDisallowedAgain() + { + string venvPython = CreateFile("app", "venv", "Scripts", "python.exe"); + CreateFile("app", "venv", "pyvenv.cfg"); + + AllowCustomManagerPaths(true); + Pip.SetEnvironmentManaged(venvPython, true); + Assert.True(Pip.IsEnvironmentManaged(venvPython)); + + AllowCustomManagerPaths(false); + Assert.False(Pip.IsEnvironmentManaged(venvPython)); + } + + [Fact] + public void AnInterpreterThatCannotBeInspectedIsNotManaged() + { + Assert.Equal(PythonEnvironmentKind.Unknown, Pip.ClassifyEnvironment("bad\0path")); + Assert.False(Pip.IsEnvironmentManaged("bad\0path")); + } + + [Fact] + public void AutomaticSelectionSkipsUnmanagedEnvironments() + { + string venvPython = CreateFile("app", "venv", "Scripts", "python.exe"); + CreateFile("app", "venv", "pyvenv.cfg"); + string systemPython = CreateFile("Python314", "python.exe"); + + Assert.Equal(new(false, ""), new ScriptedPip([venvPython]).GetExecutableFile()); + Assert.Equal(new(true, systemPython), new ScriptedPip([venvPython, systemPython]).GetExecutableFile()); + } + + [Fact] + public void ASelectedExecutableIsHonouredUnlessItsEnvironmentIsIgnored() + { + string venvPython = CreateFile("app", "venv", "Scripts", "python.exe"); + CreateFile("app", "venv", "pyvenv.cfg"); + string firstSystem = CreateFile("Python314", "python.exe"); + string secondSystem = CreateFile("Python313", "python.exe"); + AllowCustomManagerPaths(true); + var pip = new ScriptedPip([firstSystem, secondSystem, venvPython]); + + Settings.SetDictionaryItem(Settings.K.ManagerPaths, pip.Name, venvPython); + Assert.Equal(new(true, venvPython), pip.GetExecutableFile()); + + Settings.SetDictionaryItem(Settings.K.ManagerPaths, pip.Name, secondSystem); + Pip.SetEnvironmentManaged(secondSystem, false); + Assert.Equal(new(true, firstSystem), pip.GetExecutableFile()); + } + + [Fact] + public void APickedExecutableIsRecognisedThroughAnyInterpreterInItsEnvironment() + { + string venvPython = CreateFile("app", "venv", "bin", "python"); + string venvPython3 = CreateFile("app", "venv", "bin", "python3"); + CreateFile("app", "venv", "pyvenv.cfg"); + string systemPython3 = CreateFile("usr", "bin", "python3"); + AllowCustomManagerPaths(true); + var pip = new ScriptedPip([systemPython3, venvPython3]); + Settings.SetDictionaryItem(Settings.K.ManagerPaths, pip.Name, venvPython); + + Assert.True(pip.IsChosenExecutable(venvPython3)); + Assert.False(pip.IsChosenExecutable(systemPython3)); + + Pip.SetEnvironmentManaged(venvPython3, false); + + Assert.False(pip.IsChosenExecutable(venvPython3)); + Assert.Equal(new(true, systemPython3), pip.GetExecutableFile()); + } + + [Fact] + public void AnEnvironmentIsRepresentedByTheFirstInterpreterFoundInIt() + { + string python3 = CreateFile("usr", "bin", "python3"); + string python = CreateFile("usr", "bin", "python"); + + Assert.Equal([python3], Pip.OrderCandidates([python3, python], [])); + } + + [Fact] + public void AVenvOnPathDoesNotHideTheSystemPythonAndEachEnvironmentIsListedOnce() + { + string venvPython = CreateFile("app", "venv", "bin", "python"); + string venvPython3 = CreateFile("app", "venv", "bin", "python3"); + CreateFile("app", "venv", "pyvenv.cfg"); + string systemPython3 = CreateFile("usr", "bin", "python3"); + + Assert.Equal( + [systemPython3, venvPython], + Pip.OrderCandidates([venvPython, venvPython3, systemPython3], []) + ); + } + + [Fact] + public void UncheckingThePickedExecutableExcludesIt() + { + string venvPython = CreateFile("app", "venv", "Scripts", "python.exe"); + CreateFile("app", "venv", "pyvenv.cfg"); + string systemPython = CreateFile("Python314", "python.exe"); + AllowCustomManagerPaths(true); + var pip = new ScriptedPip([systemPython, venvPython]); + Settings.SetDictionaryItem(Settings.K.ManagerPaths, pip.Name, venvPython); + + Assert.True(pip.IsChosenExecutable(venvPython)); + Assert.Equal(new(true, venvPython), pip.GetExecutableFile()); + + Pip.SetEnvironmentManaged(venvPython, false); + + Assert.False(pip.IsChosenExecutable(venvPython)); + Assert.Equal(new(true, systemPython), pip.GetExecutableFile()); + } + + [Fact] + public void InstallLocationComesFromTheSitePackagesPipReports() + { + string sitePackages = Path.Combine("app", "venv", "Lib", "site-packages"); + string banner = $"pip 26.0 from {Path.Combine(sitePackages, "pip")} (python 3.13)"; + string python = Path.Combine("app", "venv", "Scripts", "python.exe"); + + Assert.Equal( + Path.Combine(sitePackages, "requests"), + Pip.ResolveInstallLocation(banner, python, "requests", d => d == Path.Combine(sitePackages, "requests")) + ); + Assert.Equal( + Path.Combine(sitePackages, "typing_extensions"), + Pip.ResolveInstallLocation(banner, python, "typing-extensions", d => d == Path.Combine(sitePackages, "typing_extensions")) + ); + Assert.Equal(sitePackages, Pip.ResolveInstallLocation(banner, python, "PyYAML", _ => false)); + Assert.Equal( + Path.Join(Path.Combine("app", "venv", "Scripts"), "Lib", "site-packages"), + Pip.ResolveInstallLocation("", python, "PyYAML", _ => false) + ); + } + + [Fact] + public void InterpretersInTheSameDirectoryShareAnEnvironment() + { + string python = CreateFile("env", "bin", "python3"); + string pip = CreateFile("env", "bin", "pip3"); + string other = CreateFile("other", "bin", "python3"); + + Assert.True(Pip.IsSameEnvironment(pip, python)); + Assert.False(Pip.IsSameEnvironment(other, python)); + Assert.False(Pip.IsSameEnvironment("", python)); + } + + [Fact] + public void OnlyTheStoreInstallerAliasIsTreatedAsAStub() + { + string stub = CreateFile("stub", "WindowsApps", "python.exe"); + string realStore = CreateFile("store", "WindowsApps", "python.exe"); + Directory.CreateDirectory( + Path.Combine(_testRoot, "store", "WindowsApps", "PythonSoftwareFoundation.Python.3.12_qbz5n2kfra8p0") + ); + + Assert.True(Pip.IsStoreInstallerStub(stub)); + Assert.False(Pip.IsStoreInstallerStub(realStore)); + } + + private void AllowCustomManagerPaths(bool allowed) => + SecureSettings.ApplyForUser( + Environment.UserName, + SecureSettings.ResolveKey(SecureSettings.K.AllowCustomManagerPaths), + allowed + ); + + private sealed class ScriptedPip(IReadOnlyList candidates) : Pip + { + public override IReadOnlyList FindCandidateExecutableFiles() => candidates; + } + + [Theory] + [InlineData( + @"pip 26.2.1 from C:\Python313\Lib\site-packages\pip (python 3.13)", + @"C:\Python313\Lib\site-packages" + )] + [InlineData( + "pip 24.0 from /home/me/app/venv/lib/python3.12/site-packages/pip (python 3.12)", + "/home/me/app/venv/lib/python3.12/site-packages" + )] + [InlineData( + @"pip 25.1 from C:\Program Files\My Python\Lib\site-packages\pip (python 3.14)", + @"C:\Program Files\My Python\Lib\site-packages" + )] + [InlineData("", null)] + [InlineData(null, null)] + public void ParseSitePackagesDirectoryReadsThePipVersionBanner(string? banner, string? expected) + { + Assert.Equal(expected, Pip.ParseSitePackagesDirectory(banner)); + } + + [Fact] + public void CandidateInterpretersKeepVirtualEnvironmentsBehindSystemPythons() + { + string venvPython = CreateFile("app", "venv", "Scripts", "python.exe"); + CreateFile("app", "venv", "pyvenv.cfg"); + string firstSystem = CreateFile("Python314", "python.exe"); + string secondSystem = CreateFile("Python313", "python.exe"); + + Assert.Equal( + [firstSystem, secondSystem, venvPython], + Pip.OrderCandidates([venvPython, firstSystem], [secondSystem]) + ); + } + + [Fact] + public void InstalledPythonsOffPathAreFoundNewestFirst() + { + string programFiles = Path.Combine(_testRoot, "Program Files"); + string userInstalls = Path.Combine(_testRoot, "Local", "Programs", "Python"); + string installManager = Path.Combine(_testRoot, "Local", "Python"); + string python39 = CreateFile("Program Files", "Python39", "python.exe"); + string python313 = CreateFile("Local", "Programs", "Python", "Python313-arm64", "python.exe"); + string python314 = CreateFile("Local", "Python", "pythoncore-3.14-64", "python.exe"); + CreateFile("Program Files", "PythonManager", "python.exe"); + CreateFile("Program Files", "Python312", "pythonw.exe"); + CreateFile("Program Files", "Other", "python.exe"); + + Assert.Equal( + [python314, python313, python39], + Pip.FindInstalledPythons( + [programFiles, userInstalls, installManager, "", Path.Combine(_testRoot, "missing")] + ) + ); + } + + [WindowsFact] + public void CandidateInterpretersRankTheStoreStubBehindInstalledPythons() + { + string alias = CreateFile("Microsoft", "WindowsApps", "python.exe"); + string venvPython = CreateFile("app", "venv", "Scripts", "python.exe"); + CreateFile("app", "venv", "pyvenv.cfg"); + string installed = CreateFile("Python314", "python.exe"); + + Assert.Equal( + [installed, alias, venvPython], + Pip.OrderCandidates([venvPython, alias, installed], [installed.ToUpperInvariant()]) + ); + } + + [WindowsFact] + public void AnInstalledStorePythonKeepsItsPlaceOnPath() + { + string alias = CreateFile("Microsoft", "WindowsApps", "python.exe"); + Directory.CreateDirectory( + Path.Combine(_testRoot, "Microsoft", "WindowsApps", "PythonSoftwareFoundation.Python.3.12_qbz5n2kfra8p0") + ); + string installed = CreateFile("Python313", "python.exe"); + + Assert.Equal([alias, installed], Pip.OrderCandidates([alias], [installed])); + } + + private string CreateFile(params string[] segments) + { + string path = Path.Combine([_testRoot, .. segments]); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, ""); + return path; + } + [Fact] public void SearchHelpersParseSimpleIndexAndRankPrefixMatchesFirst() { diff --git a/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj b/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj index b85324ff19..a37fa3d962 100644 --- a/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj +++ b/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj @@ -63,6 +63,7 @@ +