diff --git a/extensions/positron-python/pythonFiles/positron/lsp.py b/extensions/positron-python/pythonFiles/positron/lsp.py new file mode 100644 index 00000000000..56ecf99e1e7 --- /dev/null +++ b/extensions/positron-python/pythonFiles/positron/lsp.py @@ -0,0 +1,61 @@ +# +# Copyright (C) 2023 Posit Software, PBC. All rights reserved. +# + +import urllib.parse +from typing import Tuple + +from .positron_jedilsp import POSITRON + + +class LSPService: + """ + LSPService manages the positron.lsp comm and cooridinates starting the LSP. + """ + + def __init__(self, kernel): # noqa: F821 + self.kernel = kernel + self.lsp_comm = None + + def on_comm_open(self, comm, open_msg) -> None: + """ + Setup positron.lsp comm to receive messages. + """ + self.lsp_comm = comm + comm.on_msg(self.receive_message) + self.receive_open(open_msg) + + def receive_open(self, msg) -> None: + """ + Start the LSP on the requested port. + """ + data = msg["content"]["data"] + + client_address = data.get("client_address", None) + if client_address is not None: + host, port = self.split_address(client_address) + if host is not None and port is not None: + POSITRON.start(lsp_host=host, lsp_port=port, kernel=self.kernel) + return + + raise ValueError("Invalid client_address in LSP open message") + + def receive_message(self, msg) -> None: + """ + Handle messages received from the client via the positron.lsp comm. + """ + pass + + def shutdown(self) -> None: + if self.lsp_comm is not None: + try: + self.lsp_comm.close() + except Exception: + pass + + def split_address(self, client_address: str) -> Tuple[str | None, int | None]: + """ + Split an address of the form "host:port" into a tuple of (host, port). + """ + result = urllib.parse.urlsplit("//" + client_address) + return (result.hostname, result.port) diff --git a/extensions/positron-python/pythonFiles/positron/positron_ipkernel.py b/extensions/positron-python/pythonFiles/positron/positron_ipkernel.py index 2d66e7f2b11..ee6c37bf323 100644 --- a/extensions/positron-python/pythonFiles/positron/positron_ipkernel.py +++ b/extensions/positron-python/pythonFiles/positron/positron_ipkernel.py @@ -16,6 +16,7 @@ from .dataviewer import DataViewerService from .environment import EnvironmentService from .inspectors import get_inspector +from .lsp import LSPService from .plots import PositronDisplayPublisherHook POSITRON_DATA_VIEWER_COMM = "positron.dataViewer" @@ -24,6 +25,9 @@ POSITRON_ENVIRONMENT_COMM = "positron.environment" """The comm channel target_name for Positron's Environment View""" +POSITRON_LSP_COMM = "positron.lsp" +"""The comm channel target_name for Positron's LSP""" + POSITRON_PLOT_COMM = "positron.plot" """The comm channel target_name for Positron's Plots View""" @@ -51,6 +55,10 @@ def __init__(self, **kwargs): shell.events.register("post_execute", self.handle_post_execute) self.get_user_ns_hidden().update(POSITON_NS_HIDDEN) + # Setup Positron's LSP service + self.lsp_service = LSPService(self) + self.comm_manager.register_target(POSITRON_LSP_COMM, self.lsp_service.on_comm_open) + # Setup Positron's environment service self.env_service = EnvironmentService(self) self.comm_manager.register_target(POSITRON_ENVIRONMENT_COMM, self.env_service.on_comm_open) @@ -70,6 +78,7 @@ def do_shutdown(self, restart) -> dict: result = super().do_shutdown(restart) self.display_pub_hook.shutdown() self.env_service.shutdown() + self.lsp_service.shutdown() self.dataviewer_service.shutdown() return result diff --git a/extensions/positron-python/pythonFiles/positron/positron_jedilsp.py b/extensions/positron-python/pythonFiles/positron/positron_jedilsp.py index f0482156fbc..633bbfab18f 100644 --- a/extensions/positron-python/pythonFiles/positron/positron_jedilsp.py +++ b/extensions/positron-python/pythonFiles/positron/positron_jedilsp.py @@ -12,7 +12,7 @@ from threading import Event from typing import Any, Callable, List, Optional, Union -from ipykernel import kernelapp + from jedi.api import Interpreter from jedi_language_server import jedi_utils, pygls_utils from jedi_language_server.server import ( @@ -83,8 +83,6 @@ from pygls.capabilities import get_capability from pygls.feature_manager import has_ls_param_or_annotation -from .positron_ipkernel import PositronIPyKernel - class PositronJediLanguageServer(JediLanguageServer): """Positron extenstion to the Jedi language server.""" @@ -111,38 +109,30 @@ def decorator(f): return decorator - def start(self, lsp_host, lsp_port) -> None: + def start(self, lsp_host: str, lsp_port: int, kernel) -> None: """ - Starts IPyKernel and the Jedi LSP in parallel. This arrangement allows - us to share the active namespaces of the IPyKernel's interpreter with - the Jedi LSP for enhanced completions. + Start the LSP with a reference to Positron's IPyKernel to enhance + completions with awareness of live variables from user's namespace. """ + global KERNEL + KERNEL = kernel + loop = asyncio.get_event_loop() try: - asyncio.ensure_future(self.start_ipykernel()) - asyncio.ensure_future(self.start_jedi(lsp_host, lsp_port)) + asyncio.ensure_future(self._start_jedi(lsp_host, lsp_port)) loop.run_forever() except KeyboardInterrupt: pass finally: loop.close() - async def start_jedi(self, lsp_host, lsp_port): + async def _start_jedi(self, lsp_host, lsp_port): """Starts Jedi LSP as a TCP server using existing asyncio loop.""" self._stop_event = Event() loop = asyncio.get_event_loop() self._server = await loop.create_server(self.lsp, lsp_host, lsp_port) # type: ignore await self._server.serve_forever() - async def start_ipykernel(self) -> None: - """Starts Positron's IPyKernel as the interpreter for our console.""" - app = kernelapp.IPKernelApp.instance(kernel_class=PositronIPyKernel) - app.initialize() - # Register the kernel for enhanced LSP completions - global KERNEL - KERNEL = app.kernel - app.kernel.start() - POSITRON = PositronJediLanguageServer( name="jedi-language-server", @@ -151,7 +141,7 @@ async def start_ipykernel(self) -> None: ) -KERNEL: Optional[PositronIPyKernel] = None +KERNEL = None # Server Features # Unfortunately we need to re-register these as Pygls Feature Management does diff --git a/extensions/positron-python/pythonFiles/positron_language_server.py b/extensions/positron-python/pythonFiles/positron_language_server.py index 4622e970560..56bb829b517 100644 --- a/extensions/positron-python/pythonFiles/positron_language_server.py +++ b/extensions/positron-python/pythonFiles/positron_language_server.py @@ -3,28 +3,27 @@ Server and IPyKernel in the same environment. """ +import asyncio import argparse import logging import os import sys import traceback -from typing import Tuple + +from ipykernel import kernelapp # Add the lib path to our sys path so jedi_language_server can find its references EXTENSION_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(EXTENSION_ROOT, "pythonFiles", "lib", "jedilsp")) sys.path.insert(1, os.path.join(EXTENSION_ROOT, "pythonFiles", "lib", "python")) -from positron.positron_jedilsp import POSITRON +from positron.positron_ipkernel import PositronIPyKernel -def initialize() -> Tuple[str, int]: +def initialize_config() -> None: """ Initialize the configuration for the Positron Python Language Server and REPL Kernel. - - Returns: - (str, int): TCP host and port of the LSP server """ # Given we're using TCP, support a subset of the Jedi LSP configuration @@ -34,24 +33,12 @@ def initialize() -> Tuple[str, int]: description="Positron Jedi language server: an LSP wrapper for jedi.", ) - parser.add_argument( - "--host", - help="host for web server (default 127.0.0.1)", - type=str, - default="127.0.0.1", - ) parser.add_argument( "--debugport", help="port for debugpy debugger", type=int, default=None, ) - parser.add_argument( - "--port", - help="port for web server (default 2087)", - type=int, - default=2087, - ) parser.add_argument( "--logfile", help="redirect logs to file specified", @@ -73,7 +60,7 @@ def initialize() -> Tuple[str, int]: log_level = {0: logging.WARN, 1: logging.INFO, 2: logging.DEBUG}.get( args.verbose, - logging.INFO, + logging.DEBUG, ) if args.logfile: @@ -82,41 +69,53 @@ def initialize() -> Tuple[str, int]: filemode="w", level=log_level, ) + pass else: logging.basicConfig(stream=sys.stderr, level=log_level) # Start the debugpy debugger if a port was specified if args.debugport is not None: - import debugpy - - debugpy.listen(args.debugport) + try: + import debugpy - return args.host, args.port + debugpy.listen(args.debugport) + except Exception as error: + logging.warning(f"Unable to start debugpy: {error}", exc_info=True) -def start(lsp_host: str, lsp_port: int): - """ - Starts Positron Python (based on the Jedi Language Server) to - suport both LSP and REPL functionality. - """ - exitStatus = POSITRON.start(lsp_host, lsp_port) - return exitStatus +async def start_ipykernel() -> None: + """Starts Positron's IPyKernel as the interpreter for our console.""" + app = kernelapp.IPKernelApp.instance(kernel_class=PositronIPyKernel) + app.initialize() + app.kernel.start() if __name__ == "__main__": exitStatus = 0 try: - lsp_host, lsp_port = initialize() - exitStatus = start(lsp_host, lsp_port) + # Init the configuration args + initialize_config() + + # Start Positron's IPyKernel as the interpreter for our console. + loop = asyncio.get_event_loop() + try: + asyncio.ensure_future(start_ipykernel()) + loop.run_forever() + except KeyboardInterrupt: + pass + finally: + loop.close() + except SystemExit as error: # TODO: Remove this workaround once we can improve Jedi # disconnection logic tb = "".join(traceback.format_tb(error.__traceback__)) if tb.find("connection_lost") > 0: - logging.warning("Positron LSP client disconnected, exiting.") + logging.warning("Positron Language Server client disconnected, exiting.") exitStatus = 0 else: - logging.error("Error in Positron Jedi LSP: %s", error) + logging.error("Error in Positron Language Server: %s", error) + exitStatus = 1 sys.exit(exitStatus) diff --git a/extensions/positron-python/src/client/activation/jedi/positronLanguageRuntimes.ts b/extensions/positron-python/src/client/activation/jedi/positronLanguageRuntimes.ts index 7758516db0c..50cf48af66d 100644 --- a/extensions/positron-python/src/client/activation/jedi/positronLanguageRuntimes.ts +++ b/extensions/positron-python/src/client/activation/jedi/positronLanguageRuntimes.ts @@ -151,12 +151,8 @@ export class PositronJediLanguageServerProxy implements ILanguageServerProxy { // Register each interpreter as a language runtime const portfinder = require('portfinder'); - let lspPort = 2087; let debugPort; for (const interpreter of interpreters) { - // Find an available port for our TCP server, starting the search from - // the next port each iteration. - lspPort = await portfinder.getPortPromise({ port: lspPort }); // If required, also locate an available port for the debugger if (debug) { @@ -166,10 +162,9 @@ export class PositronJediLanguageServerProxy implements ILanguageServerProxy { debugPort = await portfinder.getPortPromise({ port: debugPort }); } - const runtime: vscode.Disposable = await this.registerLanguageRuntime(ext, interpreter, lspPort, debugPort, options); + const runtime: vscode.Disposable = await this.registerLanguageRuntime(ext, interpreter, debugPort, options); this.disposables.push(runtime); - lspPort += 1; if (debugPort !== undefined) { debugPort += 1; } @@ -184,7 +179,6 @@ export class PositronJediLanguageServerProxy implements ILanguageServerProxy { private async registerLanguageRuntime( ext: vscode.Extension, interpreter: PythonEnvironment, - lspPort: number, debugPort: number | undefined, options: LanguageClientOptions): Promise { @@ -197,7 +191,7 @@ export class PositronJediLanguageServerProxy implements ILanguageServerProxy { const command = interpreter.path; const pythonVersion = interpreter.version?.raw; const lsScriptPath = path.join(EXTENSION_ROOT_DIR, 'pythonFiles', 'positron_language_server.py'); - const args = [command, lsScriptPath, `--port=${lspPort}`, '-f', '{connection_file}', '--logfile', '{log_file}'] + const args = [command, lsScriptPath, '-f', '{connection_file}', '--logfile', '{log_file}'] if (debugPort) { args.push(`--debugport=${debugPort}`); } @@ -209,9 +203,6 @@ export class PositronJediLanguageServerProxy implements ILanguageServerProxy { }; traceVerbose(`Configuring Jedi LSP with IPyKernel using args '${args}'`); - // Create a language client to connect to the LSP via TCP - const client = await this.createLanguageClientTCP(lspPort, options); - // Create an adapter for the kernel as our language runtime const runtime: positron.LanguageRuntime = ext.exports.adaptKernel(kernelSpec, PYTHON_LANGUAGE, @@ -219,16 +210,8 @@ export class PositronJediLanguageServerProxy implements ILanguageServerProxy { this.extensionVersion, '>>>', '...', - startupBehavior, () => this.startClient(client)); - - // Also stop the language client when the runtime is exiting - runtime.onDidChangeRuntimeState(state => { - if (client.isRunning() && ( - state === positron.RuntimeState.Exiting || - state === positron.RuntimeState.Exited)) { - client.stop(); - } - }); + startupBehavior, + (port: number) => this.startClient(options, port)); // Register our language runtime provider return positron.runtime.registerLanguageRuntime(runtime); @@ -274,24 +257,14 @@ export class PositronJediLanguageServerProxy implements ILanguageServerProxy { /** * Start the language client */ - private async startClient(client: LanguageClient): Promise { - this.registerHandlers(client); - await client.start(); - this.languageClients.push(client); - } - - /** - * Finds an available port to spawn a new Jedi LSP in TCP mode and returns a LanguageClient - * configured to connect to this server. - */ - private async createLanguageClientTCP( - port: number, - clientOptions: LanguageClientOptions, - ): Promise { + private async startClient(clientOptions: LanguageClientOptions, port: number): Promise { // Configure language client to connect to LSP via TCP on start const serverOptions: ServerOptions = async () => this.getServerOptions(port); - return new LanguageClient(PYTHON_LANGUAGE, 'Positron Python Jedi', serverOptions, clientOptions); + const client = new LanguageClient(PYTHON_LANGUAGE, 'Positron Python Jedi', serverOptions, clientOptions); + this.registerHandlers(client); + await client.start(); + this.languageClients.push(client); } /**