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 @@
+