Skip to content

Commit 81d46e3

Browse files
extensions: allow extensions in namespace packages (#523)
1 parent 6f970c5 commit 81d46e3

File tree

6 files changed

+68
-5
lines changed

6 files changed

+68
-5
lines changed

jupyter_server/extension/application.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from jupyter_server.serverapp import ServerApp
2222
from jupyter_server.transutils import _i18n
23-
from jupyter_server.utils import url_path_join
23+
from jupyter_server.utils import url_path_join, is_namespace_package
2424
from .handler import ExtensionHandlerMixin
2525

2626
# -----------------------------------------------------------------------------
@@ -174,7 +174,11 @@ def _default_open_browser(self):
174174

175175
@classmethod
176176
def get_extension_package(cls):
177-
return cls.__module__.split('.')[0]
177+
parts = cls.__module__.split('.')
178+
if is_namespace_package(parts[0]):
179+
# in this case the package name is `<namespace>.<package>`.
180+
return '.'.join(parts[0:2])
181+
return parts[0]
178182

179183
@classmethod
180184
def get_extension_point(cls):
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Blank namespace package for use in testing.
2+
3+
https://www.python.org/dev/peps/pep-0420/
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[metadata]
2+
name = namespace-package-test
3+
4+
[options]
5+
packages = find_namespace:

jupyter_server/tests/namespace-package-test/test_namespace/test_package/__init__.py

Whitespace-only changes.

jupyter_server/tests/test_utils.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
1+
from pathlib import Path
2+
import sys
3+
14
import pytest
25

36
from traitlets.tests.utils import check_help_all_output
4-
from jupyter_server.utils import url_escape, url_unescape
7+
from jupyter_server.utils import (
8+
url_escape,
9+
url_unescape,
10+
is_namespace_package
11+
)
512

613

714
def test_help_output():
815
check_help_all_output('jupyter_server')
916

1017

11-
1218
@pytest.mark.parametrize(
1319
'unescaped,escaped',
1420
[
1521
(
16-
'/this is a test/for spaces/',
22+
'/this is a test/for spaces/',
1723
'/this%20is%20a%20test/for%20spaces/'
1824
),
1925
(
@@ -37,3 +43,30 @@ def test_url_escaping(unescaped, escaped):
3743
# Test unescaping.
3844
path = url_unescape(escaped)
3945
assert path == unescaped
46+
47+
48+
@pytest.fixture
49+
def namespace_package_test(monkeypatch):
50+
"""Adds a blank namespace package into the PYTHONPATH for testing.
51+
52+
Yields the name of the importable namespace.
53+
"""
54+
monkeypatch.setattr(
55+
sys,
56+
'path',
57+
[
58+
str(Path(__file__).parent / 'namespace-package-test'),
59+
*sys.path
60+
]
61+
)
62+
yield 'test_namespace'
63+
64+
65+
def test_is_namespace_package(namespace_package_test):
66+
# returns True if it is a namespace package
67+
assert is_namespace_package(namespace_package_test)
68+
# returns False if it isn't a namespace package
69+
assert not is_namespace_package('sys')
70+
assert not is_namespace_package('jupyter_server')
71+
# returns None if it isn't importable
72+
assert is_namespace_package('not_a_python_namespace') is None

jupyter_server/utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
# Copyright (c) Jupyter Development Team.
44
# Distributed under the terms of the Modified BSD License.
55

6+
from _frozen_importlib_external import _NamespacePath
67
import asyncio
78
import errno
9+
import importlib.util
810
import inspect
911
import os
1012
import socket
@@ -352,3 +354,19 @@ async def async_fetch(
352354
with _request_for_tornado_client(urlstring) as request:
353355
response = await AsyncHTTPClient(io_loop).fetch(request)
354356
return response
357+
358+
359+
def is_namespace_package(namespace):
360+
"""Is the provided namespace a Python Namespace Package (PEP420).
361+
362+
https://www.python.org/dev/peps/pep-0420/#specification
363+
364+
Returns `None` if module is not importable.
365+
366+
"""
367+
# NOTE: using submodule_search_locations because the loader can be None
368+
spec = importlib.util.find_spec(namespace)
369+
if not spec:
370+
# e.g. module not installed
371+
return None
372+
return isinstance(spec.submodule_search_locations, _NamespacePath)

0 commit comments

Comments
 (0)