Repository: xonsh/xonsh
Pull request: xontrib load/unload
<!--- Thanks for opening a PR on xonsh! Please include a news entry with your PR
to help keep our changelog up to date! There are instructions available here:
https://xon.sh/devguide.html#changelog -->

<!--- If there is specific issue / feature request that this PR is addressing,
please link to the corresponding issue by using the `#issuenumber` syntax.
Thanks again! -->

- [x] implement unloading

## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**


Problem statement:
[RFC] Feat: Improve xontrib lifecycle
Xontribs do not, as of writing, provide a mechanism for unloading. IPython has the `_unload_ipython_extension` function that is called by IPython when a user unloads an extension. It would be nice to offer the same feature here.

I propose that we follow a similar design, and add two methods:
```python3
def _load_xontrib(session):
    ...
```
and
```python3
def _unload_xontrib(session):
    ...
```

where the latter is optional.

For some transition period, we should not require `_load_xontrib` to be present in the `__init__`, but once that grace period has elapsed, any xontrib that doesn't implement at least `_load_xontrib` should fail to load.


## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**

Hints from issue discussion:

Commit message: chore: give explicit name

Patch to review:
diff --git a/docs/tutorial_xontrib.rst b/docs/tutorial_xontrib.rst
index 88956e8e02..a51b8de06d 100644
--- a/docs/tutorial_xontrib.rst
+++ b/docs/tutorial_xontrib.rst
@@ -24,15 +24,50 @@ took inspiration from for xonsh:
 * `Sphinx <http://sphinx-doc.org/>`_: Extensions are just Python modules,
   bundles some extensions with the main package, interface is a list of
   string names.
+* `IPython <https://ipython.readthedocs.io/en/stable/config/extensions/index.html>`_: Extensions are just Python modules
+  with some special functions to load/unload.
 * `Oh My Zsh <http://ohmyz.sh/>`_: Centralized registry, autoloading, and
   for a shell.
 * `ESLint <http://eslint.org/>`_: Ability to use language package manager
   to install/remove extensions.
 
-
 Structure
-==========
-Xontribs are modules written in either xonsh (``*.xsh``) or Python (``*.py``).
+================
+Xontribs are modules with some special functions written
+in either xonsh (``*.xsh``) or Python (``*.py``)  that has a couple of special functions to load and unload it.
+
+It is inspired from
+
+Here is a template:
+
+.. code-block:: python
+    from xonsh.built_ins import XonshSession
+
+    def _load_xontrib_(xsh: XonshSession, **kwargs) -> dict:
+        """
+        this function will be called when loading/reloading the xontrib.
+
+        Args:
+            xsh: the current xonsh session instance, serves as the interface to manipulate the session.
+                 This allows you to register new aliases, history backends, event listeners ...
+            **kwargs: it is empty as of now. Kept for future proofing.
+        Returns:
+            dict: this will get loaded into the current execution context
+        """
+
+    def _unload_xontrib_(xsh: XonshSession, **kwargs) -> dict:
+        """If you want your extension to be unloadable, put that logic here"""
+
+This _load_xontrib_() function is called after your extension is imported,
+and the currently active :py:class:`xonsh.built_ins.XonshSession` instance is passed as the argument.
+
+.. note::
+
+    Xontribs without ``_load_xontrib_`` are still supported.
+    But when such xontrib is loaded, variables listed
+    in ``__all__`` are placed in the current
+    execution context if defined.
+
 Normally, these are stored and found in an
 `implicit namespace package <https://www.python.org/dev/peps/pep-0420/>`_
 called ``xontrib``. However, xontribs may be placed in any package or directory
@@ -64,8 +99,7 @@ Here is a sample file system layout and what the xontrib names would be::
           |- done.py     # "mypkg.subpkg.done", full module name
 
 
-You can also use `cookiecutter <https://github.com/audreyr/cookiecutter>`_ with
-the `xontrib template <https://github.com/xonsh/xontrib-cookiecutter>`_ to easily
+You can also use the `xontrib template <https://github.com/xonsh/xontrib-cookiecutter>`_ to easily
 create the layout for your xontrib package.
 
 
@@ -73,36 +107,27 @@ Loading Xontribs
 ================
 Xontribs may be loaded in a few different ways: from the config file
 (e.g. ``~/.config/xonsh/rc.xsh``), dynamically at runtime with
-the ``xontrib`` command, or by importing the
-module normally. Since these extensions are just Python modules, by
-default, they cannot be unloaded (easily).
-
-.. note::
-
-    When a xontrib is loaded its public variables are placed in the current
-    execution context unless ``__all__`` is defined, just like in regular Python
-    modules.
+the ``xontrib`` command, or its Python API.
 
-Extensions are loaded via the ``xontrib`` command, which is a xonsh default
-alias. This command may be run from anywhere in a xonshrc file or at any point
-after xonsh has started up. Loading is the default action of the ``xontrib``
-command. Thus the following methods for loading via this command are equivalent:
+Extensions are loaded via the ``xontrib load`` command.
+This command may be run from anywhere in a xonshrc file or at any point
+after xonsh has started up.
 
 .. code-block:: xonsh
 
-    xontrib myext mpl mypkg.show
     xontrib load myext mpl mypkg.show
 
-Loading the same xontrib multiple times does not have any effect after the
-first. Xontribs are simply Python modules, and therefore follow the same
-caching rules. So by the same token, you can also import them normally.
-Of course, you have to use the full module name to import a xontrib:
+The same can be done in Python as well
 
 .. code-block:: python
 
-    import xontrib.mpl
-    from xontrib import myext
-    from mypkg.show import *
+    from xonsh.xontribs import xontribs_load
+    xontribs_load(['myext', 'mpl', 'mypkg.show'])
+
+A xontrib can be unloaded from the current session using ``xontrib unload``
+
+.. code-block:: xonsh
+    xontrib unload myext mpl mypkg.show
 
 
 Listing Known Xontribs
diff --git a/news/feat-xontrib-lifecycle.rst b/news/feat-xontrib-lifecycle.rst
new file mode 100644
index 0000000000..703ddbdb96
--- /dev/null
+++ b/news/feat-xontrib-lifecycle.rst
@@ -0,0 +1,26 @@
+**Added:**
+
+* Now xontribs support `loading and unloading <https://github.com/xonsh/xonsh/issues/4541>`_
+  with functions ``_load_xontrib_(xsh: XonshSession, **kwargs) -> dict``,
+  ``_unload_xontrib_(xsh: XonshSession, **kwargs) -> None`` defined in their module.
+  `Updated doc <https://xon.sh/tutorial_xontrib.html>`_
+
+**Changed:**
+
+* <news item>
+
+**Deprecated:**
+
+* <news item>
+
+**Removed:**
+
+* <news item>
+
+**Fixed:**
+
+* <news item>
+
+**Security:**
+
+* <news item>
diff --git a/tests/test_xontribs.py b/tests/test_xontribs.py
index 1fdd1c2062..8291af82be 100644
--- a/tests/test_xontribs.py
+++ b/tests/test_xontribs.py
@@ -8,6 +8,8 @@
     xontribs_load,
     xontribs_loaded,
     xontribs_main,
+    xontribs_reload,
+    xontribs_unload,
 )
 
 
@@ -96,6 +98,51 @@ def test_xontrib_load(tmpmod):
     assert "script" in xontribs_loaded()
 
 
+def test_xontrib_unload(tmpmod, xession):
+    with tmpmod.mkdir("xontrib").join("script.py").open("w") as x:
+        x.write(
+            """
+hello = 'world'
+
+def _unload_xontrib_(xsh): del xsh.ctx['hello']
+"""
+        )
+
+    xontribs_load(["script"])
+    assert "script" in xontribs_loaded()
+    assert "hello" in xession.ctx
+    xontribs_unload(["script"])
+    assert "script" not in xontribs_loaded()
+    assert "hello" not in xession.ctx
+
+
+def test_xontrib_reload(tmpmod, xession):
+    with tmpmod.mkdir("xontrib").join("script.py").open("w") as x:
+        x.write(
+            """
+hello = 'world'
+
+def _unload_xontrib_(xsh): del xsh.ctx['hello']
+"""
+        )
+
+    xontribs_load(["script"])
+    assert "script" in xontribs_loaded()
+    assert xession.ctx["hello"] == "world"
+
+    with tmpmod.join("xontrib").join("script.py").open("w") as x:
+        x.write(
+            """
+hello = 'world1'
+
+def _unload_xontrib_(xsh): del xsh.ctx['hello']
+"""
+        )
+    xontribs_reload(["script"])
+    assert "script" in xontribs_loaded()
+    assert xession.ctx["hello"] == "world1"
+
+
 def test_xontrib_load_dashed(tmpmod):
     """
     Test that .xsh xontribs are loadable
diff --git a/xonsh/events.py b/xonsh/events.py
index 86c7287a78..7f6012c52b 100644
--- a/xonsh/events.py
+++ b/xonsh/events.py
@@ -269,6 +269,21 @@ class EventManager:
     Each event is just an attribute. They're created dynamically on first use.
     """
 
+    def register(self, func):
+        """
+            wraps ``EventManager.doc``
+
+        Parameters
+        ----------
+        func
+            extract name and doc from the function
+        """
+
+        name = func.__name__
+        doc = inspect.getdoc(func)
+        sign = inspect.signature(func)
+        return self.doc(name, f"{name}{sign}\n\n{doc}")
+
     def doc(self, name, docstring):
         """
         Applies a docstring to an event.
diff --git a/xonsh/xontribs.py b/xonsh/xontribs.py
index 3939400d66..b2034e2d9a 100644
--- a/xonsh/xontribs.py
+++ b/xonsh/xontribs.py
@@ -20,6 +20,10 @@ class ExitCode(IntEnum):
     INIT_FAILED = 2
 
 
+class XontribNotInstalled(Exception):
+    """raised when the requested xontrib is not found"""
+
+
 def find_xontrib(name):
     """Finds a xontribution from its name."""
     spec = None
@@ -36,12 +40,26 @@ def xontrib_context(name):
     spec = find_xontrib(name)
     if spec is None:
         return None
-    m = importlib.import_module(spec.name)
-    pubnames = getattr(m, "__all__", None)
-    if pubnames is not None:
-        ctx = {k: getattr(m, k) for k in pubnames}
+    module = importlib.import_module(spec.name)
+    ctx = {}
+
+    def _get__all__():
+        pubnames = getattr(module, "__all__", None)
+        if pubnames is None:
+            for k in dir(module):
+                if not k.startswith("_"):
+                    yield k, getattr(module, k)
+        else:
+            for attr in pubnames:
+                yield attr, getattr(module, attr)
+
+    entrypoint = getattr(module, "_load_xontrib_", None)
+    if entrypoint is None:
+        ctx.update(dict(_get__all__()))
     else:
-        ctx = {k: getattr(m, k) for k in dir(m) if not k.startswith("_")}
+        result = entrypoint(xsh=XSH)
+        if result is not None:
+            ctx.update(result)
     return ctx
 
 
@@ -65,28 +83,30 @@ def prompt_xontrib_install(names: tp.List[str]):
     )
 
 
-def update_context(name, ctx=None):
-    """Updates a context in place from a xontrib. If ctx is not provided,
-    then __xonsh__.ctx is updated.
-    """
-    if ctx is None:
-        ctx = XSH.ctx
+def update_context(name, ctx: dict):
+    """Updates a context in place from a xontrib."""
     modctx = xontrib_context(name)
     if modctx is None:
-        if not hasattr(update_context, "bad_imports"):
-            update_context.bad_imports = []
-        update_context.bad_imports.append(name)
-        return ctx
-    return ctx.update(modctx)
+        raise XontribNotInstalled(f"Xontrib - {name} is not found.")
+    else:
+        ctx.update(modctx)
+    return ctx
 
 
-def xontrib_names_completer(**_):
-    for name, meta in get_xontribs().items():
-        full_name = f"xontrib.{name}"
-        if full_name not in sys.modules:
+def _xontrib_name_completions(loaded=False):
+    for name, meta, spec in _get_xontrib_specs():
+        if (spec.name in sys.modules) is loaded:
             yield RichCompletion(name, append_space=True, description=meta.description)
 
 
+def xontrib_names_completer(**_):
+    yield from _xontrib_name_completions(loaded=False)
+
+
+def xontrib_unload_completer(**_):
+    yield from _xontrib_name_completions(loaded=True)
+
+
 def xontribs_load(
     names: Annotated[
         tp.Sequence[str],
@@ -103,31 +123,90 @@ def xontribs_load(
     verbose : -v, --verbose
         verbose output
     """
-    ctx = XSH.ctx
+    ctx = {} if XSH.ctx is None else XSH.ctx
     res = ExitCode.OK
     stdout = None
     stderr = None
+    bad_imports = []
     for name in names:
         if verbose:
             print(f"loading xontrib {name!r}")
         try:
             update_context(name, ctx=ctx)
+        except XontribNotInstalled:
+            bad_imports.append(name)
         except Exception:
             res = ExitCode.INIT_FAILED
             print_exception(f"Failed to load xontrib {name}.")
-    if hasattr(update_context, "bad_imports"):
+    if bad_imports:
         res = ExitCode.NOT_FOUND
-        stderr = prompt_xontrib_install(update_context.bad_imports)  # type: ignore
-        del update_context.bad_imports  # type: ignore
+        stderr = prompt_xontrib_install(bad_imports)
     return stdout, stderr, res
 
 
+def xontribs_unload(
+    names: Annotated[
+        tp.Sequence[str],
+        Arg(nargs="+", completer=xontrib_unload_completer),
+    ] = (),
+    verbose=False,
+):
+    """Unload the given xontribs
+
+    Parameters
+    ----------
+    names
+        name of xontribs to unload
+
+    Notes
+    -----
+    Proper cleanup can be implemented by the xontrib. The default is equivalent to ``del sys.modules[module]``.
+    """
+    for name in names:
+        if verbose:
+            print(f"unloading xontrib {name!r}")
+        spec = find_xontrib(name)
+        try:
+            if spec and spec.name in sys.modules:
+                module = sys.modules[spec.name]
+                unloader = getattr(module, "_unload_xontrib_", None)
+                if unloader is not None:
+                    unloader(XSH)
+                del sys.modules[spec.name]
+        except Exception as ex:
+            print_exception(f"Failed to unload xontrib {name} ({ex})")
+
+
+def xontribs_reload(
+    names: Annotated[
+        tp.Sequence[str],
+        Arg(nargs="+", completer=xontrib_unload_completer),
+    ] = (),
+    verbose=False,
+):
+    """Reload the given xontribs
+
+    Parameters
+    ----------
+    names
+        name of xontribs to reload
+    """
+    for name in names:
+        if verbose:
+            print(f"reloading xontrib {name!r}")
+        xontribs_unload([name])
+        xontribs_load([name])
+
+
+def _get_xontrib_specs():
+    for xo_name, meta in get_xontribs().items():
+        yield xo_name, meta, find_xontrib(xo_name)
+
+
 def xontrib_data():
     """Collects and returns the data about installed xontribs."""
-    meta = get_xontribs()
     data = {}
-    for xo_name in meta:
-        spec = find_xontrib(xo_name)
+    for xo_name, _, spec in _get_xontrib_specs():
         loaded = spec.name in sys.modules
         data[xo_name] = {"name": xo_name, "loaded": loaded}
 
@@ -173,6 +252,8 @@ class XontribAlias(ArgParserAlias):
     def build(self):
         parser = self.create_parser(prog="xontrib")
         parser.add_command(xontribs_load, prog="load")
+        parser.add_command(xontribs_unload, prog="unload")
+        parser.add_command(xontribs_reload, prog="reload")
         parser.add_command(_list)
         return parser
 
diff --git a/xontrib/abbrevs.py b/xontrib/abbrevs.py
index c010c52dbe..bf7160bd6d 100644
--- a/xontrib/abbrevs.py
+++ b/xontrib/abbrevs.py
@@ -32,8 +32,7 @@
 from prompt_toolkit.filters import IsMultiline, completion_is_selected
 from prompt_toolkit.keys import Keys
 
-from xonsh.built_ins import XSH, DynamicAccessProxy
-from xonsh.events import events
+from xonsh.built_ins import DynamicAccessProxy, XonshSession
 from xonsh.tools import check_for_partial_string
 
 __all__ = ()
@@ -49,12 +48,6 @@ def __call__(self, word: str, buffer: Buffer) -> str:
 
 abbrevs: "dict[str, AbbrValType]" = dict()
 
-# XSH.builtins is a namespace and extendable
-XSH.builtins.abbrevs = abbrevs
-
-proxy = DynamicAccessProxy("abbrevs", "__xonsh__.builtins.abbrevs")
-builtins.abbrevs = proxy  # type: ignore
-
 
 class _LastExpanded(tp.NamedTuple):
     word: str
@@ -121,7 +114,6 @@ def set_cursor_position(buffer, expanded: str) -> None:
     buffer.delete(len(EDIT_SYMBOL))
 
 
-@events.on_ptk_create
 def custom_keybindings(bindings, **kw):
 
     from prompt_toolkit.filters import EmacsInsertMode, ViInsertMode
@@ -156,3 +148,13 @@ def multiline_carriage_return(event):
         if not current_char or current_char.isspace():
             abbrev.expand(buffer)
         carriage_return(buffer, event.cli)
+
+
+def _load_xontrib_(xsh: XonshSession, **_):
+    xsh.builtins.events.on_ptk_create(custom_keybindings)
+    # XSH.builtins is a namespace and extendable
+    xsh.builtins.abbrevs = abbrevs
+    proxy = DynamicAccessProxy("abbrevs", "__xonsh__.builtins.abbrevs")
+    builtins.abbrevs = proxy  # type: ignore
+
+    return {"abbrevs": abbrevs}
diff --git a/xontrib/autovox.py b/xontrib/autovox.py
index 40aed1e05e..52f73fa45c 100644
--- a/xontrib/autovox.py
+++ b/xontrib/autovox.py
@@ -13,26 +13,22 @@
 from pathlib import Path
 
 import xontrib.voxapi as voxapi
-from xonsh.built_ins import XSH
+from xonsh.built_ins import XSH, XonshSession
 
 __all__ = ()
 
 
-XSH.builtins.events.doc(
-    "autovox_policy",
+def autovox_policy(path: "Path") -> "str|Path|None":
     """
-autovox_policy(path: pathlib.Path) -> Union[str, pathlib.Path, None]
+    Register a policy with autovox.
 
-Register a policy with autovox.
+    A policy is a function that takes a Path and returns the venv associated with it,
+    if any.
 
-A policy is a function that takes a Path and returns the venv associated with it,
-if any.
-
-NOTE: The policy should only return a venv for this path exactly, not for
-parent paths. Parent walking is handled by autovox so that all policies can
-be queried at each level.
-""",
-)
+    NOTE: The policy should only return a venv for this path exactly, not for
+    parent paths. Parent walking is handled by autovox so that all policies can
+    be queried at each level.
+    """
 
 
 class MultipleVenvsWarning(RuntimeWarning):
@@ -80,7 +76,8 @@ def check_for_new_venv(curdir, olddir):
 
 
 # Core mechanism: Check for venv when the current directory changes
-@XSH.builtins.events.on_chdir
+
+
 def cd_handler(newdir, olddir, **_):
     check_for_new_venv(Path(newdir), Path(olddir))
 
@@ -88,12 +85,10 @@ def cd_handler(newdir, olddir, **_):
 # Recalculate when venvs are created or destroyed
 
 
-@XSH.builtins.events.vox_on_create
 def create_handler(**_):
     check_for_new_venv(Path.cwd(), ...)
 
 
-@XSH.builtins.events.vox_on_destroy
 def destroy_handler(**_):
     check_for_new_venv(Path.cwd(), ...)
 
@@ -101,6 +96,13 @@ def destroy_handler(**_):
 # Initial activation before first prompt
 
 
-@XSH.builtins.events.on_post_init
 def load_handler(**_):
     check_for_new_venv(Path.cwd(), None)
+
+
+def _load_xontrib_(xsh: XonshSession, **_):
+    xsh.builtins.events.register(autovox_policy)
+    xsh.builtins.events.on_chdir(cd_handler)
+    xsh.builtins.events.vox_on_create(create_handler)
+    xsh.builtins.events.vox_on_destroy(destroy_handler)
+    xsh.builtins.events.on_post_init(load_handler)
diff --git a/xontrib/bashisms.py b/xontrib/bashisms.py
index 01cfdd04b2..41fc263094 100644
--- a/xontrib/bashisms.py
+++ b/xontrib/bashisms.py
@@ -15,7 +15,7 @@
 import shlex
 import sys
 
-from xonsh.built_ins import XSH
+from xonsh.built_ins import XSH, XonshSession
 
 __all__ = ()
 
@@ -28,7 +28,6 @@ def _warn_not_supported(msg: str):
     )
 
 
-@XSH.builtins.events.on_transform_command
 def bash_preproc(cmd, **kw):
     bang_previous = {
         "!": lambda x: x,
@@ -82,10 +81,6 @@ def alias(args, stdin=None):
     return ret
 
 
-XSH.aliases["alias"] = alias
-XSH.env["THREAD_SUBPROCS"] = False
-
-
 def _unset(args):
     if not args:
         print("Usage: unset ENV_VARIABLE", file=sys.stderr)
@@ -97,9 +92,6 @@ def _unset(args):
             print(f"{v} not found", file=sys.stderr)
 
 
-XSH.aliases["unset"] = _unset
-
-
 def _export(args):
     if not args:
         print("Usage: export ENV_VARIABLE=VALUE", file=sys.stderr)
@@ -112,9 +104,6 @@ def _export(args):
             print(f"{eq} equal sign not found", file=sys.stderr)
 
 
-XSH.aliases["export"] = _export
-
-
 def _set(args):
     arg = args[0]
     if arg == "-e":
@@ -129,9 +118,6 @@ def _set(args):
         _warn_not_supported(f"set {arg}")
 
 
-XSH.aliases["set"] = _set
-
-
 def _shopt(args):
 
     supported_shopt = ["DOTGLOB"]
@@ -157,7 +143,12 @@ def _shopt(args):
         _warn_not_supported(f"shopt {args}")
 
 
-XSH.aliases["shopt"] = _shopt
-
-
-XSH.aliases["complete"] = "completer list"
+def _load_xontrib_(xsh: XonshSession, **_):
+    xsh.builtins.events.on_transform_command(bash_preproc)
+    xsh.aliases.register(_unset)
+    xsh.aliases.register(_export)
+    xsh.aliases.register(_shopt)
+    xsh.aliases.register(_set)
+    xsh.aliases["complete"] = "completer list".split()
+    xsh.aliases["alias"] = alias
+    xsh.env["THREAD_SUBPROCS"] = False
diff --git a/xontrib/coreutils.py b/xontrib/coreutils.py
index bf2eb79c9b..7384b0e345 100644
--- a/xontrib/coreutils.py
+++ b/xontrib/coreutils.py
@@ -14,7 +14,7 @@
 tools avoid the need for a full subprocess call. Additionally, these
 tools are cross-platform.
 """
-from xonsh.built_ins import XSH
+from xonsh.built_ins import XonshSession
 from xonsh.platform import ON_POSIX
 from xonsh.xoreutils.cat import cat
 from xonsh.xoreutils.echo import echo
@@ -26,20 +26,18 @@
 from xonsh.xoreutils.uptime import uptime
 from xonsh.xoreutils.yes import yes
 
-__all__ = ()
 
-XSH.aliases["cat"] = cat
-XSH.aliases["echo"] = echo
-XSH.aliases["pwd"] = pwd
-XSH.aliases["tee"] = tee
-XSH.aliases["tty"] = tty
-XSH.aliases["uname"] = uname
-XSH.aliases["uptime"] = uptime
-XSH.aliases["yes"] = yes
-XSH.aliases["umask"] = umask
-XSH.aliases["uptime"] = uptime
+def _load_xontrib_(xsh: XonshSession, **_):
+    xsh.aliases["cat"] = cat
+    xsh.aliases["echo"] = echo
+    xsh.aliases["pwd"] = pwd
+    xsh.aliases["tee"] = tee
+    xsh.aliases["tty"] = tty
+    xsh.aliases["uname"] = uname
+    xsh.aliases["uptime"] = uptime
+    xsh.aliases["umask"] = umask
+    xsh.aliases["yes"] = yes
+    if ON_POSIX:
+        from xonsh.xoreutils.ulimit import ulimit
 
-if ON_POSIX:
-    from xonsh.xoreutils.ulimit import ulimit
-
-    XSH.aliases["ulimit"] = ulimit
+        xsh.aliases["ulimit"] = ulimit
diff --git a/xontrib/fish_completer.py b/xontrib/fish_completer.py
index 4b212e7879..7782b3de5a 100644
--- a/xontrib/fish_completer.py
+++ b/xontrib/fish_completer.py
@@ -26,4 +26,5 @@ def fish_proc_completer(ctx: CommandContext):
     )
 
 
-completer.add_one_completer("fish", fish_proc_completer, "<bash")
+def _load_xontrib_(**_):
+    completer.add_one_completer("fish", fish_proc_completer, "<bash")
diff --git a/xontrib/free_cwd.py b/xontrib/free_cwd.py
index 3fc435dbdb..f8f232d70a 100644
--- a/xontrib/free_cwd.py
+++ b/xontrib/free_cwd.py
@@ -4,7 +4,7 @@
 Windows Explorer to delete or rename the current or parent
 directories. Internally, it is accomplished by temporarily resetting
 CWD to the root drive folder while waiting at the prompt. This only
-works with the prompt_toolkit backend and can cause cause issues
+works with the prompt_toolkit backend and can cause issues
 if any extensions are enabled that hook the prompt and relies on
 ``os.getcwd()``.
 """
@@ -12,7 +12,7 @@
 import os
 from pathlib import Path
 
-from xonsh.built_ins import XSH
+from xonsh.built_ins import XSH, XonshSession
 from xonsh.platform import ON_CYGWIN, ON_MSYS, ON_WINDOWS
 from xonsh.tools import print_exception
 
@@ -92,7 +92,6 @@ def wrapper(*args, **kwargs):
         return wrapper
 
 
-@XSH.builtins.events.on_ptk_create
 def setup_release_cwd_hook(prompter, history, completer, bindings, **kw):
     if ON_WINDOWS and not ON_CYGWIN and not ON_MSYS:
         prompter.prompt = _cwd_release_wrapper(prompter.prompt)
@@ -101,3 +100,7 @@ def setup_release_cwd_hook(prompter, history, completer, bindings, **kw):
             completer.completer.complete = _cwd_restore_wrapper(
                 completer.completer.complete
             )
+
+
+def _load_xontrib_(xsh: XonshSession, **_):
+    xsh.builtins.events.on_ptk_create(_cwd_restore_wrapper)
diff --git a/xontrib/pdb.py b/xontrib/pdb.py
index 24f6de12e7..793265ecdb 100644
--- a/xontrib/pdb.py
+++ b/xontrib/pdb.py
@@ -1,7 +1,7 @@
 """Simple built-in debugger. Runs pdb on reception of SIGUSR1 signal."""
 import signal
 
-__all__ = ()
+from xonsh.built_ins import XonshSession
 
 
 def handle_sigusr1(sig, frame):
@@ -11,4 +11,5 @@ def handle_sigusr1(sig, frame):
     pdb.Pdb().set_trace(frame)
 
 
-signal.signal(signal.SIGUSR1, handle_sigusr1)
+def _load_xontrib_(xsh: XonshSession, **_):
+    signal.signal(signal.SIGUSR1, handle_sigusr1)
diff --git a/xontrib/vox.py b/xontrib/vox.py
index 700566668a..4891deca4a 100644
--- a/xontrib/vox.py
+++ b/xontrib/vox.py
@@ -7,7 +7,7 @@
 
 import xonsh.cli_utils as xcli
 import xontrib.voxapi as voxapi
-from xonsh.built_ins import XSH
+from xonsh.built_ins import XSH, XonshSession
 from xonsh.dirstack import pushd_fn
 from xonsh.platform import ON_WINDOWS
 from xonsh.tools import XonshError
@@ -495,4 +495,5 @@ def upgrade(
         self.out(venv)
 
 
-XSH.aliases["vox"] = VoxHandler(threadable=False)
+def _load_xontrib_(xsh: XonshSession, **_):
+    xsh.aliases["vox"] = VoxHandler(threadable=False)
diff --git a/xontrib/whole_word_jumping.py b/xontrib/whole_word_jumping.py
index 07777bdc06..7f9538146b 100644
--- a/xontrib/whole_word_jumping.py
+++ b/xontrib/whole_word_jumping.py
@@ -5,12 +5,9 @@
 """
 from prompt_toolkit.keys import Keys
 
-from xonsh.built_ins import XSH
+from xonsh.built_ins import XonshSession
 
-__all__ = ()
 
-
-@XSH.builtins.events.on_ptk_create
 def custom_keybindings(bindings, **kw):
 
     # Key bindings for jumping over whole words (everything that's not
@@ -42,3 +39,7 @@ def shift_delete(event):
         endpos = endpos + 1 if startpos == 0 else endpos
         buff.text = buff.text[:startpos] + buff.text[endpos:]
         buff.cursor_position = startpos
+
+
+def _load_xontrib_(xsh: XonshSession, **_):
+    xsh.builtins.events.on_ptk_create(custom_keybindings)
diff --git a/xontrib/xog.py b/xontrib/xog.py
index 3e5602cff0..005c5bf25b 100644
--- a/xontrib/xog.py
+++ b/xontrib/xog.py
@@ -6,9 +6,7 @@
 import pathlib
 import tempfile
 
-from xonsh.built_ins import XSH
-
-__all__ = ()
+from xonsh.built_ins import XSH, XonshSession
 
 
 def _get_log_file_name():
@@ -65,5 +63,6 @@ def _xog(args, stdout=None, stderr=None):
     return 0 if rc else -1
 
 
-XSH.env["XONSH_TRACEBACK_LOGFILE"] = _get_log_file_name()
-XSH.aliases["xog"] = _xog
+def _load_xontrib_(xsh: XonshSession, **_):
+    xsh.env["XONSH_TRACEBACK_LOGFILE"] = _get_log_file_name()
+    xsh.aliases["xog"] = _xog


Code review comments:
diff_hunk: @@ -24,15 +24,50 @@ took inspiration from for xonsh:
 * `Sphinx <http://sphinx-doc.org/>`_: Extensions are just Python modules,
   bundles some extensions with the main package, interface is a list of
   string names.
+* `IPython <https://ipython.readthedocs.io/en/stable/config/extensions/index.html>`_: Extensions are just Python modules
+  with some special functions to load/unload.
 * `Oh My Zsh <http://ohmyz.sh/>`_: Centralized registry, autoloading, and
   for a shell.
 * `ESLint <http://eslint.org/>`_: Ability to use language package manager
   to install/remove extensions.
 
-
 Structure
-==========
-Xontribs are modules written in either xonsh (``*.xsh``) or Python (``*.py``).
+================
+Xontribs are modules with some special functions written
+in either xonsh (``*.xsh``) or Python (``*.py``)  that has a couple of special functions to load and unload it.
+
+It is inspired from
path: docs/tutorial_xontrib.rst
text: @user1:
@author Did you accidentally delete the rest of this sentence?

@author:
I think that is the case, there should be ipython,sphinx ... in the mix

@author:
Ok they are above, I should be removing the hanging sentence