first commit

This commit is contained in:
Ayxan
2022-05-23 00:16:32 +04:00
commit d660f2a4ca
24786 changed files with 4428337 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from .factory import KernelProvisionerFactory # noqa
from .local_provisioner import LocalProvisioner # noqa
from .provisioner_base import KernelProvisionerBase # noqa

View File

@@ -0,0 +1,201 @@
"""Kernel Provisioner Classes"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import glob
from os import getenv
from os import path
from typing import Any
from typing import Dict
from typing import List
from entrypoints import EntryPoint
from entrypoints import get_group_all
from entrypoints import get_single
from entrypoints import NoSuchEntryPoint
from traitlets.config import default
from traitlets.config import SingletonConfigurable
from traitlets.config import Unicode
from .provisioner_base import KernelProvisionerBase
class KernelProvisionerFactory(SingletonConfigurable):
"""
:class:`KernelProvisionerFactory` is responsible for creating provisioner instances.
A singleton instance, `KernelProvisionerFactory` is also used by the :class:`KernelSpecManager`
to validate `kernel_provisioner` references found in kernel specifications to confirm their
availability (in cases where the kernel specification references a kernel provisioner that has
not been installed into the current Python environment).
It's `default_provisioner_name` attribute can be used to specify the default provisioner
to use when a kernel_spec is found to not reference a provisioner. It's value defaults to
`"local-provisioner"` which identifies the local provisioner implemented by
:class:`LocalProvisioner`.
"""
GROUP_NAME = 'jupyter_client.kernel_provisioners'
provisioners: Dict[str, EntryPoint] = {}
default_provisioner_name_env = "JUPYTER_DEFAULT_PROVISIONER_NAME"
default_provisioner_name = Unicode(
config=True,
help="""Indicates the name of the provisioner to use when no kernel_provisioner
entry is present in the kernelspec.""",
)
@default('default_provisioner_name')
def default_provisioner_name_default(self):
return getenv(self.default_provisioner_name_env, "local-provisioner")
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
for ep in KernelProvisionerFactory._get_all_provisioners():
self.provisioners[ep.name] = ep
def is_provisioner_available(self, kernel_spec: Any) -> bool:
"""
Reads the associated ``kernel_spec`` to determine the provisioner and returns whether it
exists as an entry_point (True) or not (False). If the referenced provisioner is not
in the current cache or cannot be loaded via entry_points, a warning message is issued
indicating it is not available.
"""
is_available: bool = True
provisioner_cfg = self._get_provisioner_config(kernel_spec)
provisioner_name = str(provisioner_cfg.get('provisioner_name'))
if not self._check_availability(provisioner_name):
is_available = False
self.log.warning(
f"Kernel '{kernel_spec.display_name}' is referencing a kernel "
f"provisioner ('{provisioner_name}') that is not available. "
f"Ensure the appropriate package has been installed and retry."
)
return is_available
def create_provisioner_instance(
self, kernel_id: str, kernel_spec: Any, parent: Any
) -> KernelProvisionerBase:
"""
Reads the associated ``kernel_spec`` to see if it has a `kernel_provisioner` stanza.
If one exists, it instantiates an instance. If a kernel provisioner is not
specified in the kernel specification, a default provisioner stanza is fabricated
and instantiated corresponding to the current value of `default_provisioner_name` trait.
The instantiated instance is returned.
If the provisioner is found to not exist (not registered via entry_points),
`ModuleNotFoundError` is raised.
"""
provisioner_cfg = self._get_provisioner_config(kernel_spec)
provisioner_name = str(provisioner_cfg.get('provisioner_name'))
if not self._check_availability(provisioner_name):
raise ModuleNotFoundError(
f"Kernel provisioner '{provisioner_name}' has not been registered."
)
self.log.debug(
f"Instantiating kernel '{kernel_spec.display_name}' with "
f"kernel provisioner: {provisioner_name}"
)
provisioner_class = self.provisioners[provisioner_name].load()
provisioner_config = provisioner_cfg.get('config')
provisioner: KernelProvisionerBase = provisioner_class(
kernel_id=kernel_id, kernel_spec=kernel_spec, parent=parent, **provisioner_config
)
return provisioner
def _check_availability(self, provisioner_name: str) -> bool:
"""
Checks that the given provisioner is available.
If the given provisioner is not in the current set of loaded provisioners an attempt
is made to fetch the named entry point and, if successful, loads it into the cache.
:param provisioner_name:
:return:
"""
is_available = True
if provisioner_name not in self.provisioners:
try:
ep = self._get_provisioner(provisioner_name)
self.provisioners[provisioner_name] = ep # Update cache
except NoSuchEntryPoint:
is_available = False
return is_available
def _get_provisioner_config(self, kernel_spec: Any) -> Dict[str, Any]:
"""
Return the kernel_provisioner stanza from the kernel_spec.
Checks the kernel_spec's metadata dictionary for a kernel_provisioner entry.
If found, it is returned, else one is created relative to the DEFAULT_PROVISIONER
and returned.
Parameters
----------
kernel_spec : Any - this is a KernelSpec type but listed as Any to avoid circular import
The kernel specification object from which the provisioner dictionary is derived.
Returns
-------
dict
The provisioner portion of the kernel_spec. If one does not exist, it will contain
the default information. If no `config` sub-dictionary exists, an empty `config`
dictionary will be added.
"""
env_provisioner = kernel_spec.metadata.get('kernel_provisioner', {})
if 'provisioner_name' in env_provisioner: # If no provisioner_name, return default
if (
'config' not in env_provisioner
): # if provisioner_name, but no config stanza, add one
env_provisioner.update({"config": {}})
return env_provisioner # Return what we found (plus config stanza if necessary)
return {"provisioner_name": self.default_provisioner_name, "config": {}}
def get_provisioner_entries(self) -> Dict[str, str]:
"""
Returns a dictionary of provisioner entries.
The key is the provisioner name for its entry point. The value is the colon-separated
string of the entry point's module name and object name.
"""
entries = {}
for name, ep in self.provisioners.items():
entries[name] = f"{ep.module_name}:{ep.object_name}"
return entries
@staticmethod
def _get_all_provisioners() -> List[EntryPoint]:
"""Wrapper around entrypoints.get_group_all() - primarily to facilitate testing."""
return get_group_all(KernelProvisionerFactory.GROUP_NAME)
def _get_provisioner(self, name: str) -> EntryPoint:
"""Wrapper around entrypoints.get_single() - primarily to facilitate testing."""
try:
ep = get_single(KernelProvisionerFactory.GROUP_NAME, name)
except NoSuchEntryPoint:
# Check if the entrypoint name is 'local-provisioner'. Although this should never
# happen, we have seen cases where the previous distribution of jupyter_client has
# remained which doesn't include kernel-provisioner entrypoints (so 'local-provisioner'
# is deemed not found even though its definition is in THIS package). In such cass,
# the entrypoints package uses what it first finds - which is the older distribution
# resulting in a violation of a supposed invariant condition. To address this scenario,
# we will log a warning message indicating this situation, then build the entrypoint
# instance ourselves - since we have that information.
if name == 'local-provisioner':
distros = glob.glob(f"{path.dirname(path.dirname(__file__))}-*")
self.log.warning(
f"Kernel Provisioning: The 'local-provisioner' is not found. This is likely "
f"due to the presence of multiple jupyter_client distributions and a previous "
f"distribution is being used as the source for entrypoints - which does not "
f"include 'local-provisioner'. That distribution should be removed such that "
f"only the version-appropriate distribution remains (version >= 7). Until "
f"then, a 'local-provisioner' entrypoint will be automatically constructed "
f"and used.\nThe candidate distribution locations are: {distros}"
)
ep = EntryPoint(
'local-provisioner', 'jupyter_client.provisioning', 'LocalProvisioner'
)
else:
raise
return ep

View File

@@ -0,0 +1,236 @@
"""Kernel Provisioner Classes"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import asyncio
import os
import signal
import sys
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from ..connect import KernelConnectionInfo
from ..connect import LocalPortCache
from ..launcher import launch_kernel
from ..localinterfaces import is_local_ip
from ..localinterfaces import local_ips
from .provisioner_base import KernelProvisionerBase
class LocalProvisioner(KernelProvisionerBase):
"""
:class:`LocalProvisioner` is a concrete class of ABC :py:class:`KernelProvisionerBase`
and is the out-of-box default implementation used when no kernel provisioner is
specified in the kernel specification (``kernel.json``). It provides functional
parity to existing applications by launching the kernel locally and using
:class:`subprocess.Popen` to manage its lifecycle.
This class is intended to be subclassed for customizing local kernel environments
and serve as a reference implementation for other custom provisioners.
"""
process = None
_exit_future = None
pid = None
pgid = None
ip = None
ports_cached = False
@property
def has_process(self) -> bool:
return self.process is not None
async def poll(self) -> Optional[int]:
ret = 0
if self.process:
ret = self.process.poll()
return ret
async def wait(self) -> Optional[int]:
ret = 0
if self.process:
# Use busy loop at 100ms intervals, polling until the process is
# not alive. If we find the process is no longer alive, complete
# its cleanup via the blocking wait(). Callers are responsible for
# issuing calls to wait() using a timeout (see kill()).
while await self.poll() is None:
await asyncio.sleep(0.1)
# Process is no longer alive, wait and clear
ret = self.process.wait()
# Make sure all the fds get closed.
for attr in ['stdout', 'stderr', 'stdin']:
fid = getattr(self.process, attr)
if fid:
fid.close()
self.process = None # allow has_process to now return False
return ret
async def send_signal(self, signum: int) -> None:
"""Sends a signal to the process group of the kernel (this
usually includes the kernel and any subprocesses spawned by
the kernel).
Note that since only SIGTERM is supported on Windows, we will
check if the desired signal is for interrupt and apply the
applicable code on Windows in that case.
"""
if self.process:
if signum == signal.SIGINT and sys.platform == 'win32':
from ..win_interrupt import send_interrupt
send_interrupt(self.process.win32_interrupt_event)
return
# Prefer process-group over process
if self.pgid and hasattr(os, "killpg"):
try:
os.killpg(self.pgid, signum)
return
except OSError:
pass # We'll retry sending the signal to only the process below
# If we're here, send the signal to the process and let caller handle exceptions
self.process.send_signal(signum)
return
async def kill(self, restart: bool = False) -> None:
if self.process:
if hasattr(signal, "SIGKILL"):
# If available, give preference to signalling the process-group over `kill()`.
try:
await self.send_signal(signal.SIGKILL)
return
except OSError:
pass
try:
self.process.kill()
except OSError as e:
LocalProvisioner._tolerate_no_process(e)
async def terminate(self, restart: bool = False) -> None:
if self.process:
if hasattr(signal, "SIGTERM"):
# If available, give preference to signalling the process group over `terminate()`.
try:
await self.send_signal(signal.SIGTERM)
return
except OSError:
pass
try:
self.process.terminate()
except OSError as e:
LocalProvisioner._tolerate_no_process(e)
@staticmethod
def _tolerate_no_process(os_error: OSError) -> None:
# In Windows, we will get an Access Denied error if the process
# has already terminated. Ignore it.
if sys.platform == 'win32':
if os_error.winerror != 5:
raise
# On Unix, we may get an ESRCH error (or ProcessLookupError instance) if
# the process has already terminated. Ignore it.
else:
from errno import ESRCH
if not isinstance(os_error, ProcessLookupError) or os_error.errno != ESRCH:
raise
async def cleanup(self, restart: bool = False) -> None:
if self.ports_cached and not restart:
# provisioner is about to be destroyed, return cached ports
lpc = LocalPortCache.instance()
ports = (
self.connection_info['shell_port'],
self.connection_info['iopub_port'],
self.connection_info['stdin_port'],
self.connection_info['hb_port'],
self.connection_info['control_port'],
)
for port in ports:
lpc.return_port(port)
async def pre_launch(self, **kwargs: Any) -> Dict[str, Any]:
"""Perform any steps in preparation for kernel process launch.
This includes applying additional substitutions to the kernel launch command and env.
It also includes preparation of launch parameters.
Returns the updated kwargs.
"""
# This should be considered temporary until a better division of labor can be defined.
km = self.parent
if km:
if km.transport == 'tcp' and not is_local_ip(km.ip):
raise RuntimeError(
"Can only launch a kernel on a local interface. "
"This one is not: %s."
"Make sure that the '*_address' attributes are "
"configured properly. "
"Currently valid addresses are: %s" % (km.ip, local_ips())
)
# build the Popen cmd
extra_arguments = kwargs.pop('extra_arguments', [])
# write connection file / get default ports
# TODO - change when handshake pattern is adopted
if km.cache_ports and not self.ports_cached:
lpc = LocalPortCache.instance()
km.shell_port = lpc.find_available_port(km.ip)
km.iopub_port = lpc.find_available_port(km.ip)
km.stdin_port = lpc.find_available_port(km.ip)
km.hb_port = lpc.find_available_port(km.ip)
km.control_port = lpc.find_available_port(km.ip)
self.ports_cached = True
km.write_connection_file()
self.connection_info = km.get_connection_info()
kernel_cmd = km.format_kernel_cmd(
extra_arguments=extra_arguments
) # This needs to remain here for b/c
else:
extra_arguments = kwargs.pop('extra_arguments', [])
kernel_cmd = self.kernel_spec.argv + extra_arguments
return await super().pre_launch(cmd=kernel_cmd, **kwargs)
async def launch_kernel(self, cmd: List[str], **kwargs: Any) -> KernelConnectionInfo:
scrubbed_kwargs = LocalProvisioner._scrub_kwargs(kwargs)
self.process = launch_kernel(cmd, **scrubbed_kwargs)
pgid = None
if hasattr(os, "getpgid"):
try:
pgid = os.getpgid(self.process.pid)
except OSError:
pass
self.pid = self.process.pid
self.pgid = pgid
return self.connection_info
@staticmethod
def _scrub_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]:
"""Remove any keyword arguments that Popen does not tolerate."""
keywords_to_scrub: List[str] = ['extra_arguments', 'kernel_id']
scrubbed_kwargs = kwargs.copy()
for kw in keywords_to_scrub:
scrubbed_kwargs.pop(kw, None)
return scrubbed_kwargs
async def get_provisioner_info(self) -> Dict:
"""Captures the base information necessary for persistence relative to this instance."""
provisioner_info = await super().get_provisioner_info()
provisioner_info.update({'pid': self.pid, 'pgid': self.pgid, 'ip': self.ip})
return provisioner_info
async def load_provisioner_info(self, provisioner_info: Dict) -> None:
"""Loads the base information necessary for persistence relative to this instance."""
await super().load_provisioner_info(provisioner_info)
self.pid = provisioner_info['pid']
self.pgid = provisioner_info['pgid']
self.ip = provisioner_info['ip']

View File

@@ -0,0 +1,262 @@
"""Kernel Provisioner Classes"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import os
from abc import ABC
from abc import ABCMeta
from abc import abstractmethod
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from traitlets.config import Instance
from traitlets.config import LoggingConfigurable
from traitlets.config import Unicode
from ..connect import KernelConnectionInfo
class KernelProvisionerMeta(ABCMeta, type(LoggingConfigurable)): # type: ignore
pass
class KernelProvisionerBase(ABC, LoggingConfigurable, metaclass=KernelProvisionerMeta):
"""
Abstract base class defining methods for KernelProvisioner classes.
A majority of methods are abstract (requiring implementations via a subclass) while
some are optional and others provide implementations common to all instances.
Subclasses should be aware of which methods require a call to the superclass.
Many of these methods model those of :class:`subprocess.Popen` for parity with
previous versions where the kernel process was managed directly.
"""
# The kernel specification associated with this provisioner
kernel_spec: Any = Instance('jupyter_client.kernelspec.KernelSpec', allow_none=True)
kernel_id: str = Unicode(None, allow_none=True)
connection_info: KernelConnectionInfo = {}
@property
@abstractmethod
def has_process(self) -> bool:
"""
Returns true if this provisioner is currently managing a process.
This property is asserted to be True immediately following a call to
the provisioner's :meth:`launch_kernel` method.
"""
pass
@abstractmethod
async def poll(self) -> Optional[int]:
"""
Checks if kernel process is still running.
If running, None is returned, otherwise the process's integer-valued exit code is returned.
This method is called from :meth:`KernelManager.is_alive`.
"""
pass
@abstractmethod
async def wait(self) -> Optional[int]:
"""
Waits for kernel process to terminate.
This method is called from `KernelManager.finish_shutdown()` and
`KernelManager.kill_kernel()` when terminating a kernel gracefully or
immediately, respectively.
"""
pass
@abstractmethod
async def send_signal(self, signum: int) -> None:
"""
Sends signal identified by signum to the kernel process.
This method is called from `KernelManager.signal_kernel()` to send the
kernel process a signal.
"""
pass
@abstractmethod
async def kill(self, restart: bool = False) -> None:
"""
Kill the kernel process.
This is typically accomplished via a SIGKILL signal, which cannot be caught.
This method is called from `KernelManager.kill_kernel()` when terminating
a kernel immediately.
restart is True if this operation will precede a subsequent launch_kernel request.
"""
pass
@abstractmethod
async def terminate(self, restart: bool = False) -> None:
"""
Terminates the kernel process.
This is typically accomplished via a SIGTERM signal, which can be caught, allowing
the kernel provisioner to perform possible cleanup of resources. This method is
called indirectly from `KernelManager.finish_shutdown()` during a kernel's
graceful termination.
restart is True if this operation precedes a start launch_kernel request.
"""
pass
@abstractmethod
async def launch_kernel(self, cmd: List[str], **kwargs: Any) -> KernelConnectionInfo:
"""
Launch the kernel process and return its connection information.
This method is called from `KernelManager.launch_kernel()` during the
kernel manager's start kernel sequence.
"""
pass
@abstractmethod
async def cleanup(self, restart: bool = False) -> None:
"""
Cleanup any resources allocated on behalf of the kernel provisioner.
This method is called from `KernelManager.cleanup_resources()` as part of
its shutdown kernel sequence.
restart is True if this operation precedes a start launch_kernel request.
"""
pass
async def shutdown_requested(self, restart: bool = False) -> None:
"""
Allows the provisioner to determine if the kernel's shutdown has been requested.
This method is called from `KernelManager.request_shutdown()` as part of
its shutdown sequence.
This method is optional and is primarily used in scenarios where the provisioner
may need to perform other operations in preparation for a kernel's shutdown.
"""
pass
async def pre_launch(self, **kwargs: Any) -> Dict[str, Any]:
"""
Perform any steps in preparation for kernel process launch.
This includes applying additional substitutions to the kernel launch command
and environment. It also includes preparation of launch parameters.
NOTE: Subclass implementations are advised to call this method as it applies
environment variable substitutions from the local environment and calls the
provisioner's :meth:`_finalize_env()` method to allow each provisioner the
ability to cleanup the environment variables that will be used by the kernel.
This method is called from `KernelManager.pre_start_kernel()` as part of its
start kernel sequence.
Returns the (potentially updated) keyword arguments that are passed to
:meth:`launch_kernel()`.
"""
env = kwargs.pop('env', os.environ).copy()
env.update(self.__apply_env_substitutions(env))
self._finalize_env(env)
kwargs['env'] = env
return kwargs
async def post_launch(self, **kwargs: Any) -> None:
"""
Perform any steps following the kernel process launch.
This method is called from `KernelManager.post_start_kernel()` as part of its
start kernel sequence.
"""
pass
async def get_provisioner_info(self) -> Dict[str, Any]:
"""
Captures the base information necessary for persistence relative to this instance.
This enables applications that subclass `KernelManager` to persist a kernel provisioner's
relevant information to accomplish functionality like disaster recovery or high availability
by calling this method via the kernel manager's `provisioner` attribute.
NOTE: The superclass method must always be called first to ensure proper serialization.
"""
provisioner_info: Dict[str, Any] = {}
provisioner_info['kernel_id'] = self.kernel_id
provisioner_info['connection_info'] = self.connection_info
return provisioner_info
async def load_provisioner_info(self, provisioner_info: Dict) -> None:
"""
Loads the base information necessary for persistence relative to this instance.
The inverse of `get_provisioner_info()`, this enables applications that subclass
`KernelManager` to re-establish communication with a provisioner that is managing
a (presumably) remote kernel from an entirely different process that the original
provisioner.
NOTE: The superclass method must always be called first to ensure proper deserialization.
"""
self.kernel_id = provisioner_info['kernel_id']
self.connection_info = provisioner_info['connection_info']
def get_shutdown_wait_time(self, recommended: float = 5.0) -> float:
"""
Returns the time allowed for a complete shutdown. This may vary by provisioner.
This method is called from `KernelManager.finish_shutdown()` during the graceful
phase of its kernel shutdown sequence.
The recommended value will typically be what is configured in the kernel manager.
"""
return recommended
def get_stable_start_time(self, recommended: float = 10.0) -> float:
"""
Returns the expected upper bound for a kernel (re-)start to complete.
This may vary by provisioner.
The recommended value will typically be what is configured in the kernel restarter.
"""
return recommended
def _finalize_env(self, env: Dict[str, str]) -> None:
"""
Ensures env is appropriate prior to launch.
This method is called from `KernelProvisionerBase.pre_launch()` during the kernel's
start sequence.
NOTE: Subclasses should be sure to call super()._finalize_env(env)
"""
if self.kernel_spec.language and self.kernel_spec.language.lower().startswith("python"):
# Don't allow PYTHONEXECUTABLE to be passed to kernel process.
# If set, it can bork all the things.
env.pop('PYTHONEXECUTABLE', None)
def __apply_env_substitutions(self, substitution_values: Dict[str, str]) -> Dict[str, str]:
"""
Walks entries in the kernelspec's env stanza and applies substitutions from current env.
This method is called from `KernelProvisionerBase.pre_launch()` during the kernel's
start sequence.
Returns the substituted list of env entries.
NOTE: This method is private and is not intended to be overridden by provisioners.
"""
substituted_env = {}
if self.kernel_spec:
from string import Template
# For each templated env entry, fill any templated references
# matching names of env variables with those values and build
# new dict with substitutions.
templated_env = self.kernel_spec.env
for k, v in templated_env.items():
substituted_env.update({k: Template(v).safe_substitute(substitution_values)})
return substituted_env