You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			311 lines
		
	
	
		
			9.3 KiB
		
	
	
	
		
			Python
		
	
			
		
		
	
	
			311 lines
		
	
	
		
			9.3 KiB
		
	
	
	
		
			Python
		
	
"""JupyterLab Server process handler"""
 | 
						|
 | 
						|
# Copyright (c) Jupyter Development Team.
 | 
						|
# Distributed under the terms of the Modified BSD License.
 | 
						|
from __future__ import annotations
 | 
						|
 | 
						|
import atexit
 | 
						|
import logging
 | 
						|
import os
 | 
						|
import re
 | 
						|
import signal
 | 
						|
import subprocess
 | 
						|
import sys
 | 
						|
import threading
 | 
						|
import time
 | 
						|
import weakref
 | 
						|
from logging import Logger
 | 
						|
from shutil import which as _which
 | 
						|
from typing import Any
 | 
						|
 | 
						|
from tornado import gen
 | 
						|
 | 
						|
try:
 | 
						|
    import pty
 | 
						|
except ImportError:
 | 
						|
    pty = None  # type:ignore[assignment]
 | 
						|
 | 
						|
if sys.platform == "win32":
 | 
						|
    list2cmdline = subprocess.list2cmdline
 | 
						|
else:
 | 
						|
 | 
						|
    def list2cmdline(cmd_list: list[str]) -> str:
 | 
						|
        """Shim for list2cmdline on posix."""
 | 
						|
        import shlex
 | 
						|
 | 
						|
        return " ".join(map(shlex.quote, cmd_list))
 | 
						|
 | 
						|
 | 
						|
def which(command: str, env: dict[str, str] | None = None) -> str:
 | 
						|
    """Get the full path to a command.
 | 
						|
 | 
						|
    Parameters
 | 
						|
    ----------
 | 
						|
    command: str
 | 
						|
        The command name or path.
 | 
						|
    env: dict, optional
 | 
						|
        The environment variables, defaults to `os.environ`.
 | 
						|
    """
 | 
						|
    env = env or os.environ  # type:ignore[assignment]
 | 
						|
    path = env.get("PATH") or os.defpath  # type:ignore[union-attr]
 | 
						|
    command_with_path = _which(command, path=path)
 | 
						|
 | 
						|
    # Allow nodejs as an alias to node.
 | 
						|
    if command == "node" and not command_with_path:
 | 
						|
        command = "nodejs"
 | 
						|
        command_with_path = _which("nodejs", path=path)
 | 
						|
 | 
						|
    if not command_with_path:
 | 
						|
        if command in ["nodejs", "node", "npm"]:
 | 
						|
            msg = "Please install Node.js and npm before continuing installation. You may be able to install Node.js from your package manager, from conda, or directly from the Node.js website (https://nodejs.org)."
 | 
						|
            raise ValueError(msg)
 | 
						|
        raise ValueError("The command was not found or was not " + "executable: %s." % command)
 | 
						|
    return os.path.abspath(command_with_path)
 | 
						|
 | 
						|
 | 
						|
class Process:
 | 
						|
    """A wrapper for a child process."""
 | 
						|
 | 
						|
    _procs: weakref.WeakSet = weakref.WeakSet()
 | 
						|
    _pool = None
 | 
						|
 | 
						|
    def __init__(
 | 
						|
        self,
 | 
						|
        cmd: list[str],
 | 
						|
        logger: Logger | None = None,
 | 
						|
        cwd: str | None = None,
 | 
						|
        kill_event: threading.Event | None = None,
 | 
						|
        env: dict[str, str] | None = None,
 | 
						|
        quiet: bool = False,
 | 
						|
    ) -> None:
 | 
						|
        """Start a subprocess that can be run asynchronously.
 | 
						|
 | 
						|
        Parameters
 | 
						|
        ----------
 | 
						|
        cmd: list
 | 
						|
            The command to run.
 | 
						|
        logger: :class:`~logger.Logger`, optional
 | 
						|
            The logger instance.
 | 
						|
        cwd: string, optional
 | 
						|
            The cwd of the process.
 | 
						|
        env: dict, optional
 | 
						|
            The environment for the process.
 | 
						|
        kill_event: :class:`~threading.Event`, optional
 | 
						|
            An event used to kill the process operation.
 | 
						|
        quiet: bool, optional
 | 
						|
            Whether to suppress output.
 | 
						|
        """
 | 
						|
        if not isinstance(cmd, (list, tuple)):
 | 
						|
            msg = "Command must be given as a list"  # type:ignore[unreachable]
 | 
						|
            raise ValueError(msg)
 | 
						|
 | 
						|
        if kill_event and kill_event.is_set():
 | 
						|
            msg = "Process aborted"
 | 
						|
            raise ValueError(msg)
 | 
						|
 | 
						|
        self.logger = logger or self.get_log()
 | 
						|
        self._last_line = ""
 | 
						|
        if not quiet:
 | 
						|
            self.logger.info("> %s", list2cmdline(cmd))
 | 
						|
        self.cmd = cmd
 | 
						|
 | 
						|
        kwargs = {}
 | 
						|
        if quiet:
 | 
						|
            kwargs["stdout"] = subprocess.DEVNULL
 | 
						|
 | 
						|
        self.proc = self._create_process(cwd=cwd, env=env, **kwargs)
 | 
						|
        self._kill_event = kill_event or threading.Event()
 | 
						|
 | 
						|
        Process._procs.add(self)
 | 
						|
 | 
						|
    def terminate(self) -> int:
 | 
						|
        """Terminate the process and return the exit code."""
 | 
						|
        proc = self.proc
 | 
						|
 | 
						|
        # Kill the process.
 | 
						|
        if proc.poll() is None:
 | 
						|
            os.kill(proc.pid, signal.SIGTERM)
 | 
						|
 | 
						|
        # Wait for the process to close.
 | 
						|
        try:
 | 
						|
            proc.wait(timeout=2.0)
 | 
						|
        except subprocess.TimeoutExpired:
 | 
						|
            if os.name == "nt":  # noqa: SIM108
 | 
						|
                sig = signal.SIGBREAK  # type:ignore[attr-defined]
 | 
						|
            else:
 | 
						|
                sig = signal.SIGKILL
 | 
						|
 | 
						|
            if proc.poll() is None:
 | 
						|
                os.kill(proc.pid, sig)
 | 
						|
 | 
						|
        finally:
 | 
						|
            if self in Process._procs:
 | 
						|
                Process._procs.remove(self)
 | 
						|
 | 
						|
        return proc.wait()
 | 
						|
 | 
						|
    def wait(self) -> int:
 | 
						|
        """Wait for the process to finish.
 | 
						|
 | 
						|
        Returns
 | 
						|
        -------
 | 
						|
        The process exit code.
 | 
						|
        """
 | 
						|
        proc = self.proc
 | 
						|
        kill_event = self._kill_event
 | 
						|
        while proc.poll() is None:
 | 
						|
            if kill_event.is_set():
 | 
						|
                self.terminate()
 | 
						|
                msg = "Process was aborted"
 | 
						|
                raise ValueError(msg)
 | 
						|
            time.sleep(1.0)
 | 
						|
        return self.terminate()
 | 
						|
 | 
						|
    @gen.coroutine
 | 
						|
    def wait_async(self) -> Any:
 | 
						|
        """Asynchronously wait for the process to finish."""
 | 
						|
        proc = self.proc
 | 
						|
        kill_event = self._kill_event
 | 
						|
        while proc.poll() is None:
 | 
						|
            if kill_event.is_set():
 | 
						|
                self.terminate()
 | 
						|
                msg = "Process was aborted"
 | 
						|
                raise ValueError(msg)
 | 
						|
            yield gen.sleep(1.0)
 | 
						|
 | 
						|
        raise gen.Return(self.terminate())
 | 
						|
 | 
						|
    def _create_process(self, **kwargs: Any) -> subprocess.Popen[str]:
 | 
						|
        """Create the process."""
 | 
						|
        cmd = list(self.cmd)
 | 
						|
        kwargs.setdefault("stderr", subprocess.STDOUT)
 | 
						|
 | 
						|
        cmd[0] = which(cmd[0], kwargs.get("env"))
 | 
						|
 | 
						|
        if os.name == "nt":
 | 
						|
            kwargs["shell"] = True
 | 
						|
 | 
						|
        return subprocess.Popen(cmd, **kwargs)  # noqa: S603
 | 
						|
 | 
						|
    @classmethod
 | 
						|
    def _cleanup(cls: type[Process]) -> None:
 | 
						|
        """Clean up the started subprocesses at exit."""
 | 
						|
        for proc in list(cls._procs):
 | 
						|
            proc.terminate()
 | 
						|
 | 
						|
    def get_log(self) -> Logger:
 | 
						|
        """Get our logger."""
 | 
						|
        if hasattr(self, "logger") and self.logger is not None:
 | 
						|
            return self.logger
 | 
						|
        # fallback logger
 | 
						|
        self.logger = logging.getLogger("jupyterlab")
 | 
						|
        self.logger.setLevel(logging.INFO)
 | 
						|
        return self.logger
 | 
						|
 | 
						|
 | 
						|
class WatchHelper(Process):
 | 
						|
    """A process helper for a watch process."""
 | 
						|
 | 
						|
    def __init__(
 | 
						|
        self,
 | 
						|
        cmd: list[str],
 | 
						|
        startup_regex: str,
 | 
						|
        logger: Logger | None = None,
 | 
						|
        cwd: str | None = None,
 | 
						|
        kill_event: threading.Event | None = None,
 | 
						|
        env: dict[str, str] | None = None,
 | 
						|
    ) -> None:
 | 
						|
        """Initialize the process helper.
 | 
						|
 | 
						|
        Parameters
 | 
						|
        ----------
 | 
						|
        cmd: list
 | 
						|
            The command to run.
 | 
						|
        startup_regex: string
 | 
						|
            The regex to wait for at startup.
 | 
						|
        logger: :class:`~logger.Logger`, optional
 | 
						|
            The logger instance.
 | 
						|
        cwd: string, optional
 | 
						|
            The cwd of the process.
 | 
						|
        env: dict, optional
 | 
						|
            The environment for the process.
 | 
						|
        kill_event: callable, optional
 | 
						|
            A function to call to check if we should abort.
 | 
						|
        """
 | 
						|
        super().__init__(cmd, logger=logger, cwd=cwd, kill_event=kill_event, env=env)
 | 
						|
 | 
						|
        if pty is None:
 | 
						|
            self._stdout = self.proc.stdout  # type:ignore[unreachable]
 | 
						|
 | 
						|
        while 1:
 | 
						|
            line = self._stdout.readline().decode("utf-8")  # type:ignore[has-type]
 | 
						|
            if not line:
 | 
						|
                msg = "Process ended improperly"
 | 
						|
                raise RuntimeError(msg)
 | 
						|
            print(line.rstrip())
 | 
						|
            if re.match(startup_regex, line):
 | 
						|
                break
 | 
						|
 | 
						|
        self._read_thread = threading.Thread(target=self._read_incoming, daemon=True)
 | 
						|
        self._read_thread.start()
 | 
						|
 | 
						|
    def terminate(self) -> int:
 | 
						|
        """Terminate the process."""
 | 
						|
        proc = self.proc
 | 
						|
 | 
						|
        if proc.poll() is None:
 | 
						|
            if os.name != "nt":
 | 
						|
                # Kill the process group if we started a new session.
 | 
						|
                os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
 | 
						|
            else:
 | 
						|
                os.kill(proc.pid, signal.SIGTERM)
 | 
						|
 | 
						|
        # Wait for the process to close.
 | 
						|
        try:
 | 
						|
            proc.wait()
 | 
						|
        finally:
 | 
						|
            if self in Process._procs:
 | 
						|
                Process._procs.remove(self)
 | 
						|
 | 
						|
        return proc.returncode
 | 
						|
 | 
						|
    def _read_incoming(self) -> None:
 | 
						|
        """Run in a thread to read stdout and print"""
 | 
						|
        fileno = self._stdout.fileno()  # type:ignore[has-type]
 | 
						|
        while 1:
 | 
						|
            try:
 | 
						|
                buf = os.read(fileno, 1024)
 | 
						|
            except OSError as e:
 | 
						|
                self.logger.debug("Read incoming error %s", e)
 | 
						|
                return
 | 
						|
 | 
						|
            if not buf:
 | 
						|
                return
 | 
						|
 | 
						|
            print(buf.decode("utf-8"), end="")
 | 
						|
 | 
						|
    def _create_process(self, **kwargs: Any) -> subprocess.Popen[str]:
 | 
						|
        """Create the watcher helper process."""
 | 
						|
        kwargs["bufsize"] = 0
 | 
						|
 | 
						|
        if pty is not None:
 | 
						|
            master, slave = pty.openpty()
 | 
						|
            kwargs["stderr"] = kwargs["stdout"] = slave
 | 
						|
            kwargs["start_new_session"] = True
 | 
						|
            self._stdout = os.fdopen(master, "rb")  # type:ignore[has-type]
 | 
						|
        else:
 | 
						|
            kwargs["stdout"] = subprocess.PIPE  # type:ignore[unreachable]
 | 
						|
 | 
						|
            if os.name == "nt":
 | 
						|
                startupinfo = subprocess.STARTUPINFO()
 | 
						|
                startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
 | 
						|
                kwargs["startupinfo"] = startupinfo
 | 
						|
                kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
 | 
						|
                kwargs["shell"] = True
 | 
						|
 | 
						|
        return super()._create_process(**kwargs)
 | 
						|
 | 
						|
 | 
						|
# Register the cleanup handler.
 | 
						|
atexit.register(Process._cleanup)
 |