Skip to content

Commit 1bbcbcb

Browse files
committed
Enable extensions to control the file_to_run
1 parent 01f0f43 commit 1bbcbcb

File tree

4 files changed

+223
-43
lines changed

4 files changed

+223
-43
lines changed

jupyter_server/extension/application.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ def get_extension_point(cls):
186186
def _default_url(self):
187187
return self.extension_url
188188

189+
file_url_prefix = Unicode('notebooks')
190+
189191
# Is this linked to a serverapp yet?
190192
_linked = Bool(False)
191193

@@ -337,7 +339,8 @@ def _jupyter_server_config(self):
337339
base_config = {
338340
"ServerApp": {
339341
"default_url": self.default_url,
340-
"open_browser": self.open_browser
342+
"open_browser": self.open_browser,
343+
"file_url_prefix": self.file_url_prefix
341344
}
342345
}
343346
base_config["ServerApp"].update(self.serverapp_config)

jupyter_server/pytest_plugin.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,7 @@ def jp_serverapp(
278278
"""Starts a Jupyter Server instance based on the established configuration values."""
279279
app = jp_configurable_serverapp(config=jp_server_config, argv=jp_argv)
280280
yield app
281-
app.remove_server_info_file()
282-
app.remove_browser_open_file()
283-
app.cleanup_kernels()
281+
app._cleanup()
284282

285283

286284
@pytest.fixture

jupyter_server/serverapp.py

Lines changed: 127 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import webbrowser
3333
import urllib
3434
import inspect
35+
import pathlib
3536

3637
from base64 import encodebytes
3738
try:
@@ -102,7 +103,13 @@
102103
from jupyter_server._sysinfo import get_sys_info
103104

104105
from ._tz import utcnow, utcfromtimestamp
105-
from .utils import url_path_join, check_pid, url_escape, urljoin, pathname2url
106+
from .utils import (
107+
url_path_join,
108+
check_pid,
109+
url_escape,
110+
urljoin,
111+
pathname2url
112+
)
106113

107114
from jupyter_server.extension.serverextension import ServerExtensionApp
108115
from jupyter_server.extension.manager import ExtensionManager
@@ -620,10 +627,15 @@ def _default_log_format(self):
620627
return u"%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s]%(end_color)s %(message)s"
621628

622629
# file to be opened in the Jupyter server
623-
file_to_run = Unicode('', config=True)
630+
file_to_run = Unicode('',
631+
help="Open the named file when the application is launched."
632+
).tag(config=True)
624633

625-
# Network related information
634+
file_url_prefix = Unicode('notebooks',
635+
help="The URL prefix where files are opened directly."
636+
).tag(config=True)
626637

638+
# Network related information
627639
allow_origin = Unicode('', config=True,
628640
help="""Set the Access-Control-Allow-Origin header
629641
@@ -1195,6 +1207,13 @@ def _default_browser_open_file(self):
11951207
basename = "jpserver-%s-open.html" % os.getpid()
11961208
return os.path.join(self.runtime_dir, basename)
11971209

1210+
browser_open_file_to_run = Unicode()
1211+
1212+
@default('browser_open_file_to_run')
1213+
def _default_browser_open_file_to_run(self):
1214+
basename = "jpserver-file-to-run-%s-open.html" % os.getpid()
1215+
return os.path.join(self.runtime_dir, basename)
1216+
11981217
pylab = Unicode('disabled', config=True,
11991218
help=_("""
12001219
DISABLED: use %pylab or %matplotlib in the notebook to enable matplotlib.
@@ -1254,7 +1273,7 @@ def _root_dir_validate(self, proposal):
12541273
# If we receive a non-absolute path, make it absolute.
12551274
value = os.path.abspath(value)
12561275
if not os.path.isdir(value):
1257-
raise TraitError(trans.gettext("No such notebook dir: '%r'") % value)
1276+
raise TraitError(trans.gettext("No such directory: '%r'") % value)
12581277
return value
12591278

12601279
@observe('root_dir')
@@ -1874,10 +1893,71 @@ def remove_server_info_file(self):
18741893
os.unlink(self.info_file)
18751894
except OSError as e:
18761895
if e.errno != errno.ENOENT:
1877-
raise
1896+
raise;
1897+
1898+
def _resolve_file_to_run_and_root_dir(self):
1899+
"""Returns a relative path from file_to_run
1900+
to root_dir. If root_dir and file_to_run
1901+
are incompatible, i.e. on different subtrees,
1902+
crash the app and log a critical message. Note
1903+
that if root_dir is not configured and file_to_run
1904+
is configured, root_dir will be set to the parent
1905+
directory of file_to_run.
1906+
"""
1907+
rootdir_abspath = pathlib.Path(self.root_dir).resolve()
1908+
file_rawpath = pathlib.Path(self.file_to_run)
1909+
combined_path = (rootdir_abspath / file_rawpath).resolve()
1910+
is_child = str(combined_path).startswith(str(rootdir_abspath))
1911+
1912+
if is_child:
1913+
if combined_path.parent != rootdir_abspath:
1914+
self.log.debug(
1915+
"The `root_dir` trait is set to a directory that's not "
1916+
"the immediate parent directory of `file_to_run`. Note that "
1917+
"the server will start at `root_dir` and open the "
1918+
"the file from the relative path to the `root_dir`."
1919+
)
1920+
return str(combined_path.relative_to(rootdir_abspath))
1921+
1922+
self.log.critical(
1923+
"`root_dir` and `file_to_run` are incompatible. They "
1924+
"don't share the same subtrees. Make sure `file_to_run` "
1925+
"is on the same path as `root_dir`."
1926+
)
1927+
self.exit(1)
1928+
1929+
def _write_browser_open_file(self, url, fh):
1930+
if self.token:
1931+
url = url_concat(url, {'token': self.token})
1932+
url = url_path_join(self.connection_url, url)
1933+
1934+
jinja2_env = self.web_app.settings['jinja2_env']
1935+
template = jinja2_env.get_template('browser-open.html')
1936+
fh.write(template.render(open_url=url, base_url=self.base_url))
1937+
1938+
def write_browser_open_files(self):
1939+
"""Write an `browser_open_file` and `browser_open_file_to_run` files
1940+
1941+
This can be used to open a file directly in a browser.
1942+
"""
1943+
# default_url contains base_url, but so does connection_url
1944+
self.write_browser_open_file()
1945+
1946+
# Create a second browser open file if
1947+
# file_to_run is set.
1948+
if self.file_to_run:
1949+
# Make sure file_to_run and root_dir are compatible.
1950+
file_to_run_relpath = self._resolve_file_to_run_and_root_dir()
1951+
1952+
file_open_url = url_escape(
1953+
url_path_join(self.file_url_prefix, *file_to_run_relpath.split(os.sep))
1954+
)
1955+
1956+
with open(self.browser_open_file_to_run, 'w', encoding='utf-8') as f:
1957+
self._write_browser_open_file(file_open_url, f)
18781958

18791959
def write_browser_open_file(self):
1880-
"""Write an nbserver-<pid>-open.html file
1960+
"""Write an jpserver-<pid>-open.html file
18811961
18821962
This can be used to open the notebook in a browser
18831963
"""
@@ -1887,17 +1967,21 @@ def write_browser_open_file(self):
18871967
with open(self.browser_open_file, 'w', encoding='utf-8') as f:
18881968
self._write_browser_open_file(open_url, f)
18891969

1890-
def _write_browser_open_file(self, url, fh):
1891-
if self.token:
1892-
url = url_concat(url, {'token': self.token})
1893-
url = url_path_join(self.connection_url, url)
1970+
def remove_browser_open_files(self):
1971+
"""Remove the `browser_open_file` and `browser_open_file_to_run` files
1972+
created for this server.
18941973
1895-
jinja2_env = self.web_app.settings['jinja2_env']
1896-
template = jinja2_env.get_template('browser-open.html')
1897-
fh.write(template.render(open_url=url, base_url=self.base_url))
1974+
Ignores the error raised when the file has already been removed.
1975+
"""
1976+
self.remove_browser_open_file()
1977+
try:
1978+
os.unlink(self.browser_open_file_to_run)
1979+
except OSError as e:
1980+
if e.errno != errno.ENOENT:
1981+
raises
18981982

18991983
def remove_browser_open_file(self):
1900-
"""Remove the nbserver-<pid>-open.html file created for this server.
1984+
"""Remove the jpserver-<pid>-open.html file created for this server.
19011985
19021986
Ignores the error raised when the file has already been removed.
19031987
"""
@@ -1907,42 +1991,40 @@ def remove_browser_open_file(self):
19071991
if e.errno != errno.ENOENT:
19081992
raise
19091993

1910-
def launch_browser(self):
1911-
try:
1912-
browser = webbrowser.get(self.browser or None)
1913-
except webbrowser.Error as e:
1914-
self.log.warning(_('No web browser found: %s.') % e)
1915-
browser = None
1916-
1917-
if not browser:
1918-
return
1919-
1994+
def _prepare_browser_open(self):
19201995
if not self.use_redirect_file:
19211996
uri = self.default_url[len(self.base_url):]
19221997

19231998
if self.token:
19241999
uri = url_concat(uri, {'token': self.token})
19252000

19262001
if self.file_to_run:
1927-
if not os.path.exists(self.file_to_run):
1928-
self.log.critical(_("%s does not exist") % self.file_to_run)
1929-
self.exit(1)
1930-
1931-
relpath = os.path.relpath(self.file_to_run, self.root_dir)
1932-
uri = url_escape(url_path_join('notebooks', *relpath.split(os.sep)))
1933-
1934-
# Write a temporary file to open in the browser
1935-
fd, open_file = tempfile.mkstemp(suffix='.html')
1936-
with open(fd, 'w', encoding='utf-8') as fh:
1937-
self._write_browser_open_file(uri, fh)
2002+
# Create a separate, temporary open-browser-file
2003+
# pointing at a specific file.
2004+
open_file = self.browser_open_file_to_run
19382005
else:
2006+
# otherwise, just return the usual open browser file.
19392007
open_file = self.browser_open_file
19402008

19412009
if self.use_redirect_file:
19422010
assembled_url = urljoin('file:', pathname2url(open_file))
19432011
else:
19442012
assembled_url = url_path_join(self.connection_url, uri)
19452013

2014+
return assembled_url, open_file
2015+
2016+
def launch_browser(self):
2017+
try:
2018+
browser = webbrowser.get(self.browser or None)
2019+
except webbrowser.Error as e:
2020+
self.log.warning(_('No web browser found: %s.') % e)
2021+
browser = None
2022+
2023+
if not browser:
2024+
return
2025+
2026+
assembled_url, _ = self._prepare_browser_open()
2027+
19462028
b = lambda: browser.open(assembled_url, new=self.webbrowser_open_new)
19472029
threading.Thread(target=b).start()
19482030

@@ -1970,7 +2052,7 @@ def start_app(self):
19702052
"resources section at https://jupyter.org/community.html."))
19712053

19722054
self.write_server_info_file()
1973-
self.write_browser_open_file()
2055+
self.write_browser_open_files()
19742056

19752057
# Handle the browser opening.
19762058
if self.open_browser:
@@ -1987,6 +2069,14 @@ def start_app(self):
19872069
' %s' % self.display_url,
19882070
]))
19892071

2072+
def _cleanup(self):
2073+
"""General cleanup of files and kernels created
2074+
by this instance ServerApp.
2075+
"""
2076+
self.remove_server_info_file()
2077+
self.remove_browser_open_files()
2078+
self.cleanup_kernels()
2079+
19902080
def start_ioloop(self):
19912081
"""Start the IO Loop."""
19922082
self.io_loop = ioloop.IOLoop.current()
@@ -2000,9 +2090,7 @@ def start_ioloop(self):
20002090
except KeyboardInterrupt:
20012091
self.log.info(_("Interrupted..."))
20022092
finally:
2003-
self.remove_server_info_file()
2004-
self.remove_browser_open_file()
2005-
self.cleanup_kernels()
2093+
self._cleanup()
20062094

20072095
def start(self):
20082096
""" Start the Jupyter server app, after initialization

tests/test_serverapp.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import getpass
3+
import pathlib
34
import pytest
45
import logging
56
from unittest.mock import patch
@@ -117,3 +118,93 @@ def test_list_running_servers(jp_serverapp, jp_web_app):
117118
servers = list(list_running_servers(jp_serverapp.runtime_dir))
118119
assert len(servers) >= 1
119120

121+
122+
@pytest.fixture
123+
def prefix_path(jp_root_dir, tmp_path):
124+
"""If a given path is prefixed with the literal
125+
strings `/jp_root_dir` or `/tmp_path`, replace those
126+
strings with these fixtures.
127+
128+
Returns a pathlib Path object.
129+
"""
130+
def _inner(rawpath):
131+
path = pathlib.PurePosixPath(rawpath)
132+
if rawpath.startswith('/jp_root_dir'):
133+
path = jp_root_dir.joinpath(*path.parts[2:])
134+
elif rawpath.startswith('/tmp_path'):
135+
path = tmp_path.joinpath(*path.parts[2:])
136+
return pathlib.Path(path)
137+
return _inner
138+
139+
140+
@pytest.mark.parametrize(
141+
"root_dir,file_to_run,expected_output",
142+
[
143+
(
144+
None,
145+
'notebook.ipynb',
146+
'notebook.ipynb'
147+
),
148+
(
149+
None,
150+
'/tmp_path/path/to/notebook.ipynb',
151+
'notebook.ipynb'
152+
),
153+
(
154+
'/jp_root_dir',
155+
'/tmp_path/path/to/notebook.ipynb',
156+
SystemExit
157+
),
158+
(
159+
'/tmp_path',
160+
'/tmp_path/path/to/notebook.ipynb',
161+
'path/to/notebook.ipynb'
162+
),
163+
(
164+
'/jp_root_dir',
165+
'notebook.ipynb',
166+
'notebook.ipynb'
167+
),
168+
(
169+
'/jp_root_dir',
170+
'path/to/notebook.ipynb',
171+
'path/to/notebook.ipynb'
172+
),
173+
]
174+
)
175+
def test_resolve_file_to_run_and_root_dir(
176+
prefix_path,
177+
root_dir,
178+
file_to_run,
179+
expected_output
180+
):
181+
# Verify that the Singleton instance is cleared before the test runs.
182+
ServerApp.clear_instance()
183+
184+
# Setup the file_to_run path, in case the server checks
185+
# if the directory exists before initializing the server.
186+
file_to_run = prefix_path(file_to_run)
187+
if file_to_run.is_absolute():
188+
file_to_run.parent.mkdir(parents=True, exist_ok=True)
189+
kwargs = {"file_to_run": str(file_to_run)}
190+
191+
# Setup the root_dir path, in case the server checks
192+
# if the directory exists before initializing the server.
193+
if root_dir:
194+
root_dir = prefix_path(root_dir)
195+
if root_dir.is_absolute():
196+
root_dir.parent.mkdir(parents=True, exist_ok=True)
197+
kwargs["root_dir"] = str(root_dir)
198+
199+
# Create the notebook in the given location
200+
serverapp = ServerApp.instance(**kwargs)
201+
202+
if expected_output is SystemExit:
203+
with pytest.raises(SystemExit):
204+
serverapp._resolve_file_to_run_and_root_dir()
205+
else:
206+
relpath = serverapp._resolve_file_to_run_and_root_dir()
207+
assert relpath == str(pathlib.Path(expected_output))
208+
209+
# Clear the singleton instance after each run.
210+
ServerApp.clear_instance()

0 commit comments

Comments
 (0)