Skip to content

Commit 6579a24

Browse files
committed
Make preferred_dir content manager trait
Move the preferred_dir trait to content manager, but keep the old one for backwards compatibility. The new location should also cause the value to be read later in the init, allowing us to avoid the deferred validation. Also fixes an issue with escaping the root dir on Windows if an absolute path is passed.
1 parent 213e25c commit 6579a24

File tree

5 files changed

+88
-73
lines changed

5 files changed

+88
-73
lines changed

jupyter_server/serverapp.py

Lines changed: 4 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1644,71 +1644,29 @@ def _normalize_dir(self, value):
16441644
value = os.path.abspath(value)
16451645
return value
16461646

1647-
# Because the validation of preferred_dir depends on root_dir and validation
1648-
# occurs when the trait is loaded, there are times when we should defer the
1649-
# validation of preferred_dir (e.g., when preferred_dir is defined via CLI
1650-
# and root_dir is defined via a config file).
1651-
_defer_preferred_dir_validation = False
1652-
16531647
@validate("root_dir")
16541648
def _root_dir_validate(self, proposal):
16551649
value = self._normalize_dir(proposal["value"])
16561650
if not os.path.isdir(value):
16571651
raise TraitError(trans.gettext("No such directory: '%r'") % value)
1658-
1659-
if self._defer_preferred_dir_validation:
1660-
# If we're here, then preferred_dir is configured on the CLI and
1661-
# root_dir is configured in a file
1662-
self._preferred_dir_validation(self.preferred_dir, value)
16631652
return value
16641653

16651654
preferred_dir = Unicode(
1655+
None,
1656+
allow_none=True,
16661657
config=True,
16671658
help=trans.gettext("Preferred starting directory to use for notebooks and kernels."),
16681659
)
16691660

1670-
@default("preferred_dir")
1671-
def _default_prefered_dir(self):
1672-
return self.root_dir
1673-
16741661
@validate("preferred_dir")
16751662
def _preferred_dir_validate(self, proposal):
1663+
if proposal["value"] is None:
1664+
return None
16761665
value = self._normalize_dir(proposal["value"])
16771666
if not os.path.isdir(value):
16781667
raise TraitError(trans.gettext("No such preferred dir: '%r'") % value)
1679-
1680-
# Before we validate against root_dir, check if this trait is defined on the CLI
1681-
# and root_dir is not. If that's the case, we'll defer it's further validation
1682-
# until root_dir is validated or the server is starting (the latter occurs when
1683-
# the default root_dir (cwd) is used).
1684-
cli_config = self.cli_config.get("ServerApp", {})
1685-
if "preferred_dir" in cli_config and "root_dir" not in cli_config:
1686-
self._defer_preferred_dir_validation = True
1687-
1688-
if not self._defer_preferred_dir_validation: # Validate now
1689-
self._preferred_dir_validation(value, self.root_dir)
16901668
return value
16911669

1692-
def _preferred_dir_validation(self, preferred_dir: str, root_dir: str) -> None:
1693-
"""Validate preferred dir relative to root_dir - preferred_dir must be equal or a subdir of root_dir"""
1694-
if not preferred_dir.startswith(root_dir):
1695-
raise TraitError(
1696-
trans.gettext(
1697-
"preferred_dir must be equal or a subdir of root_dir. preferred_dir: '%r' root_dir: '%r'"
1698-
)
1699-
% (preferred_dir, root_dir)
1700-
)
1701-
self._defer_preferred_dir_validation = False
1702-
1703-
@observe("root_dir")
1704-
def _root_dir_changed(self, change):
1705-
self._root_dir_set = True
1706-
if not self.preferred_dir.startswith(change["new"]):
1707-
self.log.warning(
1708-
trans.gettext("Value of preferred_dir updated to use value of root_dir")
1709-
)
1710-
self.preferred_dir = change["new"]
1711-
17121670
@observe("server_extensions")
17131671
def _update_server_extensions(self, change):
17141672
self.log.warning(_i18n("server_extensions is deprecated, use jpserver_extensions"))
@@ -2495,10 +2453,6 @@ def initialize(
24952453
# Parse command line, load ServerApp config files,
24962454
# and update ServerApp config.
24972455
super().initialize(argv=argv)
2498-
if self._defer_preferred_dir_validation:
2499-
# If we're here, then preferred_dir is configured on the CLI and
2500-
# root_dir has the default value (cwd)
2501-
self._preferred_dir_validation(self.preferred_dir, self.root_dir)
25022456
if self._dispatching:
25032457
return
25042458
# initialize io loop as early as possible,

jupyter_server/services/contents/fileio.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,9 @@ def _get_os_path(self, path):
254254
404: if path is outside root
255255
"""
256256
root = os.path.abspath(self.root_dir)
257+
# to_os_path is not safe if path starts with a drive, since os.path.join discards first part
258+
if os.path.splitdrive(path)[0]:
259+
raise HTTPError(404, "%s is not a relative API path" % path)
257260
os_path = to_os_path(path, root)
258261
if not (os.path.abspath(os_path) + os.path.sep).startswith(root):
259262
raise HTTPError(404, "%s is outside root contents directory" % path)

jupyter_server/services/contents/filemanager.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
import shutil
88
import stat
99
import sys
10+
import warnings
1011
from datetime import datetime
12+
from posixpath import isabs
1113

1214
import nbformat
1315
from anyio.to_thread import run_sync
@@ -17,7 +19,7 @@
1719
from traitlets import Bool, TraitError, Unicode, default, validate
1820

1921
from jupyter_server import _tz as tz
20-
from jupyter_server.base.handlers import AuthenticatedFileHandler
22+
from jupyter_server.base.handlers import AuthenticatedFileHandler, HTTPError
2123
from jupyter_server.transutils import _i18n
2224

2325
from .filecheckpoints import AsyncFileCheckpoints, FileCheckpoints
@@ -55,6 +57,40 @@ def _validate_root_dir(self, proposal):
5557
raise TraitError("%r is not a directory" % value)
5658
return value
5759

60+
@default("preferred_dir")
61+
def _default_preferred_dir(self):
62+
try:
63+
value = self.parent.preferred_dir
64+
except AttributeError:
65+
pass
66+
else:
67+
if value is not None:
68+
warnings.warn(
69+
"ServerApp.preferred_dir config is deprecated in jupyter-server 2.0. Use ContentsManager.preferred_dir with a relative path instead",
70+
DeprecationWarning,
71+
stacklevel=3,
72+
)
73+
# For transitioning to relative path, we check if it is a valid relative path:
74+
try:
75+
if not os.path.isabs(value) and self.dir_exists(value):
76+
return value
77+
except HTTPError:
78+
pass
79+
value = self.parent._normalize_dir(value)
80+
if not os.path.isdir(value):
81+
raise TraitError(_i18n("No such directory: '%r'") % value)
82+
if not (value + os.path.sep).startswith(self.root_dir):
83+
raise TraitError("%s is outside root contents directory" % value)
84+
return os.path.relpath(value, self.root_dir).replace(os.path.sep, "/")
85+
return "/"
86+
87+
@validate("preferred_dir")
88+
def _validate_preferred_dir(self, proposal):
89+
try:
90+
super()._validate_preferred_dir(proposal)
91+
except HTTPError as e:
92+
raise TraitError(e.log_message) from e
93+
5894
@default("checkpoints_class")
5995
def _checkpoints_class_default(self):
6096
return FileCheckpoints

jupyter_server/services/contents/manager.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,26 @@ def emit(self, data):
7575

7676
root_dir = Unicode("/", config=True)
7777

78+
preferred_dir = Unicode(
79+
"/",
80+
config=True,
81+
help=_i18n(
82+
"Preferred starting directory to use for notebooks as a path local to `root_dir`."
83+
),
84+
)
85+
86+
@validate("preferred_dir")
87+
def _validate_preferred_dir(self, proposal):
88+
value = proposal["value"]
89+
if not self.dir_exists(value):
90+
raise TraitError(_i18n("No such directory: '%r'") % value)
91+
return value
92+
7893
allow_hidden = Bool(False, config=True, help="Allow access to hidden files")
7994

8095
notary = Instance(sign.NotebookNotary)
8196

97+
@default("notary")
8298
def _notary_default(self):
8399
return sign.NotebookNotary(parent=self)
84100

tests/test_serverapp.py

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,8 @@ def test_valid_preferred_dir_does_not_exist(tmp_path, jp_configurable_serverapp)
283283
assert "No such preferred dir:" in str(error)
284284

285285

286+
# This tests some deprecated behavior as well
287+
@pytest.mark.filterwarnings("ignore::DeprecationWarning")
286288
@pytest.mark.parametrize(
287289
"root_dir_loc,preferred_dir_loc",
288290
[
@@ -333,13 +335,17 @@ def test_preferred_dir_validation(
333335
kwargs["argv"] = argv # type:ignore
334336

335337
if root_dir_loc == "default" and preferred_dir_loc != "default": # error expected
336-
with pytest.raises(SystemExit):
337-
jp_configurable_serverapp(**kwargs)
338+
app = jp_configurable_serverapp(**kwargs)
339+
with pytest.raises(TraitError):
340+
app.contents_manager.preferred_dir
338341
else:
339342
app = jp_configurable_serverapp(**kwargs)
340343
assert app.root_dir == expected_root_dir
341-
assert app.preferred_dir == expected_preferred_dir
342-
assert app.preferred_dir.startswith(app.root_dir)
344+
rel = os.path.relpath(expected_preferred_dir, expected_root_dir)
345+
if rel == ".":
346+
rel = "/"
347+
assert app.contents_manager.preferred_dir == rel
348+
assert ".." not in rel
343349

344350

345351
def test_invalid_preferred_dir_does_not_exist(tmp_path, jp_configurable_serverapp):
@@ -362,42 +368,42 @@ def test_invalid_preferred_dir_does_not_exist_set(tmp_path, jp_configurable_serv
362368
assert "No such preferred dir:" in str(error)
363369

364370

371+
@pytest.mark.filterwarnings("ignore::DeprecationWarning")
365372
def test_invalid_preferred_dir_not_root_subdir(tmp_path, jp_configurable_serverapp):
366373
path = str(tmp_path / "subdir")
367374
os.makedirs(path, exist_ok=True)
368375
not_subdir_path = str(tmp_path)
369376

370377
with pytest.raises(TraitError) as error:
371378
app = jp_configurable_serverapp(root_dir=path, preferred_dir=not_subdir_path)
379+
# reading the value triggers default generator:
380+
app.contents_manager.preferred_dir
372381

373-
assert "preferred_dir must be equal or a subdir of root_dir. " in str(error)
382+
assert "is outside root contents directory" in str(error)
374383

375384

376385
def test_invalid_preferred_dir_not_root_subdir_set(tmp_path, jp_configurable_serverapp):
377386
path = str(tmp_path / "subdir")
378387
os.makedirs(path, exist_ok=True)
379-
not_subdir_path = str(tmp_path)
388+
not_subdir_path = os.path.relpath(tmp_path, path)
380389

381390
app = jp_configurable_serverapp(root_dir=path)
382391
with pytest.raises(TraitError) as error:
383-
app.preferred_dir = not_subdir_path
384-
385-
assert "preferred_dir must be equal or a subdir of root_dir. " in str(error)
392+
app.contents_manager.preferred_dir = not_subdir_path
386393

394+
assert "is outside root contents directory" in str(error)
387395

388-
def test_observed_root_dir_updates_preferred_dir(tmp_path, jp_configurable_serverapp):
389-
path = str(tmp_path)
390-
new_path = str(tmp_path / "subdir")
391-
os.makedirs(new_path, exist_ok=True)
392396

393-
app = jp_configurable_serverapp(root_dir=path, preferred_dir=path)
394-
app.root_dir = new_path
395-
assert app.preferred_dir == new_path
397+
def test_absolute_preferred_dir_not_root_subdir_set(tmp_path, jp_configurable_serverapp):
398+
path = str(tmp_path / "subdir")
399+
os.makedirs(path, exist_ok=True)
400+
not_subdir_path = str(tmp_path)
396401

402+
app = jp_configurable_serverapp(root_dir=path)
403+
with pytest.raises(TraitError) as error:
404+
app.contents_manager.preferred_dir = not_subdir_path
397405

398-
def test_observed_root_dir_does_not_update_preferred_dir(tmp_path, jp_configurable_serverapp):
399-
path = str(tmp_path)
400-
new_path = str(tmp_path.parent)
401-
app = jp_configurable_serverapp(root_dir=path, preferred_dir=path)
402-
app.root_dir = new_path
403-
assert app.preferred_dir == path
406+
if os.name == "nt":
407+
assert "is not a relative API path" in str(error)
408+
else:
409+
assert "is outside root contents directory" in str(error)

0 commit comments

Comments
 (0)