From eb7156bbbb937276919dcc5c840676cef0d305ae Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Fri, 11 Sep 2026 10:15:09 +0200 Subject: [PATCH] Add managed Pyrefly support and workspace Python interpreters Replace pylsp with native Pyrefly packages, add persistent interpreter selection, and handle language server activation and package-update lifecycle safely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- OneWare.slnx | 1 + README.md | 26 ++ .../LanguageService/LanguageServiceLsp.cs | 77 ++-- .../LanguageServiceLspAutoDownload.cs | 138 ++++++- src/OneWare.Python/AssemblyInfo.cs | 3 + src/OneWare.Python/LanguageServicePython.cs | 121 +++++- .../PythonIndentationStrategy.cs | 99 +++++ .../PythonInterpreterPickerService.cs | 161 ++++++++ .../PythonInterpreterResolution.cs | 38 ++ .../PythonInterpreterService.cs | 300 ++++++++++++++ .../PythonLanguageServerConfiguration.cs | 25 ++ src/OneWare.Python/PythonModule.cs | 65 ++- src/OneWare.Python/TypeAssistancePython.cs | 4 +- .../LanguageServiceLspAutoDownloadTests.cs | 291 +++++++++++++ .../OneWare.Python.UnitTests.csproj | 20 + .../PythonIndentationTests.cs | 106 +++++ .../PythonInterpreterPickerTests.cs | 242 +++++++++++ .../PythonInterpreterServiceTests.cs | 382 ++++++++++++++++++ .../PythonLanguageServerConfigurationTests.cs | 68 ++++ 19 files changed, 2119 insertions(+), 48 deletions(-) create mode 100644 src/OneWare.Python/AssemblyInfo.cs create mode 100644 src/OneWare.Python/PythonIndentationStrategy.cs create mode 100644 src/OneWare.Python/PythonInterpreterPickerService.cs create mode 100644 src/OneWare.Python/PythonInterpreterResolution.cs create mode 100644 src/OneWare.Python/PythonInterpreterService.cs create mode 100644 src/OneWare.Python/PythonLanguageServerConfiguration.cs create mode 100644 tests/OneWare.Essentials.UnitTests/LanguageServiceLspAutoDownloadTests.cs create mode 100644 tests/OneWare.Python.UnitTests/OneWare.Python.UnitTests.csproj create mode 100644 tests/OneWare.Python.UnitTests/PythonIndentationTests.cs create mode 100644 tests/OneWare.Python.UnitTests/PythonInterpreterPickerTests.cs create mode 100644 tests/OneWare.Python.UnitTests/PythonInterpreterServiceTests.cs create mode 100644 tests/OneWare.Python.UnitTests/PythonLanguageServerConfigurationTests.cs 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(), Browse, Refresh]; + var explicitPath = interpreters.GetWorkspaceInterpreter(workspace); + var pathComparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + object selected = candidates.FirstOrDefault(x => + string.Equals(x.ExecutablePath, explicitPath, pathComparison)) as object ?? Automatic; + var choice = await windows.ShowInputSelectAsync("Python: Select Interpreter", + $"Workspace: {workspace}\nCurrent: {current.Message}\n\n" + + "Automatic uses workspace .venv/venv, then the global default, then PATH. " + + "Python must be installed externally. Pyrefly project configuration may override " + + "this editor fallback. Cancel leaves the selection unchanged.", + current.IsResolved ? MessageBoxIcon.Info : MessageBoxIcon.Warning, + options, selected, owner); + if (choice == null) return; + if (Equals(choice, invalidSelection)) + { + await windows.ShowMessageAsync("Python interpreter", current.Message, MessageBoxIcon.Warning, owner); + return; + } + if (Equals(choice, Refresh)) continue; + if (Equals(choice, Automatic)) + { + interpreters.SetWorkspaceInterpreter(workspace, null); + settings.Save(paths.SettingsPath); + return; + } + string? path; + if (Equals(choice, Browse)) + { + if (owner == null) + { + await windows.ShowMessageAsync("Python interpreter", + "File browsing is not available in this window. " + + "Set the global default in Settings > Languages > Python.", + MessageBoxIcon.Info); + continue; + } + path = await StorageProviderHelper.SelectFileAsync(owner, "Select Python executable", + workspace, FilePickerFileTypes.All); + if (path == null) continue; + } + else if (choice is PythonInterpreterCandidate candidate) + path = candidate.ExecutablePath; + else + return; + + interpreters.SetWorkspaceInterpreter(workspace, path); + settings.Save(paths.SettingsPath); + var result = interpreters.GetInterpreter(workspace); + if (!result.IsResolved) + await windows.ShowMessageAsync("Python interpreter", result.Message, MessageBoxIcon.Warning, owner); + return; + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException + or NotSupportedException) + { + await windows.ShowMessageAsync("Python interpreter", + "The interpreter selection could not be completed. Check that the workspace and executable " + + "are accessible, or set the global default in Settings > Languages > Python.", + MessageBoxIcon.Warning); + } + finally + { + _isOpen = false; + } + } +} diff --git a/src/OneWare.Python/PythonInterpreterResolution.cs b/src/OneWare.Python/PythonInterpreterResolution.cs new file mode 100644 index 000000000..cbc0e6da0 --- /dev/null +++ b/src/OneWare.Python/PythonInterpreterResolution.cs @@ -0,0 +1,38 @@ +namespace OneWare.Python; + +public enum PythonInterpreterSource +{ + None, + WorkspaceOverride, + WorkspaceEnvironment, + GlobalDefault, + Path +} + +public enum PythonInterpreterStatus +{ + Resolved, + Missing, + InvalidExplicitSelection +} + +public sealed record PythonInterpreterResolution( + string Workspace, + string? ExecutablePath, + PythonInterpreterSource Source, + PythonInterpreterStatus Status, + string Message) +{ + public bool IsResolved => Status == PythonInterpreterStatus.Resolved; +} + +public sealed class PythonInterpreterChangedEventArgs(PythonInterpreterResolution resolution) : EventArgs +{ + public string Workspace => Resolution.Workspace; + public PythonInterpreterResolution Resolution { get; } = resolution; +} + +public sealed record PythonInterpreterCandidate(string ExecutablePath, string Label) +{ + public override string ToString() => $"{Label} - {ExecutablePath}"; +} diff --git a/src/OneWare.Python/PythonInterpreterService.cs b/src/OneWare.Python/PythonInterpreterService.cs new file mode 100644 index 000000000..c4de49ae2 --- /dev/null +++ b/src/OneWare.Python/PythonInterpreterService.cs @@ -0,0 +1,300 @@ +using System.Reactive.Disposables; +using System.Reactive.Linq; +using Avalonia.Platform.Storage; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using OneWare.Essentials.Models; +using OneWare.Essentials.Services; + +namespace OneWare.Python; + +public sealed class PythonInterpreterService : IDisposable +{ + public const string GlobalInterpreterSettingKey = "PythonModule_DefaultInterpreter"; + public const string WorkspaceInterpretersSettingKey = "PythonModule_WorkspaceInterpreters"; + private const int MaximumPathDirectories = 128; + private const int MaximumCandidates = 32; + private readonly ISettingsService _settings; + private readonly bool _isWindows; + private readonly Func _isExecutable; + private readonly Func _getEnvironmentVariable; + private readonly StringComparer _pathComparer; + private readonly ILogger _logger; + private readonly HashSet _reportedInvalidEntries = []; + private readonly Dictionary _tracked; + private readonly CompositeDisposable _subscriptions = new(); + private readonly object _gate = new(); + private bool _initialized; + + public PythonInterpreterService(ISettingsService settings, ILogger? logger = null) + : this(settings, OperatingSystem.IsWindows(), IsExecutableFile, Environment.GetEnvironmentVariable, logger) + { + } + + internal PythonInterpreterService(ISettingsService settings, bool isWindows, + Func isExecutable, Func getEnvironmentVariable, + ILogger? logger = null) + { + _settings = settings; + _isWindows = isWindows; + _isExecutable = isExecutable; + _getEnvironmentVariable = getEnvironmentVariable; + _logger = logger ?? NullLogger.Instance; + _pathComparer = isWindows ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + _tracked = new Dictionary(_pathComparer); + } + + public event EventHandler? InterpreterChanged; + + public void Initialize() + { + lock (_gate) + { + if (_initialized) return; + if (!_settings.HasSetting(GlobalInterpreterSettingKey)) + _settings.RegisterSetting("Languages", "Python", GlobalInterpreterSettingKey, + new FilePathSetting("Default Python interpreter", "", "Automatic", null, + path => string.IsNullOrWhiteSpace(path) || IsValidExplicitPath(path), + FilePickerFileTypes.All) + { + HoverDescription = "Absolute path to a Python executable. Empty uses Python on PATH. " + + "Workspace selections and .venv/venv take precedence.", + MarkdownDocumentation = "Python must be installed externally. This does not install a runtime " + + "or create an environment. Pyrefly project configuration may override " + + "the editor's interpreter fallback." + }); + if (!_settings.HasSetting(WorkspaceInterpretersSettingKey)) + _settings.Register(WorkspaceInterpretersSettingKey, new Dictionary()); + _initialized = true; + _subscriptions.Add(_settings.GetSettingObservable(GlobalInterpreterSettingKey) + .Skip(1).Subscribe(_ => RefreshTracked())); + _subscriptions.Add(_settings.GetSettingObservable>(WorkspaceInterpretersSettingKey) + .Skip(1).Subscribe(_ => RefreshTracked())); + } + } + + public PythonInterpreterResolution GetInterpreter(string workspace) + { + Initialize(); + var normalized = NormalizeWorkspace(workspace); + lock (_gate) + { + var resolution = Resolve(normalized); + _tracked[normalized] = resolution; + return resolution; + } + } + + public string? GetWorkspaceInterpreter(string workspace) + { + Initialize(); + lock (_gate) + return GetWorkspaceSelections().GetValueOrDefault(NormalizeWorkspace(workspace)); + } + + public void SetWorkspaceInterpreter(string workspace, string? executablePath) + { + Initialize(); + var normalized = NormalizeWorkspace(workspace); + lock (_gate) + { + var selections = GetWorkspaceSelections(); + if (string.IsNullOrWhiteSpace(executablePath)) + selections.Remove(normalized); + else + selections[normalized] = NormalizeExplicitPath(executablePath); + // Replace the map, rather than mutating the setting's value, to notify and persist normally. + _settings.SetSettingValue(WorkspaceInterpretersSettingKey, selections); + } + } + + public void Refresh(string workspace) + { + Initialize(); + RefreshTracked(NormalizeWorkspace(workspace)); + } + + public Task> DiscoverCandidatesAsync(string workspace, + CancellationToken cancellationToken = default) + { + Initialize(); + var normalized = NormalizeWorkspace(workspace); + string? selected; + string global; + lock (_gate) + { + selected = GetWorkspaceSelections().GetValueOrDefault(normalized); + global = _settings.GetSettingValue(GlobalInterpreterSettingKey); + } + return Task.Run>(() => + { + var candidates = new List(); + var seen = new HashSet(_pathComparer); + void Add(string? path, string label) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(path) || candidates.Count >= MaximumCandidates) return; + path = NormalizeExplicitPath(path); + if (Path.IsPathFullyQualified(path) && _isExecutable(path) && seen.Add(path)) + candidates.Add(new PythonInterpreterCandidate(path, label)); + } + + Add(selected, "Workspace selection"); + Add(GetEnvironmentInterpreterPath(Path.Combine(normalized, ".venv"), _isWindows), "Workspace .venv"); + Add(GetEnvironmentInterpreterPath(Path.Combine(normalized, "venv"), _isWindows), "Workspace venv"); + foreach (var variable in new[] { "VIRTUAL_ENV", "CONDA_PREFIX" }) + { + var environment = _getEnvironmentVariable(variable); + if (!string.IsNullOrWhiteSpace(environment) && Path.IsPathFullyQualified(environment)) + Add(variable == "CONDA_PREFIX" && _isWindows + ? Path.Combine(environment, "python.exe") + : GetEnvironmentInterpreterPath(environment, _isWindows), variable); + } + Add(global, "Global default"); + foreach (var path in GetPathExecutables()) + Add(path, "PATH"); + return candidates; + }, cancellationToken); + } + + public static string GetEnvironmentInterpreterPath(string environment, bool isWindows) => + isWindows ? Path.Combine(environment, "Scripts", "python.exe") : Path.Combine(environment, "bin", "python"); + + public static string NormalizeWorkspace(string workspace) => + Path.TrimEndingDirectorySeparator(Path.GetFullPath(workspace)); + + private Dictionary GetWorkspaceSelections() + { + var result = new Dictionary(_pathComparer); + foreach (var entry in _settings.GetSettingValue>(WorkspaceInterpretersSettingKey)) + { + try + { + if (!Path.IsPathFullyQualified(entry.Key)) + { + ReportInvalidEntry(entry.Key); + continue; + } + if (string.IsNullOrWhiteSpace(entry.Value)) + ReportInvalidEntry(entry.Key); + result[NormalizeWorkspace(entry.Key)] = entry.Value ?? ""; + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException) + { + ReportInvalidEntry(entry.Key); + } + } + return result; + } + + private PythonInterpreterResolution Resolve(string workspace) + { + if (GetWorkspaceSelections().TryGetValue(workspace, out var selected)) + return ResolveExplicit(workspace, selected, PythonInterpreterSource.WorkspaceOverride); + foreach (var name in new[] { ".venv", "venv" }) + { + var path = GetEnvironmentInterpreterPath(Path.Combine(workspace, name), _isWindows); + if (_isExecutable(path)) + return Resolved(workspace, path, PythonInterpreterSource.WorkspaceEnvironment); + } + var global = _settings.GetSettingValue(GlobalInterpreterSettingKey); + if (!string.IsNullOrWhiteSpace(global)) + return ResolveExplicit(workspace, global, PythonInterpreterSource.GlobalDefault); + var fallback = GetPathExecutables().FirstOrDefault(_isExecutable); + return fallback != null + ? Resolved(workspace, fallback, PythonInterpreterSource.Path) + : new PythonInterpreterResolution(workspace, null, PythonInterpreterSource.None, + PythonInterpreterStatus.Missing, + "No Python interpreter found. Install Python externally or select an existing executable. " + + "Available Pyrefly analysis remains usable."); + } + + private PythonInterpreterResolution ResolveExplicit(string workspace, string path, PythonInterpreterSource source) + { + path = NormalizeExplicitPath(path); + return IsValidExplicitPath(path) + ? Resolved(workspace, path, source) + : new PythonInterpreterResolution(workspace, path, source, + PythonInterpreterStatus.InvalidExplicitSelection, + $"The selected Python interpreter is missing or not executable: {path}. " + + "Select an existing executable or choose Automatic; no fallback was used."); + } + + private static PythonInterpreterResolution Resolved(string workspace, string path, PythonInterpreterSource source) => + new(workspace, path, source, PythonInterpreterStatus.Resolved, $"{source}: {path}"); + + private void ReportInvalidEntry(string workspace) + { + if (_reportedInvalidEntries.Add(workspace)) + _logger.LogWarning("Malformed persisted Python interpreter selection for workspace {Workspace}. " + + "Choose an existing interpreter or Automatic to repair the selection.", workspace); + } + + private bool IsValidExplicitPath(string path) => + Path.IsPathFullyQualified(path) && _isExecutable(path); + + private static string NormalizeExplicitPath(string path) + { + try + { + // Do not resolve symbolic links: a venv's executable path identifies its environment. + return Path.IsPathFullyQualified(path) ? Path.GetFullPath(path) : path; + } + catch (ArgumentException) { return path; } + catch (NotSupportedException) { return path; } + catch (PathTooLongException) { return path; } + } + + private IEnumerable GetPathExecutables() + { + var names = _isWindows ? new[] { "python.exe", "python3.exe" } : new[] { "python3", "python" }; + var directories = (_getEnvironmentVariable("PATH") ?? "") + .Split(_isWindows ? ';' : ':') + .Take(MaximumPathDirectories).Select(x => x.Trim().Trim('"')) + .Where(Path.IsPathFullyQualified).Distinct(_pathComparer).ToArray(); + foreach (var name in names) + foreach (var directory in directories) + { + var path = NormalizeExplicitPath(Path.Combine(directory, name)); + yield return path; + } + } + + private void RefreshTracked(string? workspace = null) + { + List changed = []; + lock (_gate) + { + foreach (var key in _tracked.Keys.ToArray()) + { + if (workspace != null && !_pathComparer.Equals(key, workspace)) continue; + var previous = _tracked[key]; + var current = Resolve(key); + _tracked[key] = current; + if (previous.Status != current.Status || previous.Source != current.Source || + !_pathComparer.Equals(previous.ExecutablePath, current.ExecutablePath)) + changed.Add(current); + } + } + foreach (var resolution in changed) + InterpreterChanged?.Invoke(this, new PythonInterpreterChangedEventArgs(resolution)); + } + + private static bool IsExecutableFile(string path) + { + try + { + if (!File.Exists(path)) return false; + if (OperatingSystem.IsWindows()) + return string.Equals(Path.GetExtension(path), ".exe", StringComparison.OrdinalIgnoreCase); + return (File.GetUnixFileMode(path) & + (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0; + } + catch (IOException) { return false; } + catch (UnauthorizedAccessException) { return false; } + catch (ArgumentException) { return false; } + catch (NotSupportedException) { return false; } + } + + public void Dispose() => _subscriptions.Dispose(); +} diff --git a/src/OneWare.Python/PythonLanguageServerConfiguration.cs b/src/OneWare.Python/PythonLanguageServerConfiguration.cs new file mode 100644 index 000000000..b80ee015c --- /dev/null +++ b/src/OneWare.Python/PythonLanguageServerConfiguration.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace OneWare.Python; + +internal static class PythonLanguageServerConfiguration +{ + public static JObject Create(string? interpreter) + { + var settings = new JObject + { + ["pyrefly"] = new JObject { ["diagnosticMode"] = "openFilesOnly" } + }; + if (!string.IsNullOrWhiteSpace(interpreter)) settings["pythonPath"] = interpreter; + return settings; + } + + public static Container Respond(ConfigurationParams request, JObject settings) => + new(request.Items.Select(item => item.Section switch + { + "python" => (JToken)settings.DeepClone(), + null or "" => new JObject { ["python"] = settings.DeepClone() }, + _ => JValue.CreateNull() + })); +} diff --git a/src/OneWare.Python/PythonModule.cs b/src/OneWare.Python/PythonModule.cs index 454703a0a..b466a4bda 100644 --- a/src/OneWare.Python/PythonModule.cs +++ b/src/OneWare.Python/PythonModule.cs @@ -1,32 +1,85 @@ using Microsoft.Extensions.DependencyInjection; using OneWare.Essentials.Helpers; using OneWare.Essentials.Models; +using OneWare.Essentials.PackageManager; using OneWare.Essentials.Services; namespace OneWare.Python; public class PythonModule : OneWareModuleBase { - public const string LspName = "pylsp"; - public const string LspPathSetting = "PythonModule_PylspPath"; + public const string LspName = "pyrefly"; + public const string LspPathSetting = "PythonModule_PyreflyPath"; + public const string PyreflyVersion = "1.3.0"; + + public static readonly string[] SupportedExtensions = [".py", ".pyi"]; + + public static readonly Package PyreflyPackage = new() + { + Category = "Binaries", + Id = LspName, + Type = "NativeTool", + Name = "Pyrefly", + Description = "Python language support", + License = "MIT", + Links = [new PackageLink { Name = "GitHub", Url = "https://github.com/facebook/pyrefly" }], + Tabs = + [ + new PackageTab + { + Title = "License", + ContentUrl = $"https://raw.githubusercontent.com/facebook/pyrefly/{PyreflyVersion}/LICENSE" + } + ], + Versions = + [ + new PackageVersion + { + Version = PyreflyVersion, + Targets = + [ + CreateTarget("win-x64", "windows-x86_64.zip", "pyrefly.exe"), + CreateTarget("win-arm64", "windows-arm64.zip", "pyrefly.exe"), + CreateTarget("linux-x64", "linux-x86_64-musl.tar.gz", "pyrefly"), + CreateTarget("linux-arm64", "linux-arm64-musl.tar.gz", "pyrefly"), + CreateTarget("osx-x64", "macos-x86_64.tar.gz", "pyrefly"), + CreateTarget("osx-arm64", "macos-arm64.tar.gz", "pyrefly") + ] + } + ] + }; + + private static PackageTarget CreateTarget(string target, string archive, string executable) => new() + { + Target = target, + Url = $"https://github.com/facebook/pyrefly/releases/download/{PyreflyVersion}/pyrefly-{archive}", + AutoSetting = [new PackageAutoSetting { RelativePath = executable, SettingKey = LspPathSetting }] + }; public override void RegisterServices(IServiceCollection services) { + services.AddSingleton(); + services.AddSingleton(); } public override void Initialize(IServiceProvider serviceProvider) { + serviceProvider.Resolve().RegisterPackage(PyreflyPackage); + serviceProvider.Resolve().RegisterSetting("Languages", "Python", LspPathSetting, - new FilePathSetting("Pylsp Path", "", null, + new FilePathSetting("Pyrefly Path", "", null, serviceProvider.Resolve().NativeToolsDirectory, PlatformHelper.ExistsOnPath, PlatformHelper.ExeFile) { - HoverDescription = "Path for Pylsp executable" + HoverDescription = "Path for the Pyrefly executable. Leave empty to use automatic installation." }); serviceProvider.Resolve().RegisterErrorSource(LspName); + serviceProvider.Resolve().Initialize(); + serviceProvider.Resolve().Initialize(); - serviceProvider.Resolve() - .RegisterService(typeof(LanguageServicePython), false, ".py"); + var languageManager = serviceProvider.Resolve(); + languageManager.RegisterLanguageExtensionLink(".pyi", ".py"); + languageManager.RegisterService(typeof(LanguageServicePython), true, SupportedExtensions); } } \ No newline at end of file diff --git a/src/OneWare.Python/TypeAssistancePython.cs b/src/OneWare.Python/TypeAssistancePython.cs index 3107ff1da..fd04f2260 100644 --- a/src/OneWare.Python/TypeAssistancePython.cs +++ b/src/OneWare.Python/TypeAssistancePython.cs @@ -8,8 +8,8 @@ internal class TypeAssistancePython : TypeAssistanceLanguageService public TypeAssistancePython(IEditor editor, LanguageServicePython ls) : base(editor, ls) { CodeBox.TextArea.IndentationStrategy = - IndentationStrategy = new LspIndentationStrategy(CodeBox.Options, ls, editor.FullPath); - LineCommentSequence = "//"; + IndentationStrategy = new PythonIndentationStrategy(CodeBox.Options); + LineCommentSequence = "#"; } public override bool CanAddBreakPoints => false; diff --git a/tests/OneWare.Essentials.UnitTests/LanguageServiceLspAutoDownloadTests.cs b/tests/OneWare.Essentials.UnitTests/LanguageServiceLspAutoDownloadTests.cs new file mode 100644 index 000000000..64f5c26dc --- /dev/null +++ b/tests/OneWare.Essentials.UnitTests/LanguageServiceLspAutoDownloadTests.cs @@ -0,0 +1,291 @@ +using System; +using System.IO; +using System.Reactive.Subjects; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using OneWare.Essentials.LanguageService; +using OneWare.Essentials.PackageManager; +using OneWare.Essentials.PackageManager.Compatibility; +using OneWare.Essentials.Services; +using OneWare.Essentials.ViewModels; +using Xunit; + +namespace OneWare.Essentials.UnitTests; + +[CollectionDefinition("Language server lifecycle", DisableParallelization = true)] +public class LanguageServerLifecycleCollection; + +[Collection("Language server lifecycle")] +public class LanguageServiceLspAutoDownloadTests : IDisposable +{ + private readonly string _executable = Path.GetTempFileName(); + private readonly IServiceProvider? _previousContainer = ContainerLocator.Container; + + public LanguageServiceLspAutoDownloadTests() + { + ContainerLocator.SetContainer(new LoggerProvider()); + } + + [Fact] + public async Task InitialPathDoesNotActivateBeforeDerivedConstruction() + { + using var path = new BehaviorSubject(_executable); + var server = CreateServer(path, out var packages); + + Assert.Equal(0, server.Starts); + Assert.Equal("lsp", server.StartArguments); + await server.ActivateAsync(); + Assert.Equal(1, server.Starts); + Assert.Equal(0, packages.Installs); + } + + [Fact] + public async Task ConcurrentActivationAndInstallAutoSettingLaunchOnlyOnce() + { + using var path = new BehaviorSubject(""); + var server = CreateServer(path, out var packages); + var install = new TaskCompletionSource(); + packages.Install = () => install.Task; + + var first = server.ActivateAsync(); + await server.ActivateAsync(); + path.OnNext(_executable); + install.SetResult(new PackageInstallResult { Status = PackageInstallResultReason.Installed }); + await first; + + Assert.Equal(1, packages.Installs); + Assert.Equal(1, server.Starts); + } + + [Theory] + [InlineData(PackageInstallResultReason.ErrorDownloading)] + [InlineData(PackageInstallResultReason.Incompatible)] + [InlineData(PackageInstallResultReason.NotFound)] + public async Task FailedInstallDoesNotActivateOrRepeatedlyDownloadAndAllowsManualRecovery( + PackageInstallResultReason reason) + { + using var path = new BehaviorSubject(""); + var server = CreateServer(path, out var packages); + packages.Install = () => Task.FromResult(new PackageInstallResult { Status = reason }); + + await server.ActivateAsync(); + await server.ActivateAsync(); + Assert.False(server.IsActivated); + Assert.Equal(0, server.Starts); + Assert.Equal(1, packages.Installs); + + path.OnNext(_executable); + Assert.Equal(1, server.Starts); + } + + [Fact] + public async Task DisabledAutoDownloadNeverInstalls() + { + using var path = new BehaviorSubject(""); + var server = CreateServer(path, out var packages, false); + await server.ActivateAsync(); + Assert.Equal(0, packages.Installs); + } + + [Fact] + public async Task RestartBlocksNewActivationUntilTheOutgoingStopCompletes() + { + using var path = new BehaviorSubject(_executable); + var server = CreateServer(path, out _); + var exited = new TaskCompletionSource(); + var stopped = new TaskCompletionSource(); + var replacementStarted = new TaskCompletionSource(); + server.Launch = () => + { + if (server.Starts == 2) replacementStarted.SetResult(); + return exited.Task; + }; + server.Stop = () => + { + exited.TrySetResult(); + return stopped.Task; + }; + var initial = server.ActivateAsync(); + + var restart = server.RestartAsync(); + await initial; + await server.ActivateAsync(); + Assert.Equal(1, server.Starts); + + exited = new TaskCompletionSource(); + stopped.SetResult(); + await replacementStarted.Task.WaitAsync(TimeSpan.FromSeconds(3)); + Assert.Equal(2, server.Starts); + await server.DeactivateAsync(); + await restart.WaitAsync(TimeSpan.FromSeconds(3)); + Assert.False(server.IsActivated); + } + + [Fact] + public async Task LaterDeactivationCancelsPendingRestart() + { + using var path = new BehaviorSubject(_executable); + var server = CreateServer(path, out _); + var exited = new TaskCompletionSource(); + var stopped = new TaskCompletionSource(); + server.Launch = () => exited.Task; + server.Stop = () => + { + exited.TrySetResult(); + return stopped.Task; + }; + var initial = server.ActivateAsync(); + var restart = server.RestartAsync(); + await initial; + var laterStop = server.DeactivateAsync(); + + stopped.SetResult(); + await laterStop; + await restart.WaitAsync(TimeSpan.FromSeconds(3)); + Assert.Equal(1, server.Starts); + Assert.False(server.IsActivated); + } + + [Fact] + public async Task DeactivationDuringInstallationPreventsLateLaunch() + { + using var path = new BehaviorSubject(""); + var server = CreateServer(path, out var packages); + var install = new TaskCompletionSource(); + packages.Install = () => install.Task; + var activation = server.ActivateAsync(); + + await server.DeactivateAsync(); + path.OnNext(_executable); + install.SetResult(new PackageInstallResult { Status = PackageInstallResultReason.Installed }); + await activation; + Assert.Equal(0, server.Starts); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task PackageReplacementReactivatesBeforeOrAfterOutgoingCleanup(bool publishBeforeCleanup) + { + using var path = new BehaviorSubject(_executable); + var server = CreateServer(path, out _); + var exited = new TaskCompletionSource(); + server.Launch = () => server.Starts == 1 ? exited.Task : Task.CompletedTask; + var initial = server.ActivateAsync(); + server.SimulateNaturalExitCleanup(); + + if (!publishBeforeCleanup) + { + exited.SetResult(); + await initial; + } + path.OnNext(""); + path.OnNext(_executable); + if (publishBeforeCleanup) + { + Assert.Equal(1, server.Starts); + exited.SetResult(); + await initial; + } + + Assert.Equal(2, server.Starts); + } + + [Fact] + public async Task ExplicitStopInvalidatesQueuedPackageReplacement() + { + using var path = new BehaviorSubject(_executable); + var server = CreateServer(path, out _); + var exited = new TaskCompletionSource(); + server.Launch = () => exited.Task; + var initial = server.ActivateAsync(); + server.SimulateNaturalExitCleanup(); + path.OnNext(""); + path.OnNext(_executable); + + await server.DeactivateAsync(); + exited.SetResult(); + await initial; + + Assert.Equal(1, server.Starts); + Assert.False(server.IsActivated); + } + + [Theory] + [InlineData("")] + [InlineData("/a-nonexistent-oneware-language-server/executable")] + public async Task MissingExecutableDoesNotLeaveBaseServiceActivated(string path) + { + var server = new MissingServer(path); + await server.ActivateAsync(); + Assert.False(server.IsActivated); + Assert.False(server.IsLanguageServiceReady); + } + + private static TestServer CreateServer(BehaviorSubject path, out PackageProxy packages, + bool autoDownload = true) + { + var service = DispatchProxy.Create(); + packages = (PackageProxy)service; + return new TestServer(path, service, new BehaviorSubject(autoDownload)); + } + + public void Dispose() + { + File.Delete(_executable); + if (_previousContainer != null) ContainerLocator.SetContainer(_previousContainer); + } + + public class PackageProxy : DispatchProxy + { + public int Installs { get; private set; } + public Func> Install { get; set; } = () => + Task.FromResult(new PackageInstallResult { Status = PackageInstallResultReason.Installed }); + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod?.Name != nameof(IPackageService.InstallAsync)) + throw new NotSupportedException(targetMethod?.Name); + Installs++; + return Install(); + } + } + + private sealed class LoggerProvider : IServiceProvider + { + public object? GetService(Type serviceType) => serviceType == typeof(ILogger) ? NullLogger.Instance : null; + } + + private sealed class TestServer(IObservable path, IPackageService packages, IObservable enabled) + : LanguageServiceLspAutoDownload(path, new Package { Id = "test" }, "test", null, packages, enabled, "lsp") + { + public int Starts { get; private set; } + public string? StartArguments => Arguments; + public Func? Launch { get; set; } + public Func? Stop { get; set; } + public void SimulateNaturalExitCleanup() => IsActivated = false; + + protected override Task ActivateServerAsync() + { + Starts++; + IsActivated = Launch != null; + return Launch?.Invoke() ?? Task.CompletedTask; + } + + protected override Task DeactivateServerAsync() + { + IsActivated = false; + return Stop?.Invoke() ?? Task.CompletedTask; + } + + public override ITypeAssistance GetTypeAssistance(IEditor editor) => throw new NotSupportedException(); + } + + private sealed class MissingServer : LanguageServiceLsp + { + public MissingServer(string path) : base("missing", null) => ExecutablePath = path; + public override ITypeAssistance GetTypeAssistance(IEditor editor) => throw new NotSupportedException(); + } +} diff --git a/tests/OneWare.Python.UnitTests/OneWare.Python.UnitTests.csproj b/tests/OneWare.Python.UnitTests/OneWare.Python.UnitTests.csproj new file mode 100644 index 000000000..60174f3f8 --- /dev/null +++ b/tests/OneWare.Python.UnitTests/OneWare.Python.UnitTests.csproj @@ -0,0 +1,20 @@ + + + + + + net10.0 + Library + False + True + False + True + enable + + + + + + + + diff --git a/tests/OneWare.Python.UnitTests/PythonIndentationTests.cs b/tests/OneWare.Python.UnitTests/PythonIndentationTests.cs new file mode 100644 index 000000000..65d26f248 --- /dev/null +++ b/tests/OneWare.Python.UnitTests/PythonIndentationTests.cs @@ -0,0 +1,106 @@ +using AvaloniaEdit; +using AvaloniaEdit.Document; +using Xunit; + +namespace OneWare.Python.UnitTests; + +public class PythonIndentationTests +{ + [Theory] + [InlineData("value = 1", "")] + [InlineData(" value = 1", " ")] + [InlineData("\tvalue = 1", "\t")] + [InlineData(" ", " ")] + [InlineData("if ready:", " ")] + [InlineData(" for item in items: # next item", " ")] + [InlineData("async def run():", " ")] + [InlineData("class Example:", " ")] + [InlineData("match value:", " ")] + [InlineData(" case 1:", " ")] + [InlineData("if value == '#:':", " ")] + [InlineData("if value == '\\'':", " ")] + [InlineData(" # if ready:", " ")] + [InlineData(" value = 1 # comment:", " ")] + [InlineData(" value = 'text:'", " ")] + [InlineData(" value = \"unfinished:", " ")] + [InlineData(" value = 'escaped\\':", " ")] + [InlineData(" values = {'key':", " ")] + [InlineData("values = {\n 'key':", " ")] + [InlineData("values = items[\n start:", " ")] + [InlineData("text = '''\n if ready:", " ")] + [InlineData("text = \"\"\"\n if ready:", " ")] + [InlineData("text = '''\nif ready:\n'''\nif ready:", " ")] + [InlineData("if (\n ready\n):", " ")] + [InlineData("if ready: run()", "")] + public void NewlinePreservesIndentationAndIndentsBlockHeaders(string precedingText, string expectedIndentation) + { + var document = new TextDocument(precedingText + "\n"); + var strategy = CreateStrategy(); + + strategy.IndentLine(document, document.Lines[^1]); + + Assert.Equal(precedingText + "\n" + expectedIndentation, document.Text); + } + + [Fact] + public void NewlineReplacesLeadingWhitespaceWithoutChangingFollowingText() + { + var document = new TextDocument(" if ready:\n run()"); + + CreateStrategy().IndentLine(document, document.Lines[1]); + + Assert.Equal(" if ready:\n run()", document.Text); + } + + [Fact] + public void NewlineUsesCurrentEditorOptions() + { + var options = new TextEditorOptions { ConvertTabsToSpaces = true, IndentationSize = 2 }; + var strategy = new PythonIndentationStrategy(options); + var document = new TextDocument("if ready:\n"); + + strategy.IndentLine(document, document.Lines[1]); + Assert.Equal("if ready:\n ", document.Text); + + options.ConvertTabsToSpaces = false; + document.Text = "if ready:\n"; + strategy.IndentLine(document, document.Lines[1]); + Assert.Equal("if ready:\n\t", document.Text); + } + + [Fact] + public void FirstLineIsUnchanged() + { + var document = new TextDocument(" value = 1"); + + CreateStrategy().IndentLine(document, document.Lines[0]); + + Assert.Equal(" value = 1", document.Text); + } + + [Fact] + public void IndentLinesDoesNotChangeExistingBlockStructure() + { + const string text = "if ready:\n run()\nfinish()\n"; + var document = new TextDocument(text); + var strategy = CreateStrategy(); + + strategy.IndentLines(document, 1, document.LineCount); + strategy.IndentLines(document, 2, 3); + + Assert.Equal(text, document.Text); + } + + [Fact] + public void NewlineSupportsCrLf() + { + var document = new TextDocument("if ready:\r\n"); + + CreateStrategy().IndentLine(document, document.Lines[1]); + + Assert.Equal("if ready:\r\n ", document.Text); + } + + private static PythonIndentationStrategy CreateStrategy() => + new(new TextEditorOptions { ConvertTabsToSpaces = true, IndentationSize = 4 }); +} diff --git a/tests/OneWare.Python.UnitTests/PythonInterpreterPickerTests.cs b/tests/OneWare.Python.UnitTests/PythonInterpreterPickerTests.cs new file mode 100644 index 000000000..0e2070754 --- /dev/null +++ b/tests/OneWare.Python.UnitTests/PythonInterpreterPickerTests.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using OneWare.Essentials.Models; +using OneWare.Essentials.Services; +using OneWare.Essentials.ViewModels; +using OneWare.Settings; +using Xunit; + +namespace OneWare.Python.UnitTests; + +public sealed class PythonInterpreterPickerTests +{ + [Fact] + public async Task CancelLeavesWorkspaceSettingUnchanged() + { + using var fixture = new PythonInterpreterServiceTests.InterpreterFixture(); + var executable = fixture.AddExecutable("chosen/python"); + fixture.Service.SetWorkspaceInterpreter(fixture.Workspace, executable); + var before = fixture.Settings.GetSettingValue>( + PythonInterpreterService.WorkspaceInterpretersSettingKey); + var picker = CreatePicker(fixture, (_, _) => Task.FromResult(null)); + + await picker.SelectInterpreterAsync(); + + Assert.Same(before, fixture.Settings.GetSettingValue>( + PythonInterpreterService.WorkspaceInterpretersSettingKey)); + Assert.Equal(executable, fixture.Service.GetWorkspaceInterpreter(fixture.Workspace)); + } + + [Fact] + public async Task AutomaticRemovesWorkspaceOverride() + { + using var fixture = new PythonInterpreterServiceTests.InterpreterFixture(); + fixture.Service.SetWorkspaceInterpreter(fixture.Workspace, fixture.AddExecutable("chosen/python")); + var picker = CreatePicker(fixture, (_, arguments) => Task.FromResult( + ((IEnumerable)arguments![3]!).First())); + + await picker.SelectInterpreterAsync(); + + Assert.Null(fixture.Service.GetWorkspaceInterpreter(fixture.Workspace)); + } + + [Fact] + public async Task RefreshRediscoversCandidatesWithoutChangingSelection() + { + using var fixture = new PythonInterpreterServiceTests.InterpreterFixture(); + var calls = 0; + var picker = CreatePicker(fixture, (_, arguments) => + { + calls++; + return Task.FromResult(calls == 1 + ? ((IEnumerable)arguments![3]!).Single(x => Equals(x, "Refresh")) + : null); + }); + + await picker.SelectInterpreterAsync(); + + Assert.Equal(2, calls); + Assert.Null(fixture.Service.GetWorkspaceInterpreter(fixture.Workspace)); + } + + [Theory] + [InlineData(".py", true)] + [InlineData(".pyi", true)] + [InlineData(".txt", false)] + public void ActivePythonDocumentUsesProjectRootOtherwiseActiveProject(string extension, bool usesDocument) + { + using var fixture = new PythonInterpreterServiceTests.InterpreterFixture(); + var documentRoot = Path.Combine(fixture.Root, "document-project"); + var document = Proxy((method, _) => method.Name switch + { + "get_FullPath" => Path.Combine(documentRoot, "nested", "file" + extension), + "get_Extension" => extension, + _ => null + }); + var dock = Proxy((method, _) => method.Name == "get_CurrentDocument" ? document : null); + var projects = Proxy((method, _) => method.Name switch + { + "get_ActiveProject" => Project(fixture.Workspace), + "GetRootFromFile" => Project(documentRoot), + _ => null + }); + var picker = new PythonInterpreterPickerService(fixture.Service, Proxy(), dock, projects, + Proxy(), fixture.Settings, Proxy()); + Assert.Equal(usesDocument ? documentRoot : fixture.Workspace, picker.GetActiveWorkspace()); + } + + [Fact] + public void LoosePythonFileUsesContainingDirectory() + { + using var fixture = new PythonInterpreterServiceTests.InterpreterFixture(); + var directory = Path.Combine(fixture.Root, "loose-files"); + var document = Proxy((method, _) => method.Name switch + { + "get_FullPath" => Path.Combine(directory, "script.py"), + "get_Extension" => ".py", + _ => null + }); + var dock = Proxy((method, _) => method.Name == "get_CurrentDocument" ? document : null); + var picker = new PythonInterpreterPickerService(fixture.Service, Proxy(), dock, + Proxy(), Proxy(), fixture.Settings, Proxy()); + Assert.Equal(directory, picker.GetActiveWorkspace()); + } + + [Fact] + public async Task NoWorkspaceExplainsGlobalSettingWithoutOpeningPicker() + { + using var fixture = new PythonInterpreterServiceTests.InterpreterFixture(); + string? message = null; + var windows = Proxy((method, arguments) => + { + Assert.Equal("ShowMessageAsync", method.Name); + message = (string)arguments![1]!; + return Task.CompletedTask; + }); + var picker = new PythonInterpreterPickerService(fixture.Service, windows, + Proxy(), Proxy(), Proxy(), + fixture.Settings, Proxy()); + await picker.SelectInterpreterAsync(); + Assert.Contains("Settings", message); + } + + [Fact] + public void InitializeRegistersPaletteAndCodeMenuOnce() + { + using var fixture = new PythonInterpreterServiceTests.InterpreterFixture(); + var commandCount = 0; + var menuCount = 0; + var commands = Proxy((method, args) => + { + Assert.Equal("RegisterCommand", method.Name); + Assert.Equal("Python: Select Interpreter", ((IApplicationCommand)args![0]!).Name); + commandCount++; + return null; + }); + var windows = Proxy((method, args) => + { + Assert.Equal("RegisterMenuItem", method.Name); + Assert.Equal("MainWindow_MainMenu/Code", args![0]); + var menu = Assert.Single((MenuItemModel[])args[1]!); + Assert.Equal("Python", menu.Header); + Assert.Equal("Select Interpreter...", Assert.Single(menu.Items!).Header); + menuCount++; + return null; + }); + var picker = new PythonInterpreterPickerService(fixture.Service, windows, + Proxy(), Proxy(), commands, fixture.Settings, Proxy()); + picker.Initialize(); + picker.Initialize(); + Assert.Equal(1, commandCount); + Assert.Equal(1, menuCount); + } + + private static PythonInterpreterPickerService CreatePicker( + PythonInterpreterServiceTests.InterpreterFixture fixture, + Func select, + ISettingsService? settings = null) + { + var windows = Proxy((method, args) => + method.Name == "ShowInputSelectAsync" ? select(method, args) : Task.CompletedTask); + var projects = Proxy((method, _) => + method.Name == "get_ActiveProject" ? Project(fixture.Workspace) : null); + return new PythonInterpreterPickerService(fixture.Service, windows, + Proxy(), projects, Proxy(), + settings ?? Proxy(), + Proxy((method, _) => method.Name == "get_SettingsPath" + ? Path.Combine(fixture.Root, "settings.json") + : null)); + } + + [Fact] + public async Task ConfirmingMissingCurrentSelectionDoesNotResetToAutomatic() + { + using var fixture = new PythonInterpreterServiceTests.InterpreterFixture(); + var missing = Path.Combine(fixture.Root, "missing-python"); + fixture.Service.SetWorkspaceInterpreter(fixture.Workspace, missing); + var picker = CreatePicker(fixture, (_, arguments) => + { + var selected = Assert.IsType(arguments![4]); + Assert.Contains("missing or invalid", selected.Label); + return Task.FromResult(selected); + }); + + await picker.SelectInterpreterAsync(); + + Assert.Equal(missing, fixture.Service.GetWorkspaceInterpreter(fixture.Workspace)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ConfirmedSelectionsAreSavedImmediately(bool automatic) + { + using var fixture = new PythonInterpreterServiceTests.InterpreterFixture(); + var executable = fixture.AddExecutable("chosen/python"); + fixture.Service.SetWorkspaceInterpreter(fixture.Workspace, Path.Combine(fixture.Root, "previous")); + Directory.CreateDirectory(fixture.Root); + try + { + var picker = CreatePicker(fixture, (_, arguments) => + { + var options = (IEnumerable)arguments![3]!; + return Task.FromResult(automatic + ? options.First() + : options.OfType().Single(x => x.ExecutablePath == executable) as object); + }, fixture.Settings); + fixture.Settings.SetSettingValue(PythonInterpreterService.GlobalInterpreterSettingKey, executable); + + await picker.SelectInterpreterAsync(); + + var saved = new SettingsService(); + saved.Load(Path.Combine(fixture.Root, "settings.json")); + using var service = new PythonInterpreterService(saved); + Assert.Equal(automatic ? null : executable, service.GetWorkspaceInterpreter(fixture.Workspace)); + } + finally + { + Directory.Delete(fixture.Root, true); + } + } + + private static IProjectRoot Project(string root) => + Proxy((method, _) => method.Name == "get_RootFolderPath" ? root : null); + + private static T Proxy(Func? invoke = null) where T : class + { + var instance = DispatchProxy.Create(); + ((InterfaceProxy)(object)instance).Handler = invoke; + return instance; + } + + public class InterfaceProxy : DispatchProxy + { + public Func? Handler { get; set; } + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => + Handler?.Invoke(targetMethod!, args); + } +} diff --git a/tests/OneWare.Python.UnitTests/PythonInterpreterServiceTests.cs b/tests/OneWare.Python.UnitTests/PythonInterpreterServiceTests.cs new file mode 100644 index 000000000..7245b2328 --- /dev/null +++ b/tests/OneWare.Python.UnitTests/PythonInterpreterServiceTests.cs @@ -0,0 +1,382 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OneWare.Settings; +using Xunit; + +namespace OneWare.Python.UnitTests; + +public sealed class PythonInterpreterServiceTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ResolutionFollowsDocumentedPrecedence(bool windows) + { + using var fixture = new InterpreterFixture(windows); + var workspaceOverride = fixture.AddExecutable("selected/python"); + var dotVenv = fixture.AddEnvironment(fixture.Workspace, ".venv"); + var venv = fixture.AddEnvironment(fixture.Workspace, "venv"); + var global = fixture.AddExecutable("global/python"); + var path = fixture.AddPathPython(); + fixture.Settings.SetSettingValue(PythonInterpreterService.GlobalInterpreterSettingKey, global); + fixture.Service.SetWorkspaceInterpreter(fixture.Workspace, workspaceOverride); + + Assert.Equal(workspaceOverride, fixture.Service.GetInterpreter(fixture.Workspace).ExecutablePath); + fixture.Service.SetWorkspaceInterpreter(fixture.Workspace, null); + Assert.Equal(dotVenv, fixture.Service.GetInterpreter(fixture.Workspace).ExecutablePath); + fixture.Executables.Remove(dotVenv); + Assert.Equal(venv, fixture.Service.GetInterpreter(fixture.Workspace).ExecutablePath); + fixture.Executables.Remove(venv); + Assert.Equal(global, fixture.Service.GetInterpreter(fixture.Workspace).ExecutablePath); + fixture.Settings.SetSettingValue(PythonInterpreterService.GlobalInterpreterSettingKey, ""); + Assert.Equal(path, fixture.Service.GetInterpreter(fixture.Workspace).ExecutablePath); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void InvalidExplicitSelectionNeverFallsBack(bool workspaceSelection) + { + using var fixture = new InterpreterFixture(); + var invalid = Path.Combine(fixture.Root, "missing", "python"); + fixture.AddPathPython(); + fixture.AddExecutable("global/python"); + if (workspaceSelection) + { + fixture.AddEnvironment(fixture.Workspace, ".venv"); + fixture.Service.SetWorkspaceInterpreter(fixture.Workspace, invalid); + } + else + fixture.Settings.SetSettingValue(PythonInterpreterService.GlobalInterpreterSettingKey, invalid); + + var resolution = fixture.Service.GetInterpreter(fixture.Workspace); + Assert.Equal(PythonInterpreterStatus.InvalidExplicitSelection, resolution.Status); + Assert.False(resolution.IsResolved); + Assert.Equal(invalid, resolution.ExecutablePath); + Assert.Equal(workspaceSelection + ? PythonInterpreterSource.WorkspaceOverride + : PythonInterpreterSource.GlobalDefault, resolution.Source); + Assert.Contains("no fallback", resolution.Message); + } + + [Theory] + [InlineData("python")] + [InlineData("relative/python")] + [InlineData("invalid\0path")] + public void ExplicitSelectionRequiresValidAbsoluteExecutablePath(string path) + { + using var fixture = new InterpreterFixture(); + fixture.Executables.Add(path); + fixture.AddPathPython(); + fixture.Service.SetWorkspaceInterpreter(fixture.Workspace, path); + Assert.Equal(PythonInterpreterStatus.InvalidExplicitSelection, + fixture.Service.GetInterpreter(fixture.Workspace).Status); + } + + [Fact] + public void MissingInterpreterIsActionableUnresolvedState() + { + using var fixture = new InterpreterFixture(); + var resolution = fixture.Service.GetInterpreter(fixture.Workspace); + Assert.Equal(PythonInterpreterStatus.Missing, resolution.Status); + Assert.Null(resolution.ExecutablePath); + Assert.Contains("Install Python externally", resolution.Message); + } + + [Theory] + [InlineData(false, "python3", "python")] + [InlineData(true, "python.exe", "python3.exe")] + public void PathFallbackUsesPlatformSpecificNameOrder(bool windows, string preferred, string other) + { + using var fixture = new InterpreterFixture(windows); + var firstDirectory = Path.Combine(fixture.Root, "bin1"); + var secondDirectory = Path.Combine(fixture.Root, "bin2"); + fixture.Environment["PATH"] = string.Join(windows ? ';' : ':', firstDirectory, secondDirectory); + fixture.Executables.Add(Path.Combine(firstDirectory, other)); + var preferredPath = Path.Combine(secondDirectory, preferred); + fixture.Executables.Add(preferredPath); + var result = fixture.Service.GetInterpreter(fixture.Workspace); + Assert.Equal(preferredPath, result.ExecutablePath); + Assert.Equal(PythonInterpreterSource.Path, result.Source); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ActivatedEnvironmentsArePickerCandidatesNotAutomaticOverrides(bool windows) + { + using var fixture = new InterpreterFixture(windows); + var virtualEnv = Path.Combine(fixture.Root, "active virtual env"); + var condaEnv = Path.Combine(fixture.Root, "active conda env"); + fixture.Environment["VIRTUAL_ENV"] = virtualEnv; + fixture.Environment["CONDA_PREFIX"] = condaEnv; + var virtualPython = PythonInterpreterService.GetEnvironmentInterpreterPath(virtualEnv, windows); + var condaPython = windows + ? Path.Combine(condaEnv, "python.exe") + : Path.Combine(condaEnv, "bin", "python"); + fixture.Executables.UnionWith([virtualPython, condaPython]); + + Assert.Equal(PythonInterpreterStatus.Missing, fixture.Service.GetInterpreter(fixture.Workspace).Status); + var candidates = await fixture.Service.DiscoverCandidatesAsync(fixture.Workspace); + Assert.Contains(candidates, x => x.ExecutablePath == virtualPython && x.Label == "VIRTUAL_ENV"); + Assert.Contains(candidates, x => x.ExecutablePath == condaPython && x.Label == "CONDA_PREFIX"); + } + + [Theory] + [InlineData(false, 2)] + [InlineData(true, 1)] + public async Task CandidateDeduplicationUsesPlatformPathComparison(bool windows, int count) + { + using var fixture = new InterpreterFixture(windows); + var path = fixture.AddExecutable("custom/python"); + var upper = path.ToUpperInvariant(); + fixture.Executables.Add(upper); + fixture.Service.SetWorkspaceInterpreter(fixture.Workspace, path); + fixture.Settings.SetSettingValue(PythonInterpreterService.GlobalInterpreterSettingKey, upper); + Assert.Equal(count, (await fixture.Service.DiscoverCandidatesAsync(fixture.Workspace)).Count); + } + + [Fact] + public void WorkspaceMapUpdatesAreImmutableAndNormalizePaths() + { + using var fixture = new InterpreterFixture(); + var original = fixture.Settings.GetSettingValue>( + PythonInterpreterService.WorkspaceInterpretersSettingKey); + var interpreter = fixture.AddExecutable("\u74b0\u5883 with spaces/python"); + var equivalentWorkspace = Path.Combine(fixture.Workspace, ".", "child", "..") + Path.DirectorySeparatorChar; + fixture.Service.SetWorkspaceInterpreter(equivalentWorkspace, interpreter); + var updated = fixture.Settings.GetSettingValue>( + PythonInterpreterService.WorkspaceInterpretersSettingKey); + + Assert.Empty(original); + Assert.NotSame(original, updated); + Assert.Equal(interpreter, updated[fixture.Workspace]); + Assert.Equal(interpreter, fixture.Service.GetWorkspaceInterpreter(fixture.Workspace)); + fixture.Service.SetWorkspaceInterpreter(fixture.Workspace, null); + Assert.Single(updated); + Assert.Null(fixture.Service.GetWorkspaceInterpreter(fixture.Workspace)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void WorkspaceSelectionsRoundTripThroughNormalSettingsPersistence(bool loadBeforeRegistration) + { + using var fixture = new InterpreterFixture(); + Directory.CreateDirectory(fixture.Root); + try + { + var interpreter = fixture.AddExecutable("\u74b0\u5883 with spaces/python"); + fixture.Service.SetWorkspaceInterpreter(fixture.Workspace, interpreter); + var settingsPath = Path.Combine(fixture.Root, "settings.json"); + fixture.Settings.Save(settingsPath); + var reloadedSettings = new SettingsService(); + if (loadBeforeRegistration) reloadedSettings.Load(settingsPath); + using var reloaded = new PythonInterpreterService(reloadedSettings, false, + fixture.Executables.Contains, _ => null); + reloaded.Initialize(); + if (!loadBeforeRegistration) reloadedSettings.Load(settingsPath); + + Assert.Equal(interpreter, reloaded.GetInterpreter(fixture.Workspace).ExecutablePath); + Assert.Equal(PythonInterpreterSource.WorkspaceOverride, reloaded.GetInterpreter(fixture.Workspace).Source); + } + finally + { + Directory.Delete(fixture.Root, true); + } + } + + [Fact] + public void WorkspaceChangeNotifiesOnlyThatTrackedWorkspace() + { + using var fixture = new InterpreterFixture(); + var secondWorkspace = Path.Combine(fixture.Root, "second"); + fixture.Service.GetInterpreter(fixture.Workspace); + fixture.Service.GetInterpreter(secondWorkspace); + var changes = new List(); + fixture.Service.InterpreterChanged += (_, args) => changes.Add(args); + fixture.Service.SetWorkspaceInterpreter(fixture.Workspace, fixture.AddExecutable("selected/python")); + + Assert.Equal(fixture.Workspace, Assert.Single(changes).Workspace); + Assert.Equal(PythonInterpreterStatus.Missing, fixture.Service.GetInterpreter(secondWorkspace).Status); + } + + [Fact] + public void GlobalChangeNotifiesOnlyWorkspacesDependingOnGlobalDefault() + { + using var fixture = new InterpreterFixture(); + var workspaceWithVenv = Path.Combine(fixture.Root, "venv-workspace"); + var workspaceWithOverride = Path.Combine(fixture.Root, "override-workspace"); + fixture.AddEnvironment(workspaceWithVenv, ".venv"); + fixture.Service.SetWorkspaceInterpreter(workspaceWithOverride, fixture.AddExecutable("override/python")); + fixture.Service.GetInterpreter(fixture.Workspace); + fixture.Service.GetInterpreter(workspaceWithVenv); + fixture.Service.GetInterpreter(workspaceWithOverride); + var changes = new List(); + fixture.Service.InterpreterChanged += (_, args) => changes.Add(args); + + fixture.Settings.SetSettingValue(PythonInterpreterService.GlobalInterpreterSettingKey, + fixture.AddExecutable("new-global/python")); + + Assert.Equal(fixture.Workspace, Assert.Single(changes).Workspace); + } + + [Fact] + public void RefreshReportsNewWorkspaceEnvironmentWithoutSettingsChanges() + { + using var fixture = new InterpreterFixture(); + fixture.Service.GetInterpreter(fixture.Workspace); + var changes = new List(); + fixture.Service.InterpreterChanged += (_, args) => changes.Add(args); + var venv = fixture.AddEnvironment(fixture.Workspace, ".venv"); + fixture.Service.Refresh(fixture.Workspace); + fixture.Service.Refresh(fixture.Workspace); + Assert.Equal(venv, Assert.Single(changes).Resolution.ExecutablePath); + } + + [Fact] + public void AutomaticResetNotifiesAndRestoresFallback() + { + using var fixture = new InterpreterFixture(); + var fallback = fixture.AddPathPython(); + fixture.Service.SetWorkspaceInterpreter(fixture.Workspace, fixture.AddExecutable("override/python")); + fixture.Service.GetInterpreter(fixture.Workspace); + var changes = new List(); + fixture.Service.InterpreterChanged += (_, args) => changes.Add(args); + fixture.Service.SetWorkspaceInterpreter(fixture.Workspace, null); + Assert.Equal(fallback, Assert.Single(changes).Resolution.ExecutablePath); + } + + [Fact] + public void InitializeIsIdempotentAndHiddenMapIsNotAVisibleSetting() + { + using var fixture = new InterpreterFixture(); + fixture.Service.Initialize(); + Assert.IsType( + fixture.Settings.GetSetting(PythonInterpreterService.WorkspaceInterpretersSettingKey)); + Assert.Single(fixture.Settings.SettingCategories["Languages"].SettingSubCategories["Python"].Settings); + } + + [Fact] + public async Task DiscoveryAndPathInspectionAreBounded() + { + using var fixture = new InterpreterFixture(); + fixture.Environment["PATH"] = string.Join(':', Enumerable.Range(0, 1000) + .Select(x => Path.Combine(fixture.Root, $"bin{x}"))); + foreach (var directory in fixture.Environment["PATH"]!.Split(':')) + fixture.Executables.Add(Path.Combine(directory, "python3")); + Assert.Equal(32, (await fixture.Service.DiscoverCandidatesAsync(fixture.Workspace)).Count); + + fixture.Executables.Clear(); + fixture.Executables.Add(Path.Combine(fixture.Root, "bin999", "python3")); + Assert.Equal(PythonInterpreterStatus.Missing, fixture.Service.GetInterpreter(fixture.Workspace).Status); + } + + [Fact] + public void RealFilesystemPreservesVenvSymlinksAndRejectsNonExecutableFiles() + { + if (OperatingSystem.IsWindows()) return; + using var fixture = new InterpreterFixture(); + var basePython = Path.Combine(fixture.Root, "base-python"); + var venvPython = Path.Combine(fixture.Workspace, ".venv", "bin", "python"); + Directory.CreateDirectory(Path.GetDirectoryName(venvPython)!); + try + { + File.WriteAllText(basePython, "#!/bin/sh\nexit 0\n"); + File.SetUnixFileMode(basePython, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + File.CreateSymbolicLink(venvPython, basePython); + var settings = new SettingsService(); + using var service = new PythonInterpreterService(settings); + Assert.Equal(venvPython, service.GetInterpreter(fixture.Workspace).ExecutablePath); + File.SetUnixFileMode(basePython, UnixFileMode.UserRead | UnixFileMode.UserWrite); + service.SetWorkspaceInterpreter(fixture.Workspace, venvPython); + Assert.Equal(PythonInterpreterStatus.InvalidExplicitSelection, service.GetInterpreter(fixture.Workspace).Status); + } + finally + { + Directory.Delete(fixture.Root, true); + } + } + + [Fact] + public void CorruptWorkspaceValuesRemainExplicitInvalidAndAreLoggedOnce() + { + using var fixture = new InterpreterFixture(); + fixture.Settings.SetSettingValue(PythonInterpreterService.WorkspaceInterpretersSettingKey, + new Dictionary + { + [fixture.Workspace] = "", + ["relative-workspace"] = "python" + }); + var logger = new RecordingLogger(); + using var service = new PythonInterpreterService(fixture.Settings, false, _ => true, _ => null, logger); + + var result = service.GetInterpreter(fixture.Workspace); + service.GetInterpreter(fixture.Workspace); + + Assert.Equal(PythonInterpreterStatus.InvalidExplicitSelection, result.Status); + Assert.Equal(PythonInterpreterSource.WorkspaceOverride, result.Source); + Assert.Equal(2, logger.WarningCount); + } + + private sealed class RecordingLogger : ILogger + { + public int WarningCount { get; private set; } + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Warning) WarningCount++; + } + } + + internal sealed class InterpreterFixture : IDisposable + { + public string Root { get; } = Path.Combine(Directory.GetCurrentDirectory(), $"python-interpreter-test-{Guid.NewGuid():N}"); + public string Workspace => Path.Combine(Root, "workspace with spaces \u65e5\u672c\u8a9e"); + public SettingsService Settings { get; } = new(); + public Dictionary Environment { get; } = new(); + public HashSet Executables { get; } + public PythonInterpreterService Service { get; } + private readonly bool _windows; + + public InterpreterFixture(bool windows = false) + { + _windows = windows; + Executables = new HashSet(windows ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + Service = new PythonInterpreterService(Settings, windows, Executables.Contains, + key => Environment.GetValueOrDefault(key)); + Service.Initialize(); + } + + public string AddExecutable(string relativePath) + { + var path = Path.Combine(Root, relativePath); + Executables.Add(path); + return path; + } + + public string AddEnvironment(string workspace, string name) + { + var path = PythonInterpreterService.GetEnvironmentInterpreterPath(Path.Combine(workspace, name), _windows); + Executables.Add(path); + return path; + } + + public string AddPathPython() + { + var directory = Path.Combine(Root, "path-bin"); + Environment["PATH"] = directory; + var path = Path.Combine(directory, _windows ? "python.exe" : "python3"); + Executables.Add(path); + return path; + } + + public void Dispose() => Service.Dispose(); + } +} diff --git a/tests/OneWare.Python.UnitTests/PythonLanguageServerConfigurationTests.cs b/tests/OneWare.Python.UnitTests/PythonLanguageServerConfigurationTests.cs new file mode 100644 index 000000000..8ce778f2c --- /dev/null +++ b/tests/OneWare.Python.UnitTests/PythonLanguageServerConfigurationTests.cs @@ -0,0 +1,68 @@ +using System.Linq; +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using Xunit; + +namespace OneWare.Python.UnitTests; + +public class PythonLanguageServerConfigurationTests +{ + [Theory] + [InlineData("/work/project with spaces/.venv/bin/python")] + [InlineData(@"C:\Projects\my project\.venv\Scripts\python.exe")] + public void ConfigurationUsesInterpreterAsData(string interpreter) + { + var settings = PythonLanguageServerConfiguration.Create(interpreter); + + Assert.Equal(interpreter, settings["pythonPath"]?.Value()); + Assert.Equal("openFilesOnly", settings["pyrefly"]?["diagnosticMode"]?.Value()); + Assert.Null(settings["python"]); + } + + [Fact] + public void UnresolvedInterpreterIsOmitted() + { + Assert.Null(PythonLanguageServerConfiguration.Create(null)["pythonPath"]); + } + + [Fact] + public void ConfigurationResponsesPreserveOrderAndSectionShape() + { + var settings = PythonLanguageServerConfiguration.Create("/work/.venv/bin/python"); + var result = PythonLanguageServerConfiguration.Respond(new ConfigurationParams + { + Items = new Container( + new ConfigurationItem { Section = "unknown" }, + new ConfigurationItem { Section = "python", ScopeUri = "file:///work/" }, + new ConfigurationItem { Section = "python" }, + new ConfigurationItem()) + }, settings).ToArray(); + + Assert.Equal(JTokenType.Null, result[0].Type); + Assert.True(JToken.DeepEquals(settings, result[1])); + Assert.True(JToken.DeepEquals(settings, result[2])); + Assert.True(JToken.DeepEquals(settings, result[3]["python"])); + Assert.NotSame(settings, result[1]); + } + + [Fact] + public void PackageHasPinnedTargetsForAllDesktopPlatforms() + { + var version = Assert.Single(PythonModule.PyreflyPackage.Versions!); + Assert.Equal(PythonModule.PyreflyVersion, version.Version); + Assert.Equal( + new[] { "linux-arm64", "linux-x64", "osx-arm64", "osx-x64", "win-arm64", "win-x64" }, + version.Targets!.Select(target => target.Target).Order().ToArray()); + foreach (var target in version.Targets!) + { + Assert.StartsWith( + $"https://github.com/facebook/pyrefly/releases/download/{PythonModule.PyreflyVersion}/", + target.Url); + var setting = Assert.Single(target.AutoSetting!); + Assert.Equal(PythonModule.LspPathSetting, setting.SettingKey); + Assert.Equal(target.Target!.StartsWith("win-") ? "pyrefly.exe" : "pyrefly", setting.RelativePath); + } + Assert.Contains(".py", PythonModule.SupportedExtensions); + Assert.Contains(".pyi", PythonModule.SupportedExtensions); + } +}