mirror of
https://github.com/jupyterhub/the-littlest-jupyterhub.git
synced 2025-12-18 21:54:05 +08:00
Merge pull request #127 from yuvipanda/pluggy
Add plugin support to the installer
This commit is contained in:
@@ -68,10 +68,16 @@ jobs:
|
|||||||
.circleci/integration-test.py build-image
|
.circleci/integration-test.py build-image
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Run basic tests tests
|
name: Run basic tests
|
||||||
command: |
|
command: |
|
||||||
.circleci/integration-test.py run-test basic-tests test_hub.py test_install.py test_extensions.py
|
.circleci/integration-test.py run-test basic-tests test_hub.py test_install.py test_extensions.py
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: Run plugin tests
|
||||||
|
command: |
|
||||||
|
.circleci/integration-test.py run-test \
|
||||||
|
--installer-args "--plugin /srv/src/integration-tests/plugins/simplest" \
|
||||||
|
plugins test_simplest_plugin.py
|
||||||
|
|
||||||
|
|
||||||
documentation:
|
documentation:
|
||||||
|
|||||||
@@ -57,12 +57,7 @@ def run_container_command(container_name, cmd):
|
|||||||
'docker', 'exec',
|
'docker', 'exec',
|
||||||
'-it', container_name,
|
'-it', container_name,
|
||||||
'/bin/bash', '-c', cmd
|
'/bin/bash', '-c', cmd
|
||||||
])
|
], check=True)
|
||||||
|
|
||||||
if proc.returncode != 0:
|
|
||||||
# Don't throw if command fails. This lets us continue next parts
|
|
||||||
# of tests. Not entirely sure this is the right thing to do though!
|
|
||||||
print(f'command {cmd} exited with return code {proc.returncode}')
|
|
||||||
|
|
||||||
|
|
||||||
def copy_to_container(container_name, src_path, dest_path):
|
def copy_to_container(container_name, src_path, dest_path):
|
||||||
|
|||||||
119
docs/contributing/plugins.rst
Normal file
119
docs/contributing/plugins.rst
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
.. _contributing/plugins:
|
||||||
|
|
||||||
|
============
|
||||||
|
TLJH Plugins
|
||||||
|
============
|
||||||
|
|
||||||
|
TLJH plugins are the official way to make customized 'spins' or 'stacks'
|
||||||
|
with TLJH as the base. For example, the earth sciences community can make
|
||||||
|
a plugin that installs commonly used packages, set up authentication
|
||||||
|
and pre-download useful datasets. The mybinder.org community can
|
||||||
|
make a plugin that gives you a single-node, single-repository mybinder.org.
|
||||||
|
Plugins are very powerful, so the possibilities are endless.
|
||||||
|
|
||||||
|
Design
|
||||||
|
======
|
||||||
|
|
||||||
|
`pluggy <https://github.com/pytest-dev/pluggy>`_ is used to implement
|
||||||
|
plugin functionality. TLJH exposes specific **hooks** that your plugin
|
||||||
|
can provide implementations for. This allows us to have specific hook
|
||||||
|
points in the application that can be explicitly extended by plugins,
|
||||||
|
balancing the need to change TLJH internals in the future with the
|
||||||
|
stability required for a good plugin ecosystem.
|
||||||
|
|
||||||
|
Writing a simple plugins
|
||||||
|
========================
|
||||||
|
|
||||||
|
We shall try to write a simple plugin that installs a few libraries,
|
||||||
|
and use it to explain how the plugin mechanism works. We shall call
|
||||||
|
this plugin ``tljh-simple``.
|
||||||
|
|
||||||
|
Plugin directory layout
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
We recommend creating a new git repo for your plugin. Plugins are
|
||||||
|
normal python packages - however, since they are usually simpler,
|
||||||
|
we recommend they live in one file.
|
||||||
|
|
||||||
|
For ``tljh-simple``, the repository's structure should look like:
|
||||||
|
|
||||||
|
.. code-block:: none
|
||||||
|
|
||||||
|
tljh_simple:
|
||||||
|
- tljh_simple.py
|
||||||
|
- setup.py
|
||||||
|
- README.md
|
||||||
|
- LICENSE
|
||||||
|
|
||||||
|
The ``README.md`` (or ``README.rst`` file) contains human readable
|
||||||
|
information about what your plugin does for your users. ``LICENSE``
|
||||||
|
specifies the license used by your plugin - we recommend the
|
||||||
|
3-Clause BSD License, since that is what is used by TLJH itself.
|
||||||
|
|
||||||
|
``setup.py`` - metadata & registration
|
||||||
|
--------------------------------------
|
||||||
|
|
||||||
|
``setup.py`` marks this as a python package, and contains metadata
|
||||||
|
about the package itself. It should look something like:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="tljh-simple",
|
||||||
|
author="YuviPanda",
|
||||||
|
version="0.1",
|
||||||
|
license="3-clause BSD",
|
||||||
|
url='https://github.com/yuvipanda/tljh-simple',
|
||||||
|
entry_points={"tljh": ["simple = tljh_simple"]},
|
||||||
|
py_modules=["tljh_simple"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
This is a mostly standard ``setup.py`` file. ``entry_points={"tljh": ["simple = tljh_simple]}``
|
||||||
|
'registers' the module ``tljh_simple`` (in file ``tljh_simple.py``) with TLJH as a plugin.
|
||||||
|
|
||||||
|
``tljh_simple.py`` - implementation
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
In ``tljh_simple.py``, you provide implementations for whichever hooks
|
||||||
|
you want to extend.
|
||||||
|
|
||||||
|
A hook implementation is a function that has the following characteristics:
|
||||||
|
|
||||||
|
#. Has same name as the hook
|
||||||
|
#. Accepts some or all of the parameters defined for the hook
|
||||||
|
#. Is decorated with the ``hookimpl`` decorator function, imported from
|
||||||
|
``tljh.hooks``.
|
||||||
|
|
||||||
|
The current list of available hooks and when they are called can be
|
||||||
|
seen in ```tljh/hooks.py`` <https://github.com/jupyterhub/the-littlest-jupyterhub/blob/master/tljh/hooks.py>`_
|
||||||
|
in the source repository.
|
||||||
|
|
||||||
|
|
||||||
|
This example provides an implementation for the ``tljh_extra_user_conda_packages``
|
||||||
|
hook, which can return a list of conda packages that'll be installed in users'
|
||||||
|
environment from conda-forge.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from tljh.hooks import hookimpl
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def tljh_extra_user_conda_packages():
|
||||||
|
return [
|
||||||
|
'xarray',
|
||||||
|
'iris',
|
||||||
|
'dask',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
Publishing plugins
|
||||||
|
==================
|
||||||
|
|
||||||
|
Plugins are python packages and should be published on PyPI. Users
|
||||||
|
can also install them directly from GitHub - although this is
|
||||||
|
not good long term practice.
|
||||||
|
|
||||||
|
The python package should be named ``tljh-<pluginname>``.
|
||||||
@@ -127,3 +127,4 @@ to people contributing in various ways.
|
|||||||
contributing/code-review
|
contributing/code-review
|
||||||
contributing/dev-setup
|
contributing/dev-setup
|
||||||
contributing/tests
|
contributing/tests
|
||||||
|
contributing/plugins
|
||||||
@@ -56,3 +56,32 @@ will fail.
|
|||||||
|
|
||||||
When pointing to a file on GitHub, make sure to use the 'Raw' version. It should point to
|
When pointing to a file on GitHub, make sure to use the 'Raw' version. It should point to
|
||||||
``raw.githubusercontent.com``, not ``github.com``.
|
``raw.githubusercontent.com``, not ``github.com``.
|
||||||
|
|
||||||
|
Installing TLJH plugins
|
||||||
|
=======================
|
||||||
|
|
||||||
|
The Littlest JupyterHub can install additional *plugins* that provide additional
|
||||||
|
features. They are most commonly used to install a particular *stack* - such as
|
||||||
|
the `PANGEO Stack <https://github.com/yuvipanda/tljh-pangeo>`_ for earth sciences
|
||||||
|
research, a stack for a praticular class, etc.
|
||||||
|
|
||||||
|
``--plugin <plugin-to-install>`` installs and activates a plugin. You can pass it
|
||||||
|
however many times you want. Since plugins are distributed as python packages,
|
||||||
|
``<plugin-to-install>`` can be anything that can be passed to ``pip install`` -
|
||||||
|
``plugin-name-on-pypy==<version>`` and ``git+https://github.com/user/repo@tag``
|
||||||
|
are the most popular ones. Specifying a version or tag is highly recommended.
|
||||||
|
|
||||||
|
For example, to install the PANGEO Plugin version 0.1 in your new TLJH install,
|
||||||
|
you would use:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
curl https://raw.githubusercontent.com/jupyterhub/the-littlest-jupyterhub/master/bootstrap/bootstrap.py \
|
||||||
|
| sudo python3 - \
|
||||||
|
--plugin git+https://github.com/yuvipanda/tljh-pangeo@v0.1
|
||||||
|
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Plugins are extremely powerful and can do a large number of arbitrary things.
|
||||||
|
Only install plugins you trust.
|
||||||
9
integration-tests/plugins/simplest/setup.py
Normal file
9
integration-tests/plugins/simplest/setup.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="tljh-simplest",
|
||||||
|
entry_points={"tljh": ["simplest = tljh_simplest"]},
|
||||||
|
py_modules=["tljh_simplest"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
33
integration-tests/plugins/simplest/tljh_simplest.py
Normal file
33
integration-tests/plugins/simplest/tljh_simplest.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
Simplest plugin that excercises all the hooks
|
||||||
|
"""
|
||||||
|
from tljh.hooks import hookimpl
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def tljh_extra_user_conda_packages():
|
||||||
|
return [
|
||||||
|
'hypothesis',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def tljh_extra_user_pip_packages():
|
||||||
|
return [
|
||||||
|
'django',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def tljh_extra_apt_packages():
|
||||||
|
return [
|
||||||
|
'sl',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def tljh_config_post_install(config):
|
||||||
|
# Put an arbitrary marker we can test for
|
||||||
|
config['simplest_plugin'] = {
|
||||||
|
'present': True
|
||||||
|
}
|
||||||
47
integration-tests/test_simplest_plugin.py
Normal file
47
integration-tests/test_simplest_plugin.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""
|
||||||
|
Test simplest plugin
|
||||||
|
"""
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
yaml = YAML(typ='rt')
|
||||||
|
|
||||||
|
|
||||||
|
def test_apt_packages():
|
||||||
|
"""
|
||||||
|
Test extra apt packages are installed
|
||||||
|
"""
|
||||||
|
assert os.path.exists('/usr/games/sl')
|
||||||
|
|
||||||
|
|
||||||
|
def test_pip_packages():
|
||||||
|
"""
|
||||||
|
Test extra user pip packages are installed
|
||||||
|
"""
|
||||||
|
subprocess.check_call([
|
||||||
|
'/opt/tljh/user/bin/python3',
|
||||||
|
'-c',
|
||||||
|
'import django'
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def test_conda_packages():
|
||||||
|
"""
|
||||||
|
Test extra user conda packages are installed
|
||||||
|
"""
|
||||||
|
subprocess.check_call([
|
||||||
|
'/opt/tljh/user/bin/python3',
|
||||||
|
'-c',
|
||||||
|
'import hypothesis'
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_hook():
|
||||||
|
"""
|
||||||
|
Check config changes are present
|
||||||
|
"""
|
||||||
|
with open('/opt/tljh/config.yaml') as f:
|
||||||
|
data = yaml.load(f)
|
||||||
|
|
||||||
|
assert data['simplest_plugin']['present']
|
||||||
1
setup.py
1
setup.py
@@ -14,6 +14,7 @@ setup(
|
|||||||
'pyyaml==3.*',
|
'pyyaml==3.*',
|
||||||
'ruamel.yaml==0.15.*',
|
'ruamel.yaml==0.15.*',
|
||||||
'jinja2',
|
'jinja2',
|
||||||
|
'pluggy>0.7<1.0'
|
||||||
],
|
],
|
||||||
entry_points={
|
entry_points={
|
||||||
'console_scripts': [
|
'console_scripts': [
|
||||||
|
|||||||
46
tljh/hooks.py
Normal file
46
tljh/hooks.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Hook specifications that pluggy plugins can override
|
||||||
|
"""
|
||||||
|
import pluggy
|
||||||
|
|
||||||
|
hookspec = pluggy.HookspecMarker('tljh')
|
||||||
|
hookimpl = pluggy.HookimplMarker('tljh')
|
||||||
|
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def tljh_extra_user_conda_packages():
|
||||||
|
"""
|
||||||
|
Return list of extra conda packages to install in user environment.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def tljh_extra_user_pip_packages():
|
||||||
|
"""
|
||||||
|
Return list of extra pip packages to install in user environment.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def tljh_extra_apt_packages():
|
||||||
|
"""
|
||||||
|
Return list of extra apt packages to install in the user environment.
|
||||||
|
|
||||||
|
These will be installed before additional pip or conda packages.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def tljh_config_post_install(config):
|
||||||
|
"""
|
||||||
|
Modify on-disk tljh-config after installation.
|
||||||
|
|
||||||
|
config is a dict-like object that should be modified
|
||||||
|
in-place. The contents of the on-disk config.yaml will
|
||||||
|
be the serialized contents of config, so try to not
|
||||||
|
overwrite anything the user might have explicitly set.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
@@ -2,16 +2,18 @@ import argparse
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import itertools
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from urllib.error import HTTPError
|
from urllib.error import HTTPError
|
||||||
from urllib.request import urlopen, URLError
|
from urllib.request import urlopen, URLError
|
||||||
|
import pluggy
|
||||||
|
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
from tljh import conda, systemd, traefik, user, apt
|
from tljh import conda, systemd, traefik, user, apt, hooks
|
||||||
from tljh.config import INSTALL_PREFIX, HUB_ENV_PREFIX, USER_ENV_PREFIX, STATE_DIR
|
from tljh.config import INSTALL_PREFIX, HUB_ENV_PREFIX, USER_ENV_PREFIX, STATE_DIR, CONFIG_FILE
|
||||||
|
|
||||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
@@ -306,6 +308,68 @@ def ensure_symlinks(prefix):
|
|||||||
os.symlink(tljh_config_src, tljh_config_dest)
|
os.symlink(tljh_config_src, tljh_config_dest)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_plugins(plugins):
|
||||||
|
"""
|
||||||
|
Install plugins & setup a pluginmanager
|
||||||
|
"""
|
||||||
|
# Install plugins
|
||||||
|
if plugins:
|
||||||
|
conda.ensure_pip_packages(HUB_ENV_PREFIX, plugins)
|
||||||
|
|
||||||
|
# Set up plugin infrastructure
|
||||||
|
pm = pluggy.PluginManager('tljh')
|
||||||
|
pm.add_hookspecs(hooks)
|
||||||
|
pm.load_setuptools_entrypoints('tljh')
|
||||||
|
|
||||||
|
return pm
|
||||||
|
|
||||||
|
def run_plugin_actions(plugin_manager, plugins):
|
||||||
|
"""
|
||||||
|
Run installer hooks defined in plugins
|
||||||
|
"""
|
||||||
|
hook = plugin_manager.hook
|
||||||
|
# Install apt packages
|
||||||
|
apt_packages = list(set(itertools.chain(*hook.tljh_extra_apt_packages())))
|
||||||
|
if apt_packages:
|
||||||
|
logger.info('Installing {} apt packages collected from plugins: {}'.format(
|
||||||
|
len(apt_packages), ' '.join(apt_packages)
|
||||||
|
))
|
||||||
|
apt.install_packages(apt_packages)
|
||||||
|
|
||||||
|
# Install conda packages
|
||||||
|
conda_packages = list(set(itertools.chain(*hook.tljh_extra_user_conda_packages())))
|
||||||
|
if conda_packages:
|
||||||
|
logger.info('Installing {} conda packages collected from plugins: {}'.format(
|
||||||
|
len(conda_packages), ' '.join(conda_packages)
|
||||||
|
))
|
||||||
|
conda.ensure_conda_packages(USER_ENV_PREFIX, conda_packages)
|
||||||
|
|
||||||
|
# Install pip packages
|
||||||
|
pip_packages = list(set(itertools.chain(*hook.tljh_extra_user_pip_packages())))
|
||||||
|
if pip_packages:
|
||||||
|
logger.info('Installing {} pip packages collected from plugins: {}'.format(
|
||||||
|
len(pip_packages), ' '.join(pip_packages)
|
||||||
|
))
|
||||||
|
conda.ensure_pip_packages(USER_ENV_PREFIX, pip_packages)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_config_yaml(plugin_manager):
|
||||||
|
"""
|
||||||
|
Ensure we have a config.yaml present
|
||||||
|
"""
|
||||||
|
if os.path.exists(CONFIG_FILE):
|
||||||
|
with open(CONFIG_FILE, 'r') as f:
|
||||||
|
config = rt_yaml.load(f)
|
||||||
|
else:
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
hook = plugin_manager.hook
|
||||||
|
hook.tljh_config_post_install(config=config)
|
||||||
|
|
||||||
|
with open(CONFIG_FILE, 'w+') as f:
|
||||||
|
rt_yaml.dump(config, f)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
argparser = argparse.ArgumentParser()
|
argparser = argparse.ArgumentParser()
|
||||||
argparser.add_argument(
|
argparser.add_argument(
|
||||||
@@ -317,10 +381,15 @@ def main():
|
|||||||
'--user-requirements-txt-url',
|
'--user-requirements-txt-url',
|
||||||
help='URL to a requirements.txt file that should be installed in the user enviornment'
|
help='URL to a requirements.txt file that should be installed in the user enviornment'
|
||||||
)
|
)
|
||||||
|
argparser.add_argument(
|
||||||
|
'--plugin',
|
||||||
|
nargs='*',
|
||||||
|
help='Plugin pip-specs to install'
|
||||||
|
)
|
||||||
|
|
||||||
args = argparser.parse_args()
|
args = argparser.parse_args()
|
||||||
|
|
||||||
|
pm = setup_plugins(args.plugin)
|
||||||
|
|
||||||
ensure_admins(args.admin)
|
ensure_admins(args.admin)
|
||||||
|
|
||||||
@@ -331,10 +400,14 @@ def main():
|
|||||||
ensure_node()
|
ensure_node()
|
||||||
ensure_jupyterhub_package(HUB_ENV_PREFIX)
|
ensure_jupyterhub_package(HUB_ENV_PREFIX)
|
||||||
ensure_chp_package(HUB_ENV_PREFIX)
|
ensure_chp_package(HUB_ENV_PREFIX)
|
||||||
|
ensure_config_yaml(pm)
|
||||||
ensure_jupyterhub_service(HUB_ENV_PREFIX)
|
ensure_jupyterhub_service(HUB_ENV_PREFIX)
|
||||||
ensure_jupyterhub_running()
|
ensure_jupyterhub_running()
|
||||||
ensure_symlinks(HUB_ENV_PREFIX)
|
ensure_symlinks(HUB_ENV_PREFIX)
|
||||||
|
|
||||||
|
# Run installer plugins last
|
||||||
|
run_plugin_actions(pm, args.plugin)
|
||||||
|
|
||||||
logger.info("Done!")
|
logger.info("Done!")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user