Skip to content

Commit 2195971

Browse files
authored
extensions: render default templates with default static_url (#1435)
1 parent e74da85 commit 2195971

File tree

5 files changed

+122
-6
lines changed

5 files changed

+122
-6
lines changed

jupyter_server/extension/handler.py

+19-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from logging import Logger
66
from typing import TYPE_CHECKING, Any, cast
77

8+
from jinja2 import Template
89
from jinja2.exceptions import TemplateNotFound
910

1011
from jupyter_server.base.handlers import FileFindHandler
@@ -21,13 +22,14 @@ class ExtensionHandlerJinjaMixin:
2122
template rendering.
2223
"""
2324

24-
def get_template(self, name: str) -> str:
25+
def get_template(self, name: str) -> Template:
2526
"""Return the jinja template object for a given name"""
2627
try:
2728
env = f"{self.name}_jinja2_env" # type:ignore[attr-defined]
28-
return cast(str, self.settings[env].get_template(name)) # type:ignore[attr-defined]
29+
template = cast(Template, self.settings[env].get_template(name)) # type:ignore[attr-defined]
30+
return template
2931
except TemplateNotFound:
30-
return cast(str, super().get_template(name)) # type:ignore[misc]
32+
return cast(Template, super().get_template(name)) # type:ignore[misc]
3133

3234

3335
class ExtensionHandlerMixin:
@@ -81,6 +83,20 @@ def server_config(self) -> Config:
8183
def base_url(self) -> str:
8284
return cast(str, self.settings.get("base_url", "/"))
8385

86+
def render_template(self, name: str, **ns) -> str:
87+
"""Override render template to handle static_paths
88+
89+
If render_template is called with a template from the base environment
90+
(e.g. default error pages)
91+
make sure our extension-specific static_url is _not_ used.
92+
"""
93+
template = cast(Template, self.get_template(name)) # type:ignore[attr-defined]
94+
ns.update(self.template_namespace) # type:ignore[attr-defined]
95+
if template.environment is self.settings["jinja2_env"]:
96+
# default template environment, use default static_url
97+
ns["static_url"] = super().static_url # type:ignore[misc]
98+
return cast(str, template.render(**ns))
99+
84100
@property
85101
def static_url_prefix(self) -> str:
86102
return self.extensionapp.static_url_prefix

tests/extension/mockextensions/__init__.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
to load in various tests.
33
"""
44

5-
from .app import MockExtensionApp
5+
from .app import MockExtensionApp, MockExtensionNoTemplateApp
66

77

88
# Function that makes these extensions discoverable
@@ -13,6 +13,10 @@ def _jupyter_server_extension_points():
1313
"module": "tests.extension.mockextensions.app",
1414
"app": MockExtensionApp,
1515
},
16+
{
17+
"module": "tests.extension.mockextensions.app",
18+
"app": MockExtensionNoTemplateApp,
19+
},
1620
{"module": "tests.extension.mockextensions.mock1"},
1721
{"module": "tests.extension.mockextensions.mock2"},
1822
{"module": "tests.extension.mockextensions.mock3"},

tests/extension/mockextensions/app.py

+26-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from jupyter_events import EventLogger
66
from jupyter_events.schema_registry import SchemaRegistryException
7+
from tornado import web
78
from traitlets import List, Unicode
89

910
from jupyter_server.base.handlers import JupyterHandler
@@ -44,14 +45,24 @@ def get(self):
4445
self.write(self.render_template("index.html"))
4546

4647

48+
class MockExtensionErrorHandler(ExtensionHandlerMixin, JupyterHandler):
49+
def get(self):
50+
raise web.HTTPError(418)
51+
52+
4753
class MockExtensionApp(ExtensionAppJinjaMixin, ExtensionApp):
4854
name = "mockextension"
4955
template_paths: List[str] = List().tag(config=True) # type:ignore[assignment]
5056
static_paths = [STATIC_PATH] # type:ignore[assignment]
5157
mock_trait = Unicode("mock trait", config=True)
5258
loaded = False
5359

54-
serverapp_config = {"jpserver_extensions": {"tests.extension.mockextensions.mock1": True}}
60+
serverapp_config = {
61+
"jpserver_extensions": {
62+
"tests.extension.mockextensions.mock1": True,
63+
"tests.extension.mockextensions.app.mockextension_notemplate": True,
64+
}
65+
}
5566

5667
@staticmethod
5768
def get_extension_package():
@@ -69,6 +80,20 @@ def initialize_settings(self):
6980
def initialize_handlers(self):
7081
self.handlers.append(("/mock", MockExtensionHandler))
7182
self.handlers.append(("/mock_template", MockExtensionTemplateHandler))
83+
self.handlers.append(("/mock_error_template", MockExtensionErrorHandler))
84+
self.loaded = True
85+
86+
87+
class MockExtensionNoTemplateApp(ExtensionApp):
88+
name = "mockextension_notemplate"
89+
loaded = False
90+
91+
@staticmethod
92+
def get_extension_package():
93+
return "tests.extension.mockextensions"
94+
95+
def initialize_handlers(self):
96+
self.handlers.append(("/mock_error_notemplate", MockExtensionErrorHandler))
7297
self.loaded = True
7398

7499

tests/extension/test_app.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,14 @@ async def _stop(*args):
171171
"Shutting down 2 extensions",
172172
"jupyter_server_terminals | extension app 'jupyter_server_terminals' stopping",
173173
f"{extension_name} | extension app 'mockextension' stopping",
174+
f"{extension_name} | extension app 'mockextension_notemplate' stopping",
174175
"jupyter_server_terminals | extension app 'jupyter_server_terminals' stopped",
175176
f"{extension_name} | extension app 'mockextension' stopped",
177+
f"{extension_name} | extension app 'mockextension_notemplate' stopped",
176178
}
177179

178180
# check the shutdown method was called twice
179-
assert calls == 2
181+
assert calls == 3
180182

181183

182184
async def test_events(jp_serverapp, jp_fetch):

tests/extension/test_handler.py

+69
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
from html.parser import HTMLParser
2+
13
import pytest
4+
from tornado.httpclient import HTTPClientError
25

36

47
@pytest.fixture
@@ -118,3 +121,69 @@ async def test_base_url(jp_fetch, jp_server_config, jp_base_url):
118121
assert r.code == 200
119122
body = r.body.decode()
120123
assert "mock static content" in body
124+
125+
126+
class StylesheetFinder(HTMLParser):
127+
"""Minimal HTML parser to find iframe.src attr"""
128+
129+
def __init__(self):
130+
super().__init__()
131+
self.stylesheets = []
132+
self.body_chunks = []
133+
self.in_head = False
134+
self.in_body = False
135+
self.in_script = False
136+
137+
def handle_starttag(self, tag, attrs):
138+
tag = tag.lower()
139+
if tag == "head":
140+
self.in_head = True
141+
elif tag == "body":
142+
self.in_body = True
143+
elif tag == "script":
144+
self.in_script = True
145+
elif self.in_head and tag.lower() == "link":
146+
attr_dict = dict(attrs)
147+
if attr_dict.get("rel", "").lower() == "stylesheet":
148+
self.stylesheets.append(attr_dict["href"])
149+
150+
def handle_endtag(self, tag):
151+
if tag == "head":
152+
self.in_head = False
153+
if tag == "body":
154+
self.in_body = False
155+
if tag == "script":
156+
self.in_script = False
157+
158+
def handle_data(self, data):
159+
if self.in_body and not self.in_script:
160+
data = data.strip()
161+
if data:
162+
self.body_chunks.append(data)
163+
164+
165+
def find_stylesheets_body(html):
166+
"""Find the href= attr of stylesheets
167+
168+
and body text of an HTML document
169+
170+
stylesheets are used to test static_url prefix
171+
"""
172+
finder = StylesheetFinder()
173+
finder.feed(html)
174+
return (finder.stylesheets, "\n".join(finder.body_chunks))
175+
176+
177+
@pytest.mark.parametrize("error_url", ["mock_error_template", "mock_error_notemplate"])
178+
async def test_error_render(jp_fetch, jp_serverapp, jp_base_url, error_url):
179+
with pytest.raises(HTTPClientError) as e:
180+
await jp_fetch(error_url, method="GET")
181+
r = e.value.response
182+
assert r.code == 418
183+
assert r.headers["Content-Type"] == "text/html"
184+
html = r.body.decode("utf8")
185+
stylesheets, body = find_stylesheets_body(html)
186+
static_prefix = f"{jp_base_url}static/"
187+
assert stylesheets
188+
assert all(stylesheet.startswith(static_prefix) for stylesheet in stylesheets)
189+
assert str(r.code) in body

0 commit comments

Comments
 (0)