Skip to content

Commit cbe843b

Browse files
committed
add a basic extension config manager
1 parent 3b09860 commit cbe843b

File tree

6 files changed

+189
-35
lines changed

6 files changed

+189
-35
lines changed

jupyter_server/extension/config.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
2+
from jupyter_server.services.config.manager import ConfigManager
3+
4+
5+
DEFAULT_SECTION_NAME = "jupyter_server_config"
6+
7+
8+
class ExtensionConfigManager(ConfigManager):
9+
"""A manager class to interface with Jupyter Server Extension config
10+
found in a `config.d` folder. It is assumed that all configuration
11+
files in this directory are JSON files.
12+
"""
13+
def get_jpserver_extensions(
14+
self,
15+
section_name=DEFAULT_SECTION_NAME
16+
):
17+
"""Return the jpserver_extensions field from all
18+
config files found."""
19+
data = self.get(section_name)
20+
return (
21+
data
22+
.get("ServerApp", {})
23+
.get("jpserver_extensions", {})
24+
)
25+
26+
def enabled(
27+
self,
28+
name,
29+
section_name=DEFAULT_SECTION_NAME,
30+
include_root=True
31+
):
32+
"""Is the extension enabled?"""
33+
extensions = self.get_jpserver_extensions(section_name)
34+
try:
35+
return extensions[name]
36+
except KeyError:
37+
return False
38+
39+
def enable(
40+
self,
41+
name,
42+
section_name=DEFAULT_SECTION_NAME,
43+
):
44+
data = {
45+
"ServerApp": {
46+
"jpserver_extensions": {
47+
name: True
48+
}
49+
}
50+
}
51+
self.update(section_name, data)
52+
53+
def disable(
54+
self,
55+
name,
56+
section_name=DEFAULT_SECTION_NAME
57+
):
58+
data = {
59+
"ServerApp": {
60+
"jpserver_extensions": {
61+
name: False
62+
}
63+
}
64+
}
65+
self.update(section_name, data)

jupyter_server/extension/manager.py

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,7 @@ def module(self):
8484
"""
8585
return self._module
8686

87-
def link(self, serverapp):
88-
"""Link the extension to a Jupyter ServerApp object.
89-
90-
This looks for a `_link_jupyter_server_extension` function
91-
in the extension's module or ExtensionApp class.
92-
"""
87+
def _get_linker(self):
9388
if self.app:
9489
linker = self.app._link_jupyter_server_extension
9590
else:
@@ -100,24 +95,39 @@ def link(self, serverapp):
10095
# Otherwise return a dummy function.
10196
lambda serverapp: None
10297
)
98+
return linker
99+
100+
def _get_loader(self):
101+
loc = self.app
102+
if not loc:
103+
loc = self.module
104+
loader = get_loader(loc)
105+
return loader
103106

104-
# Capture output to return
105-
out = linker(serverapp)
106-
# Store that this extension has been linked
107-
return out
107+
def validate(self):
108+
"""Check that both a linker and loader exists."""
109+
try:
110+
self.get_linker()
111+
self.get_loader()
112+
except Exception:
113+
return False
114+
115+
def link(self, serverapp):
116+
"""Link the extension to a Jupyter ServerApp object.
117+
118+
This looks for a `_link_jupyter_server_extension` function
119+
in the extension's module or ExtensionApp class.
120+
"""
121+
linker = self.get_linker()
122+
return linker(serverapp)
108123

109124
def load(self, serverapp):
110125
"""Load the extension in a Jupyter ServerApp object.
111126
112127
This looks for a `_load_jupyter_server_extension` function
113128
in the extension's module or ExtensionApp class.
114129
"""
115-
# Use the ExtensionApp object to find a loading function
116-
# if it exists. Otherwise, use the extension module given.
117-
loc = self.app
118-
if not loc:
119-
loc = self.module
120-
loader = get_loader(loc)
130+
loader = self.get_loader()
121131
return loader(serverapp)
122132

123133

@@ -143,7 +153,7 @@ def _validate_name(self, proposed):
143153
name = proposed['value']
144154
self._extension_points = {}
145155
try:
146-
self._metadata = get_metadata(name)
156+
self._module, self._metadata = get_metadata(name)
147157
except ImportError:
148158
raise ExtensionModuleNotFound(
149159
"The module '{name}' could not be found. Are you "
@@ -155,6 +165,16 @@ def _validate_name(self, proposed):
155165
self._extension_points[point.name] = point
156166
return name
157167

168+
@property
169+
def module(self):
170+
"""Extension metadata loaded from the extension package."""
171+
return self._module
172+
173+
@property
174+
def version(self):
175+
"""Get the version of this package, if it's given. Otherwise, return an empty string"""
176+
return getattr(self._module, "__version__", "")
177+
158178
@property
159179
def metadata(self):
160180
"""Extension metadata loaded from the extension package."""
@@ -165,6 +185,13 @@ def extension_points(self):
165185
"""A dictionary of extension points."""
166186
return self._extension_points
167187

188+
def validate(self):
189+
"""Validate all extension points in this package."""
190+
for extension in self.extensions_points:
191+
if not extension.validate():
192+
return False
193+
return True
194+
168195
def link_point(self, point_name, serverapp):
169196
linked = self._linked_points.get(point_name, False)
170197
if not linked:
@@ -191,7 +218,8 @@ class ExtensionManager(LoggingConfigurable):
191218
Usage:
192219
m = ExtensionManager(jpserver_extensions=extensions)
193220
"""
194-
def __init__(self, *args, **kwargs):
221+
def __init__(self, config_manager=None, *args, **kwargs):
222+
super().__init__(*args, **kwargs)
195223
# The `enabled_extensions` attribute provides a dictionary
196224
# with extension (package) names mapped to their ExtensionPackage interface
197225
# (see above). This manager simplifies the interaction between the
@@ -202,7 +230,9 @@ def __init__(self, *args, **kwargs):
202230
# extensions from being re-linked recursively unintentionally if another
203231
# extension attempts to link extensions again.
204232
self._linked_extensions = {}
205-
super().__init__(*args, **kwargs)
233+
self._config_manager = config_manager
234+
if self._config_manager:
235+
self.from_config_manager
206236

207237
@property
208238
def enabled_extensions(self):
@@ -221,6 +251,12 @@ def extension_points(self):
221251
for name, point in value.extension_points.items()
222252
}
223253

254+
def from_config_manager(self, config_manager):
255+
"""Add extensions found by an ExtensionConfigManager"""
256+
self._config_manager = config_manager
257+
jpserver_extensions = self._config_manager.get_jpserver_extensions()
258+
self.from_jpserver_extensions(jpserver_extensions)
259+
224260
def from_jpserver_extensions(self, jpserver_extensions):
225261
"""Add extensions from 'jpserver_extensions'-like dictionary."""
226262
for name, enabled in jpserver_extensions.items():

jupyter_server/extension/utils.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,6 @@ class NotAnExtensionApp(Exception):
1717
pass
1818

1919

20-
def get_extension_app_pkg(app_cls):
21-
"""Get the Python package name
22-
"""
23-
if not isinstance(app_cls, "ExtensionApp"):
24-
raise NotAnExtensionApp("The ")
25-
26-
2720
def get_loader(obj, logger=None):
2821
"""Looks for _load_jupyter_server_extension as an attribute
2922
of the object or module.
@@ -61,7 +54,7 @@ def get_metadata(package_name, logger=None):
6154
# _jupyter_server_extension_paths. We will remove in
6255
# a later release of Jupyter Server.
6356
try:
64-
return module._jupyter_server_extension_paths()
57+
return module, module._jupyter_server_extension_paths()
6558
except AttributeError:
6659
if logger:
6760
logger.debug(
@@ -82,7 +75,7 @@ def get_metadata(package_name, logger=None):
8275
"for extension points in the extension pacakge's "
8376
"root.".format(name=package_name)
8477
)
85-
return [{
78+
return module, [{
8679
"module": package_name,
8780
"name": package_name
8881
}]

jupyter_server/serverapp.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102

103103
from jupyter_server.extension.serverextension import ServerExtensionApp
104104
from jupyter_server.extension.manager import ExtensionManager
105+
from jupyter_server.extension.config import ExtensionConfigManager
105106

106107
#-----------------------------------------------------------------------------
107108
# Module globals
@@ -1481,13 +1482,12 @@ def find_server_extensions(self):
14811482
# This enables merging on keys, which we want for extension enabling.
14821483
# Regular config loading only merges at the class level,
14831484
# so each level clobbers the previous.
1484-
config_path = jupyter_config_path()
1485-
if self.config_dir not in config_path:
1485+
config_paths = jupyter_config_path()
1486+
if self.config_dir not in config_paths:
14861487
# add self.config_dir to the front, if set manually
1487-
config_path.insert(0, self.config_dir)
1488-
manager = ConfigManager(read_config_path=config_path)
1489-
section = manager.get(self.config_file_name)
1490-
extensions = section.get('ServerApp', {}).get('jpserver_extensions', {})
1488+
config_paths.insert(0, self.config_dir)
1489+
manager = ExtensionConfigManager(config_paths=config_paths)
1490+
extensions = manager.get_jpserver_extensions()
14911491

14921492
for modulename, enabled in sorted(extensions.items()):
14931493
if modulename not in self.jpserver_extensions:

tests/extension/test_config.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import pytest
2+
3+
from jupyter_core.paths import jupyter_config_path
4+
from jupyter_server.extension.config import (
5+
ExtensionConfigManager,
6+
)
7+
8+
# Use ServerApps environment because it monkeypatches
9+
# jupyter_core.paths and provides a config directory
10+
# that's not cross contaminating the user config directory.
11+
pytestmark = pytest.mark.usefixtures("environ")
12+
13+
14+
@pytest.fixture
15+
def configd(env_config_path):
16+
"""A pathlib.Path object that acts like a jupyter_server_config.d folder."""
17+
configd = env_config_path.joinpath('jupyter_server_config.d')
18+
configd.mkdir()
19+
return configd
20+
21+
22+
ext1_json_config = """\
23+
{
24+
"ServerApp": {
25+
"jpserver_extensions": {
26+
"ext1_config": true
27+
}
28+
}
29+
}
30+
"""
31+
32+
@pytest.fixture
33+
def ext1_config(configd):
34+
config = configd.joinpath("ext1_config.json")
35+
config.write_text(ext1_json_config)
36+
37+
38+
ext2_json_config = """\
39+
{
40+
"ServerApp": {
41+
"jpserver_extensions": {
42+
"ext2_config": false
43+
}
44+
}
45+
}
46+
"""
47+
48+
49+
@pytest.fixture
50+
def ext2_config(configd):
51+
config = configd.joinpath("ext2_config.json")
52+
config.write_text(ext2_json_config)
53+
54+
55+
def test_list_extension_from_configd(ext1_config, ext2_config):
56+
manager = ExtensionConfigManager(
57+
read_config_path=jupyter_config_path()
58+
)
59+
extensions = manager.get_jpserver_extensions()
60+
assert "ext2_config" in extensions
61+
assert "ext1_config" in extensions

tests/extension/test_utils.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
pytestmark = pytest.mark.usefixtures("environ")
99

1010

11-
1211
def test_validate_extension():
1312
# enabled at sys level
1413
assert validate_extension('tests.extension.mockextensions.mockext_sys')

0 commit comments

Comments
 (0)