Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

extensions: allow extensions in namespace packages #523

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions jupyter_server/extension/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -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 `<namespace>.<package>`.
return '.'.join(parts[0:2])
return parts[0]

Zsailer marked this conversation as resolved.
Show resolved Hide resolved
@classmethod
def get_extension_point(cls):
Expand Down
3 changes: 3 additions & 0 deletions jupyter_server/tests/namespace-package-test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Blank namespace package for use in testing.

https://www.python.org/dev/peps/pep-0420/
5 changes: 5 additions & 0 deletions jupyter_server/tests/namespace-package-test/setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[metadata]
name = namespace-package-test

[options]
packages = find_namespace:
39 changes: 36 additions & 3 deletions jupyter_server/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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/'
),
(
Expand All @@ -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
18 changes: 18 additions & 0 deletions jupyter_server/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)