diff --git a/OneWare.slnx b/OneWare.slnx
index 55c1bf9a0..de718456f 100644
--- a/OneWare.slnx
+++ b/OneWare.slnx
@@ -134,6 +134,7 @@
+
diff --git a/README.md b/README.md
index 55cd8a663..73d6b2a54 100644
--- a/README.md
+++ b/README.md
@@ -93,6 +93,32 @@ Packages that are already installed are left untouched, and a profile that canno
skipped rather than blocking startup. A package entry without a `version` installs the latest stable version. Settings
that are only read while the application starts take effect on the next launch.
+### Python support
+
+Python source and stub files (`.py` / `.pyi`) use [Pyrefly](https://pyrefly.org/). With automatic binary downloads enabled
+under **Settings > Experimental > Environment**, opening a Python file installs the pinned native language server.
+Alternatively, install **Pyrefly** from Package Manager or set **Settings > Languages > Python > Pyrefly Path**.
+Native packages are provided for Windows, Linux, and macOS on x64 and ARM64; pip and Node are not needed to host the server.
+The former pylsp integration and its executable-path setting are no longer used.
+
+Use **Code > Python > Select Interpreter...** or the **Python: Select Interpreter** command to choose an existing
+interpreter for the active Python file's workspace (or the active project). The picker lists detected environments and
+supports browsing, refreshing, and returning to **Automatic**. Selections are saved in this machine's IDE settings,
+not written into the project.
+
+Automatic resolution uses the workspace's `.venv`, then `venv`, then **Settings > Languages > Python > Default Python
+interpreter**, then Python on PATH. An explicit workspace selection overrides that chain. Missing explicit selections
+are reported and pause Python language analysis rather than silently selecting a different interpreter. With no Python
+installation and no explicit selection, available Pyrefly analysis remains usable, but third-party imports cannot be
+resolved reliably. OneWare does not install Python runtimes or create virtual environments.
+
+Interpreter changes update the workspace's analysis without reopening files. Pyrefly project configuration can take
+precedence over the IDE's interpreter fallback, including an explicit project interpreter or `skip-interpreter-query`.
+Type-checking strictness follows Pyrefly's project configuration and upstream defaults.
+The interpreter selection configures language analysis, not terminal activation or a Python run/debug configuration.
+Basic newline indentation and `#` commenting work independently of the server. Pyrefly does not supply document
+formatting; Ruff/formatter integration is not included.
+
## Nuget
| Package | Download |
diff --git a/src/OneWare.Essentials/LanguageService/LanguageServiceLsp.cs b/src/OneWare.Essentials/LanguageService/LanguageServiceLsp.cs
index e66c91d60..986f74f9a 100644
--- a/src/OneWare.Essentials/LanguageService/LanguageServiceLsp.cs
+++ b/src/OneWare.Essentials/LanguageService/LanguageServiceLsp.cs
@@ -40,6 +40,10 @@ public abstract class LanguageServiceLsp(string name, string? workspace) : Langu
protected string? Arguments { get; set; }
protected string? ExecutablePath { get; set; }
+ protected virtual void ConfigureClientOptions(LanguageClientOptions options)
+ {
+ }
+
public virtual IReadOnlyCollection> GetExtraEnvironmentVariables()
{
return new List>();
@@ -48,22 +52,17 @@ public virtual IReadOnlyCollection> GetExtraEnviron
public override async Task ActivateAsync()
{
if (IsActivated) return;
- IsActivated = true;
-
- if (ExecutablePath == null)
+ if (string.IsNullOrWhiteSpace(ExecutablePath))
{
ContainerLocator.Container.Resolve().Warning(
$"Tried to activate Language Server {Name} without executable!", new NotSupportedException(), false);
return;
}
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
- PlatformHelper.ChmodFile(ExecutablePath);
-
- _cancellation = new CancellationTokenSource();
-
if (ExecutablePath.StartsWith("wss://") || ExecutablePath.StartsWith("ws://"))
{
+ IsActivated = true;
+ _cancellation = new CancellationTokenSource();
var websocket = new ClientWebSocket();
try
{
@@ -88,6 +87,8 @@ public override async Task ActivateAsync()
return;
}
+ IsActivated = true;
+ _cancellation = new CancellationTokenSource();
var argumentArray = Arguments != null
? Arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.RemoveEmptyEntries)
: Array.Empty();
@@ -105,30 +106,49 @@ public override async Task ActivateAsync()
try
{
- _process = ContainerLocator.Container.Resolve().StartChildProcess(processStartInfo);
- var reader = new StreamReader(_process.StandardError);
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ PlatformHelper.ChmodFile(PlatformHelper.GetFullPath(ExecutablePath) ?? ExecutablePath);
+
+ var process = ContainerLocator.Container.Resolve().StartChildProcess(processStartInfo);
+ _process = process;
+ var cancellation = _cancellation;
+ var reader = new StreamReader(process.StandardError);
_ = Task.Run(() =>
{
- while (_process.HasStandardError && !reader.EndOfStream && !_cancellation.IsCancellationRequested)
+ while (process.HasStandardError && !reader.EndOfStream && !cancellation.IsCancellationRequested)
Console.WriteLine("ERR:" + reader.ReadToEnd());
- }, _cancellation.Token);
+ }, cancellation.Token);
- await InitAsync(_process.StandardOutput, _process.StandardInput);
+ await InitAsync(process.StandardOutput, process.StandardInput);
+ if (!IsLanguageServiceReady)
+ {
+ if (ReferenceEquals(_process, process)) await CleanupServerAsync();
+ return;
+ }
- await _process.WaitForExitAsync();
+ await process.WaitForExitAsync();
- await DeactivateAsync();
+ if (ReferenceEquals(_process, process)) await CleanupServerAsync();
}
catch (Exception e)
{
ContainerLocator.Container.Resolve()?.Error(e.Message, e);
- IsActivated = false;
+ await CleanupServerAsync();
}
}
- public override async Task DeactivateAsync()
+ public override Task DeactivateAsync() => CleanupServerAsync();
+
+ private async Task CleanupServerAsync()
{
IsActivated = false;
+ IsLanguageServiceReady = false;
+ var client = Client;
+ Client = null;
+ var process = _process;
+ _process = null;
+ var cancellation = _cancellation;
+ _cancellation = null;
lock (_pullDiagnosticsRequests)
{
@@ -139,14 +159,12 @@ public override async Task DeactivateAsync()
await Dispatcher.UIThread.InvokeAsync(async () =>
{
- if (Client == null) return;
+ if (client == null) return;
try
{
- Client.SendExit();
+ client.SendExit();
await Task.Delay(200);
- Client = null;
- IsLanguageServiceReady = false;
}
catch (Exception e)
{
@@ -156,13 +174,13 @@ await Dispatcher.UIThread.InvokeAsync(async () =>
ContainerLocator.Container.Resolve()?.Clear(Name);
});
await base.DeactivateAsync();
- _cancellation?.Cancel();
- _process?.Kill();
+ cancellation?.Cancel();
+ process?.Kill();
}
private async Task InitAsync(Stream input, Stream output, Action? customOptions = null)
{
- Client = LanguageClient.PreInit(options =>
+ var client = LanguageClient.PreInit(options =>
{
options.WithClientInfo(new ClientInfo { Name = "OneWare.Core" });
options.WithInput(input).WithOutput(output);
@@ -335,16 +353,22 @@ private async Task InitAsync(Stream input, Stream output, Action()?.Log("Preinit finished " + Name);
try
{
- await Client.Initialize(cancelToken).ConfigureAwait(false);
+ await client.Initialize(cancelToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancelToken.IsCancellationRequested)
+ {
+ return;
}
catch (Exception e)
{
@@ -353,6 +377,7 @@ private async Task InitAsync(Stream input, Stream output, Action()?.Log("init finished " + Name);
IsLanguageServiceReady = true;
diff --git a/src/OneWare.Essentials/LanguageService/LanguageServiceLspAutoDownload.cs b/src/OneWare.Essentials/LanguageService/LanguageServiceLspAutoDownload.cs
index 0f632a064..e909df1e2 100644
--- a/src/OneWare.Essentials/LanguageService/LanguageServiceLspAutoDownload.cs
+++ b/src/OneWare.Essentials/LanguageService/LanguageServiceLspAutoDownload.cs
@@ -1,4 +1,7 @@
-using OneWare.Essentials.PackageManager;
+using Microsoft.Extensions.Logging;
+using OneWare.Essentials.Helpers;
+using OneWare.Essentials.PackageManager;
+using OneWare.Essentials.PackageManager.Compatibility;
using OneWare.Essentials.Services;
namespace OneWare.Essentials.LanguageService;
@@ -7,7 +10,15 @@ public abstract class LanguageServiceLspAutoDownload : LanguageServiceLsp
{
private readonly Package _package;
private readonly IPackageService _packageService;
+ private readonly object _lifecycleGate = new();
+ private Task? _activationTask;
+ private bool _activating;
+ private bool _restarting;
+ private bool _pendingPathActivation;
private bool _enableAutoDownload;
+ private bool _activationRequested;
+ private int _activationGeneration;
+ private DateTime _lastInstallAttempt = DateTime.MinValue;
protected LanguageServiceLspAutoDownload(IObservable executablePath, Package package, string name,
string? workspace, IPackageService packageService, IObservable enableAutoDownload,
@@ -17,21 +28,132 @@ protected LanguageServiceLspAutoDownload(IObservable executablePath, Pac
_package = package;
_packageService = packageService;
- // Set Arguments before subscribing so the value is available when
- // ActivateAsync() fires synchronously on the first observable emission.
Arguments = arguments;
enableAutoDownload.Subscribe(x => { _enableAutoDownload = x; });
executablePath.Subscribe(x =>
{
- ExecutablePath = x;
- if (File.Exists(ExecutablePath)) _ = ActivateAsync();
+ lock (_lifecycleGate)
+ {
+ if (ExecutablePath == x) return;
+ ExecutablePath = x;
+ // LanguageManager activates after construction, not during initial subscription.
+ if (!_activationRequested || !File.Exists(ExecutablePath)) return;
+ _pendingPathActivation = true;
+ if (_restarting) return;
+ if (IsActivated)
+ _ = RestartAsync();
+ else if (!_activating)
+ _ = ActivateAsync();
+ }
});
}
- public override async Task ActivateAsync()
+ public override Task ActivateAsync()
{
- if (!File.Exists(ExecutablePath) && _enableAutoDownload) await _packageService.InstallAsync(_package);
- await base.ActivateAsync();
+ lock (_lifecycleGate)
+ {
+ if (_restarting || _activating) return Task.CompletedTask;
+ _activationRequested = true;
+ _activating = true;
+ return _activationTask = ActivateCoreAsync(_activationGeneration);
+ }
+ }
+
+ private async Task ActivateCoreAsync(int generation)
+ {
+ try
+ {
+ if (IsActivated) return;
+ if (!PlatformHelper.Exists(ExecutablePath ?? "") && _enableAutoDownload)
+ {
+ // Opening more documents must not repeatedly retry a failed/offline download.
+ if (DateTime.UtcNow - _lastInstallAttempt < TimeSpan.FromSeconds(30)) return;
+ _lastInstallAttempt = DateTime.UtcNow;
+ var result = await _packageService.InstallAsync(_package);
+ if (result.Status is not (PackageInstallResultReason.Installed or PackageInstallResultReason.AlreadyInstalled))
+ {
+ ContainerLocator.Container?.Resolve()
+ .Warning($"Could not install {Name}: {result.Status}. Install it from Package Manager or set its executable path.");
+ return;
+ }
+ }
+
+ Task activation;
+ lock (_lifecycleGate)
+ {
+ if (!_activationRequested || generation != _activationGeneration) return;
+ _pendingPathActivation = false;
+ activation = ActivateServerAsync();
+ }
+ await activation;
+ }
+ finally
+ {
+ lock (_lifecycleGate)
+ {
+ _activating = false;
+ // An installer can publish the replacement while the killed server is still cleaning up.
+ if (_pendingPathActivation && _activationRequested && !_restarting &&
+ generation == _activationGeneration && File.Exists(ExecutablePath))
+ {
+ _pendingPathActivation = false;
+ _ = ActivateAsync();
+ }
+ }
+ }
+ }
+
+ protected virtual Task ActivateServerAsync() => base.ActivateAsync();
+
+ public override Task DeactivateAsync()
+ {
+ lock (_lifecycleGate)
+ {
+ _activationRequested = false;
+ _pendingPathActivation = false;
+ _activationGeneration++;
+ return DeactivateServerAsync();
+ }
+ }
+
+ protected virtual Task DeactivateServerAsync() => base.DeactivateAsync();
+
+ public override async Task RestartAsync()
+ {
+ Task? outgoing;
+ Task deactivation;
+ int generation;
+ lock (_lifecycleGate)
+ {
+ if (_restarting) return;
+ _restarting = true;
+ outgoing = _activationTask;
+ deactivation = DeactivateAsync();
+ generation = _activationGeneration;
+ }
+
+ var stopped = false;
+ Task activation = Task.CompletedTask;
+ try
+ {
+ await deactivation;
+ // Wait for this particular outgoing server/install, never a replacement's lifetime.
+ if (outgoing != null) await outgoing;
+ stopped = true;
+ }
+ finally
+ {
+ lock (_lifecycleGate)
+ {
+ _restarting = false;
+ if (stopped && generation == _activationGeneration)
+ {
+ _lastInstallAttempt = DateTime.MinValue;
+ activation = ActivateAsync();
+ }
+ }
+ }
+ await activation;
}
}
diff --git a/src/OneWare.Python/AssemblyInfo.cs b/src/OneWare.Python/AssemblyInfo.cs
new file mode 100644
index 000000000..eb31843f5
--- /dev/null
+++ b/src/OneWare.Python/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("OneWare.Python.UnitTests")]
diff --git a/src/OneWare.Python/LanguageServicePython.cs b/src/OneWare.Python/LanguageServicePython.cs
index 0fe80c09e..b48a4f164 100644
--- a/src/OneWare.Python/LanguageServicePython.cs
+++ b/src/OneWare.Python/LanguageServicePython.cs
@@ -1,16 +1,125 @@
-using OneWare.Essentials.LanguageService;
+using Avalonia.Controls.Notifications;
+using Avalonia.Threading;
+using Microsoft.Extensions.Logging;
+using OmniSharp.Extensions.LanguageServer.Client;
+using OmniSharp.Extensions.LanguageServer.Protocol.Client;
+using OmniSharp.Extensions.LanguageServer.Protocol.Workspace;
+using OneWare.Essentials.LanguageService;
using OneWare.Essentials.Services;
using OneWare.Essentials.ViewModels;
namespace OneWare.Python;
-public class LanguageServicePython : LanguageServiceLsp
+public class LanguageServicePython : LanguageServiceLspAutoDownload
{
- public LanguageServicePython(ISettingsService settingsService)
- : base(PythonModule.LspName, null)
+ private readonly PythonInterpreterService _interpreters;
+ private readonly IWindowService _windows;
+ private readonly ILogger _logger;
+ private PythonInterpreterResolution _interpreter;
+ private string? _configuredInterpreter;
+ private string? _reportedProblem;
+ private bool _launchRequested;
+
+ public LanguageServicePython(string workspace, ISettingsService settingsService,
+ IPackageService packageService, PythonInterpreterService interpreters, IWindowService windows, ILogger logger)
+ : base(settingsService.GetSettingObservable(PythonModule.LspPathSetting),
+ PythonModule.PyreflyPackage, PythonModule.LspName, workspace, packageService,
+ settingsService.GetSettingObservable("Experimental_AutoDownloadBinaries"), arguments: "lsp")
+ {
+ _interpreters = interpreters;
+ _windows = windows;
+ _logger = logger;
+ _interpreter = interpreters.GetInterpreter(workspace);
+ interpreters.InterpreterChanged += OnInterpreterChanged;
+ LanguageServiceActivated += (_, _) => UpdateConfiguration();
+ }
+
+ public override Task ActivateAsync()
+ {
+ _launchRequested = true;
+ return base.ActivateAsync();
+ }
+
+ public override Task DeactivateAsync()
+ {
+ _launchRequested = false;
+ return base.DeactivateAsync();
+ }
+
+ protected override Task ActivateServerAsync()
+ {
+ _interpreter = _interpreters.GetInterpreter(Workspace!);
+ ReportInterpreterProblem();
+ return _interpreter.Status == PythonInterpreterStatus.InvalidExplicitSelection
+ ? Task.CompletedTask
+ : base.ActivateServerAsync();
+ }
+
+ protected override void ConfigureClientOptions(LanguageClientOptions options)
+ {
+ _configuredInterpreter = _interpreter.IsResolved ? _interpreter.ExecutablePath : null;
+ options.WithInitializationOptions(PythonLanguageServerConfiguration.Create(_configuredInterpreter));
+ // Each Python server owns one workspace, including its default/unscoped configuration.
+ options.OnConfiguration(request => Task.FromResult(PythonLanguageServerConfiguration.Respond(request,
+ PythonLanguageServerConfiguration.Create(_interpreter.IsResolved ? _interpreter.ExecutablePath : null))));
+ }
+
+ private void OnInterpreterChanged(object? sender, PythonInterpreterChangedEventArgs args)
+ {
+ var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ if (!string.Equals(args.Workspace, PythonInterpreterService.NormalizeWorkspace(Workspace!), comparison)) return;
+ Dispatcher.UIThread.Post(() =>
+ {
+ _interpreter = args.Resolution;
+ UpdateConfiguration();
+ });
+ }
+
+ private void UpdateConfiguration()
+ {
+ if (!_launchRequested) return;
+ ReportInterpreterProblem();
+ if (_interpreter.Status == PythonInterpreterStatus.InvalidExplicitSelection)
+ {
+ // Do not let Pyrefly silently choose another environment for an invalid explicit selection.
+ if (IsActivated) _ = base.DeactivateAsync();
+ return;
+ }
+
+ if (!IsActivated)
+ {
+ _ = RestartAsync();
+ return;
+ }
+ if (!IsLanguageServiceReady) return;
+
+ var interpreter = _interpreter.IsResolved ? _interpreter.ExecutablePath : null;
+ if (_configuredInterpreter == interpreter) return;
+ if (interpreter == null)
+ {
+ // Pyrefly does not clear pythonPath when a configuration response omits it.
+ _ = RestartAsync();
+ return;
+ }
+ _configuredInterpreter = interpreter;
+ ReloadConfiguration();
+ }
+
+ private void ReportInterpreterProblem()
{
- settingsService.GetSettingObservable(PythonModule.LspPathSetting)
- .Subscribe(x => { ExecutablePath = x; });
+ if (_interpreter.IsResolved)
+ {
+ _reportedProblem = null;
+ return;
+ }
+ if (_reportedProblem == _interpreter.Message) return;
+ var message = _interpreter.Message;
+ _reportedProblem = message;
+ _logger.Warning(message, showOutput: false);
+ Dispatcher.UIThread.Post(() => _windows.ShowNotificationWithButton("Python interpreter",
+ message, "Select interpreter",
+ () => { _ = ContainerLocator.Current.Resolve().SelectInterpreterAsync(); },
+ type: NotificationType.Warning));
}
public override ITypeAssistance GetTypeAssistance(IEditor editor)
diff --git a/src/OneWare.Python/PythonIndentationStrategy.cs b/src/OneWare.Python/PythonIndentationStrategy.cs
new file mode 100644
index 000000000..b3205f82d
--- /dev/null
+++ b/src/OneWare.Python/PythonIndentationStrategy.cs
@@ -0,0 +1,99 @@
+using AvaloniaEdit;
+using AvaloniaEdit.Document;
+using AvaloniaEdit.Indentation;
+
+namespace OneWare.Python;
+
+public class PythonIndentationStrategy(TextEditorOptions options) : DefaultIndentationStrategy
+{
+ public override void IndentLine(TextDocument document, DocumentLine line)
+ {
+ if (line.PreviousLine is not { } previousLine)
+ return;
+
+ var previousText = document.GetText(previousLine);
+ var indentation = previousText[..GetIndentationLength(previousText)];
+ if (EndsWithBlockColon(document.GetText(0, previousLine.EndOffset)))
+ indentation += options.IndentationString;
+
+ var text = document.GetText(line);
+ document.Replace(line.Offset, GetIndentationLength(text), indentation);
+ }
+
+ public override void IndentLines(TextDocument document, int beginLine, int endLine)
+ {
+ // Reindenting existing Python code can change its meaning. This strategy only handles newlines.
+ }
+
+ private static int GetIndentationLength(string text)
+ {
+ var length = 0;
+ while (length < text.Length && text[length] is ' ' or '\t')
+ length++;
+ return length;
+ }
+
+ private static bool EndsWithBlockColon(string text)
+ {
+ var quote = '\0';
+ var tripleQuoted = false;
+ var bracketDepth = 0;
+ var lastCodeCharacter = '\0';
+
+ // Scan preceding lines too so colons inside multiline strings and brackets are ignored.
+ for (var i = 0; i < text.Length; i++)
+ {
+ var character = text[i];
+ if (quote != '\0')
+ {
+ if (character == '\\')
+ {
+ i++;
+ }
+ else if (character == quote &&
+ (!tripleQuoted || i + 2 < text.Length && text[i + 1] == quote && text[i + 2] == quote))
+ {
+ if (tripleQuoted)
+ i += 2;
+ quote = '\0';
+ lastCodeCharacter = character;
+ }
+ else if (!tripleQuoted && character is '\r' or '\n')
+ {
+ quote = '\0';
+ lastCodeCharacter = '\0';
+ }
+
+ continue;
+ }
+
+ if (character == '#')
+ {
+ while (i + 1 < text.Length && text[i + 1] is not ('\r' or '\n'))
+ i++;
+ }
+ else if (character is '\'' or '"')
+ {
+ quote = character;
+ tripleQuoted = i + 2 < text.Length && text[i + 1] == quote && text[i + 2] == quote;
+ if (tripleQuoted)
+ i += 2;
+ lastCodeCharacter = character;
+ }
+ else if (character is '\r' or '\n')
+ {
+ lastCodeCharacter = '\0';
+ }
+ else if (!char.IsWhiteSpace(character))
+ {
+ if (character is '(' or '[' or '{')
+ bracketDepth++;
+ else if (character is ')' or ']' or '}')
+ bracketDepth = Math.Max(0, bracketDepth - 1);
+ lastCodeCharacter = character;
+ }
+ }
+
+ return quote == '\0' && bracketDepth == 0 && lastCodeCharacter == ':';
+ }
+}
diff --git a/src/OneWare.Python/PythonInterpreterPickerService.cs b/src/OneWare.Python/PythonInterpreterPickerService.cs
new file mode 100644
index 000000000..1d319082e
--- /dev/null
+++ b/src/OneWare.Python/PythonInterpreterPickerService.cs
@@ -0,0 +1,161 @@
+using Avalonia.Platform.Storage;
+using CommunityToolkit.Mvvm.Input;
+using OneWare.Essentials.Commands;
+using OneWare.Essentials.Enums;
+using OneWare.Essentials.Helpers;
+using OneWare.Essentials.Models;
+using OneWare.Essentials.Services;
+
+namespace OneWare.Python;
+
+public sealed class PythonInterpreterPickerService(
+ PythonInterpreterService interpreters,
+ IWindowService windows,
+ IMainDockService dock,
+ IProjectExplorerService projects,
+ IApplicationCommandService commands,
+ ISettingsService settings,
+ IPaths paths)
+{
+ private const string Automatic = "Automatic (workspace .venv/venv, global default, then PATH)";
+ private const string Browse = "Browse...";
+ private const string Refresh = "Refresh";
+ private bool _initialized;
+ private bool _isOpen;
+
+ public void Initialize()
+ {
+ if (_initialized) return;
+ _initialized = true;
+ interpreters.Initialize();
+ var command = new AsyncRelayCommand(SelectInterpreterAsync);
+ commands.RegisterCommand(new CommandApplicationCommand("Python: Select Interpreter", command)
+ {
+ Detail = "Select the Python interpreter for the active workspace"
+ });
+ windows.RegisterMenuItem("MainWindow_MainMenu/Code",
+ new MenuItemModel("Python")
+ {
+ Header = "Python",
+ Items =
+ [
+ new MenuItemModel("SelectInterpreter")
+ {
+ Header = "Select Interpreter...",
+ Command = command
+ }
+ ]
+ });
+ }
+
+ public string? GetActiveWorkspace()
+ {
+ var document = dock.CurrentDocument;
+ if (document != null && !string.IsNullOrWhiteSpace(document.FullPath) &&
+ (string.Equals(document.Extension, ".py", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(document.Extension, ".pyi", StringComparison.OrdinalIgnoreCase)))
+ return projects.GetRootFromFile(document.FullPath)?.RootFolderPath ??
+ Path.GetDirectoryName(document.FullPath);
+ return projects.ActiveProject?.RootFolderPath;
+ }
+
+ public async Task SelectInterpreterAsync()
+ {
+ if (_isOpen) return;
+ _isOpen = true;
+ try
+ {
+ var workspace = GetActiveWorkspace();
+ var owner = dock.CurrentDocument is { } document
+ ? dock.GetWindowOwner(document)
+ : dock.GetWindowOwner(projects);
+ if (string.IsNullOrWhiteSpace(workspace))
+ {
+ await windows.ShowMessageAsync("Python interpreter",
+ "Open a Python file or select an active project to choose a workspace interpreter. " +
+ "The global default is available in Settings > Languages > Python.",
+ MessageBoxIcon.Info, owner);
+ return;
+ }
+ workspace = PythonInterpreterService.NormalizeWorkspace(workspace);
+ while (true)
+ {
+ interpreters.Refresh(workspace);
+ var current = interpreters.GetInterpreter(workspace);
+ var candidates = (await interpreters.DiscoverCandidatesAsync(workspace)).ToList();
+ PythonInterpreterCandidate? invalidSelection = null;
+ if (current.Source == PythonInterpreterSource.WorkspaceOverride && !current.IsResolved)
+ {
+ invalidSelection = new PythonInterpreterCandidate(current.ExecutablePath ?? "",
+ "Current selection (missing or invalid)");
+ candidates.Insert(0, invalidSelection);
+ }
+ object[] options = [Automatic, .. candidates.Cast