diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index e5cb6bcafd..cc7b866e80 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -20,7 +20,7 @@ from jupyter_server.serverapp import ServerApp from jupyter_server.transutils import _i18n -from jupyter_server.utils import url_path_join +from jupyter_server.utils import url_path_join, is_namespace_package from .handler import ExtensionHandlerMixin # ----------------------------------------------------------------------------- @@ -174,7 +174,11 @@ def _default_open_browser(self): @classmethod def get_extension_package(cls): - return cls.__module__.split('.')[0] + parts = cls.__module__.split('.') + if is_namespace_package(parts[0]): + # in this case the package name is `.`. + return '.'.join(parts[0:2]) + return parts[0] @classmethod def get_extension_point(cls): diff --git a/jupyter_server/tests/namespace-package-test/README.md b/jupyter_server/tests/namespace-package-test/README.md new file mode 100644 index 0000000000..f72158b9b1 --- /dev/null +++ b/jupyter_server/tests/namespace-package-test/README.md @@ -0,0 +1,3 @@ +Blank namespace package for use in testing. + +https://www.python.org/dev/peps/pep-0420/ diff --git a/jupyter_server/tests/namespace-package-test/setup.cfg b/jupyter_server/tests/namespace-package-test/setup.cfg new file mode 100644 index 0000000000..105be78362 --- /dev/null +++ b/jupyter_server/tests/namespace-package-test/setup.cfg @@ -0,0 +1,5 @@ +[metadata] +name = namespace-package-test + +[options] +packages = find_namespace: diff --git a/jupyter_server/tests/namespace-package-test/test_namespace/test_package/__init__.py b/jupyter_server/tests/namespace-package-test/test_namespace/test_package/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/jupyter_server/tests/test_utils.py b/jupyter_server/tests/test_utils.py index c047e8307c..3de7336b6f 100644 --- a/jupyter_server/tests/test_utils.py +++ b/jupyter_server/tests/test_utils.py @@ -1,19 +1,25 @@ +from pathlib import Path +import sys + import pytest from traitlets.tests.utils import check_help_all_output -from jupyter_server.utils import url_escape, url_unescape +from jupyter_server.utils import ( + url_escape, + url_unescape, + is_namespace_package +) def test_help_output(): check_help_all_output('jupyter_server') - @pytest.mark.parametrize( 'unescaped,escaped', [ ( - '/this is a test/for spaces/', + '/this is a test/for spaces/', '/this%20is%20a%20test/for%20spaces/' ), ( @@ -37,3 +43,30 @@ def test_url_escaping(unescaped, escaped): # Test unescaping. path = url_unescape(escaped) assert path == unescaped + + +@pytest.fixture +def namespace_package_test(monkeypatch): + """Adds a blank namespace package into the PYTHONPATH for testing. + + Yields the name of the importable namespace. + """ + monkeypatch.setattr( + sys, + 'path', + [ + str(Path(__file__).parent / 'namespace-package-test'), + *sys.path + ] + ) + yield 'test_namespace' + + +def test_is_namespace_package(namespace_package_test): + # returns True if it is a namespace package + assert is_namespace_package(namespace_package_test) + # returns False if it isn't a namespace package + assert not is_namespace_package('sys') + assert not is_namespace_package('jupyter_server') + # returns None if it isn't importable + assert is_namespace_package('not_a_python_namespace') is None diff --git a/jupyter_server/utils.py b/jupyter_server/utils.py index e0a532bbdd..afa4c90d50 100644 --- a/jupyter_server/utils.py +++ b/jupyter_server/utils.py @@ -3,8 +3,10 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +from _frozen_importlib_external import _NamespacePath import asyncio import errno +import importlib.util import inspect import os import socket @@ -352,3 +354,19 @@ async def async_fetch( with _request_for_tornado_client(urlstring) as request: response = await AsyncHTTPClient(io_loop).fetch(request) return response + + +def is_namespace_package(namespace): + """Is the provided namespace a Python Namespace Package (PEP420). + + https://www.python.org/dev/peps/pep-0420/#specification + + Returns `None` if module is not importable. + + """ + # NOTE: using submodule_search_locations because the loader can be None + spec = importlib.util.find_spec(namespace) + if not spec: + # e.g. module not installed + return None + return isinstance(spec.submodule_search_locations, _NamespacePath)