Repository: jupyter-server/jupyter_server

Issue:
Jupyter Server imports extensions even when they are disabled, slowing down startup
## Description

jupyter server will do an import of the *python* package for *all* extensions, even if they are explicitly disabled. This can cause a big performance penalty, as well as cause code to execute that perhaps shouldn't be.

## Reproduce

1. Install `jupyter_server` on a fresh virtualenv / conda env
2. Install the `panel` package, as that provides a server extension that takes a while to start
3. Run `jupyter server extension list` to make sure that `panel.io.jupyter_server_extension` is enabled
4. Disable it with `jupyter server extension disable panel.io.jupyter_server_extension`, and verify with `jupyter server extension list` again
5. Start `jupyter server`

## Expected behavior

The `panel.io.jupyter_serverextension` package is *not* imported, and the line `Package panel.io.jupyter_server_extension took 0.4643s to import` is not printed by `jupyter-server` on startup. The extension is disabled, and should not be imported.

Instead, the line is printed as the python package is imported anyway.

## Context

This came up in the context of debugging https://github.com/2i2c-org/infrastructure/issues/2047, where I was trying to figure out why jupyter server startup was so slow jupyterhub was timing out at 30s for the *python process* to startup after the container has started. I was testing by assuming that disabling an extension wouldn't import it, both as it is the least surprising behavior but also because that is how notebook server behaves. Eventually `strace` output led me to realize that disabling extenions doesn't stop them from being imported.

While https://github.com/jupyter-server/jupyter_server/pull/1176 and https://github.com/jupyter-server/jupyter_server/pull/1171
make it easier to see which packages are adding startup latency, it is still impossible to disable an extension temporarily to remove the latency issue. The only way to reduce latency from panel's serverextension is to *entirely uninstall panel*, which breaks other functionality too.

Given that enable / disable status can be determined without having to import the python package, no import should ideally be done if the extension itself is disabled.

Discussion:
yuvipanda: Thanks a lot for working on this, @minrk!
codecov[bot]: # [Codecov](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server) Report
Base: **80.09**% // Head: **39.25**% // Decreases project coverage by **`-40.84%`** :warning:
> Coverage data is based on head [(`9f7855a`)](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server) compared to base [(`4e9d105`)](https://codecov.io/gh/jupyter-server/jupyter_server/commit/4e9d105d42b16df901cc35e06ab235a242cf25a1?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server).
> Patch coverage: 80.95% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##             main    #1180       +/-   ##
===========================================
- Coverage   80.09%   39.25%   -40.84%     
===========================================
  Files          68       68               
  Lines        8153     8149        -4     
  Branches     1606     1240      -366     
===========================================
- Hits         6530     3199     -3331     
- Misses       1196     4748     +3552     
+ Partials      427      202      -225     
```


| [Impacted Files](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server) | Coverage Δ | |
|---|---|---|
| [jupyter\_server/extension/manager.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvZXh0ZW5zaW9uL21hbmFnZXIucHk=) | `67.31% <80.95%> (-25.03%)` | :arrow_down: |
| [jupyter\_server/terminal/\_\_init\_\_.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvdGVybWluYWwvX19pbml0X18ucHk=) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [jupyter\_server/terminal/handlers.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvdGVybWluYWwvaGFuZGxlcnMucHk=) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [jupyter\_server/terminal/api\_handlers.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvdGVybWluYWwvYXBpX2hhbmRsZXJzLnB5) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [jupyter\_server/terminal/terminalmanager.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvdGVybWluYWwvdGVybWluYWxtYW5hZ2VyLnB5) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [...pyter\_server/services/contents/largefilemanager.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvc2VydmljZXMvY29udGVudHMvbGFyZ2VmaWxlbWFuYWdlci5weQ==) | `11.11% <0.00%> (-72.73%)` | :arrow_down: |
| [jupyter\_server/services/contents/fileio.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvc2VydmljZXMvY29udGVudHMvZmlsZWlvLnB5) | `21.87% <0.00%> (-68.23%)` | :arrow_down: |
| [...upyter\_server/services/contents/filecheckpoints.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvc2VydmljZXMvY29udGVudHMvZmlsZWNoZWNrcG9pbnRzLnB5) | `22.35% <0.00%> (-67.65%)` | :arrow_down: |
| [jupyter\_server/gateway/handlers.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvZ2F0ZXdheS9oYW5kbGVycy5weQ==) | `0.00% <0.00%> (-62.28%)` | :arrow_down: |
| [jupyter\_server/services/sessions/sessionmanager.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvc2VydmljZXMvc2Vzc2lvbnMvc2Vzc2lvbm1hbmFnZXIucHk=) | `26.72% <0.00%> (-61.64%)` | :arrow_down: |
| ... and [44 more](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/jupyter-server/jupyter_server/pull/1180?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server).


Patch:
diff --git a/jupyter_server/extension/manager.py b/jupyter_server/extension/manager.py
index 519921365..cf60f4ece 100644
--- a/jupyter_server/extension/manager.py
+++ b/jupyter_server/extension/manager.py
@@ -2,7 +2,7 @@
 import importlib
 
 from tornado.gen import multi
-from traitlets import Any, Bool, Dict, HasTraits, Instance, Unicode, default, observe
+from traitlets import Any, Bool, Dict, HasTraits, Instance, List, Unicode, default, observe
 from traitlets import validate as validate_trait
 from traitlets.config import LoggingConfigurable
 
@@ -157,22 +157,39 @@ class ExtensionPackage(LoggingConfigurable):
     """
 
     name = Unicode(help="Name of the an importable Python package.")
-    enabled = Bool(False).tag(config=True)
+    enabled = Bool(False, help="Whether the extension package is enabled.")
 
-    def __init__(self, *args, **kwargs):
+    _linked_points = Dict()
+    extension_points = Dict()
+    module = Any(allow_none=True, help="The module for this extension package. None if not enabled")
+    metadata = List(Dict(), help="Extension metadata loaded from the extension package.")
+    version = Unicode(
+        help="""
+            The version of this extension package, if it can be found.
+            Otherwise, an empty string.
+            """,
+    )
+
+    @default("version")
+    def _load_version(self):
+        if not self.enabled:
+            return ""
+        return getattr(self.module, "__version__", "")
+
+    def __init__(self, **kwargs):
         """Initialize an extension package."""
-        # Store extension points that have been linked.
-        self._linked_points = {}
-        super().__init__(*args, **kwargs)
+        super().__init__(**kwargs)
+        if self.enabled:
+            self._load_metadata()
 
-    _linked_points: dict = {}
+    def _load_metadata(self):
+        """Import package and load metadata
 
-    @validate_trait("name")
-    def _validate_name(self, proposed):
-        name = proposed["value"]
-        self._extension_points = {}
+        Only used if extension package is enabled
+        """
+        name = self.name
         try:
-            self._module, self._metadata = get_metadata(name, self.log)
+            self.module, self.metadata = get_metadata(name, logger=self.log)
         except ImportError as e:
             msg = (
                 f"The module '{name}' could not be found ({e}). Are you "
@@ -180,31 +197,11 @@ class ExtensionPackage(LoggingConfigurable):
             )
             raise ExtensionModuleNotFound(msg) from None
         # Create extension point interfaces for each extension path.
-        for m in self._metadata:
+        for m in self.metadata:
             point = ExtensionPoint(metadata=m)
-            self._extension_points[point.name] = point
+            self.extension_points[point.name] = point
         return name
 
-    @property
-    def module(self):
-        """Extension metadata loaded from the extension package."""
-        return self._module
-
-    @property
-    def version(self) -> str:
-        """Get the version of this package, if it's given. Otherwise, return an empty string"""
-        return getattr(self._module, "__version__", "")
-
-    @property
-    def metadata(self):
-        """Extension metadata loaded from the extension package."""
-        return self._metadata
-
-    @property
-    def extension_points(self):
-        """A dictionary of extension points."""
-        return self._extension_points
-
     def validate(self):
         """Validate all extension points in this package."""
         for extension in self.extension_points.values():


Test patch:
diff --git a/tests/extension/test_manager.py b/tests/extension/test_manager.py
index d1ffd21a8..48a527bb4 100644
--- a/tests/extension/test_manager.py
+++ b/tests/extension/test_manager.py
@@ -1,4 +1,5 @@
 import os
+import sys
 import unittest.mock as mock
 
 import pytest
@@ -60,7 +61,7 @@ def test_extension_package_api():
     path1 = metadata_list[0]
     app = path1["app"]
 
-    e = ExtensionPackage(name="tests.extension.mockextensions")
+    e = ExtensionPackage(name="tests.extension.mockextensions", enabled=True)
     e.extension_points
     assert hasattr(e, "extension_points")
     assert len(e.extension_points) == len(metadata_list)
@@ -70,7 +71,9 @@ def test_extension_package_api():
 
 def test_extension_package_notfound_error():
     with pytest.raises(ExtensionModuleNotFound):
-        ExtensionPackage(name="nonexistent")
+        ExtensionPackage(name="nonexistent", enabled=True)
+    # no raise if not enabled
+    ExtensionPackage(name="nonexistent", enabled=False)
 
 
 def _normalize_path(path_list):
@@ -144,3 +147,23 @@ def test_extension_manager_fail_load(jp_serverapp, has_app):
                 manager.load_extension(name)
         else:
             manager.load_extension(name)
+
+
+@pytest.mark.parametrize("has_app", [True, False])
+def test_disable_no_import(jp_serverapp, has_app):
+    # de-import modules so we can detect if they are re-imported
+    disabled_ext = "tests.extension.mockextensions.mock1"
+    enabled_ext = "tests.extension.mockextensions.mock2"
+    sys.modules.pop(disabled_ext, None)
+    sys.modules.pop(enabled_ext, None)
+
+    manager = ExtensionManager(serverapp=jp_serverapp if has_app else None)
+    manager.add_extension(disabled_ext, enabled=False)
+    manager.add_extension(enabled_ext, enabled=True)
+    assert disabled_ext not in sys.modules
+    assert enabled_ext in sys.modules
+
+    ext_pkg = manager.extensions[disabled_ext]
+    assert ext_pkg.extension_points == {}
+    assert ext_pkg.version == ""
+    assert ext_pkg.metadata == []