Skip to content

Commit 70f65a2

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 37bba4c commit 70f65a2

File tree

6 files changed

+121
-95
lines changed

6 files changed

+121
-95
lines changed

jupyter_server/serverapp.py

Lines changed: 5 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1647,22 +1647,11 @@ def _normalize_dir(self, value):
16471647
value = os.path.abspath(value)
16481648
return value
16491649

1650-
# Because the validation of preferred_dir depends on root_dir and validation
1651-
# occurs when the trait is loaded, there are times when we should defer the
1652-
# validation of preferred_dir (e.g., when preferred_dir is defined via CLI
1653-
# and root_dir is defined via a config file).
1654-
_defer_preferred_dir_validation = False
1655-
16561650
@validate("root_dir")
16571651
def _root_dir_validate(self, proposal):
16581652
value = self._normalize_dir(proposal["value"])
16591653
if not os.path.isdir(value):
16601654
raise TraitError(trans.gettext("No such directory: '%r'") % value)
1661-
1662-
if self._defer_preferred_dir_validation:
1663-
# If we're here, then preferred_dir is configured on the CLI and
1664-
# root_dir is configured in a file
1665-
self._preferred_dir_validation(self.preferred_dir, value)
16661655
return value
16671656

16681657
preferred_dir = Unicode(
@@ -1679,39 +1668,8 @@ def _preferred_dir_validate(self, proposal):
16791668
value = self._normalize_dir(proposal["value"])
16801669
if not os.path.isdir(value):
16811670
raise TraitError(trans.gettext("No such preferred dir: '%r'") % value)
1682-
1683-
# Before we validate against root_dir, check if this trait is defined on the CLI
1684-
# and root_dir is not. If that's the case, we'll defer it's further validation
1685-
# until root_dir is validated or the server is starting (the latter occurs when
1686-
# the default root_dir (cwd) is used).
1687-
cli_config = self.cli_config.get("ServerApp", {})
1688-
if "preferred_dir" in cli_config and "root_dir" not in cli_config:
1689-
self._defer_preferred_dir_validation = True
1690-
1691-
if not self._defer_preferred_dir_validation: # Validate now
1692-
self._preferred_dir_validation(value, self.root_dir)
16931671
return value
16941672

1695-
def _preferred_dir_validation(self, preferred_dir: str, root_dir: str) -> None:
1696-
"""Validate preferred dir relative to root_dir - preferred_dir must be equal or a subdir of root_dir"""
1697-
if not preferred_dir.startswith(root_dir):
1698-
raise TraitError(
1699-
trans.gettext(
1700-
"preferred_dir must be equal or a subdir of root_dir. preferred_dir: '%r' root_dir: '%r'"
1701-
)
1702-
% (preferred_dir, root_dir)
1703-
)
1704-
self._defer_preferred_dir_validation = False
1705-
1706-
@observe("root_dir")
1707-
def _root_dir_changed(self, change):
1708-
self._root_dir_set = True
1709-
if not self.preferred_dir.startswith(change["new"]):
1710-
self.log.warning(
1711-
trans.gettext("Value of preferred_dir updated to use value of root_dir")
1712-
)
1713-
self.preferred_dir = change["new"]
1714-
17151673
@observe("server_extensions")
17161674
def _update_server_extensions(self, change):
17171675
self.log.warning(_i18n("server_extensions is deprecated, use jpserver_extensions"))
@@ -1893,6 +1851,9 @@ def init_configurables(self):
18931851
parent=self,
18941852
log=self.log,
18951853
)
1854+
# Trigger a default/validation here explicitly while we still support the
1855+
# deprecated trait on ServerApp (FIXME remove when deprecation finalized)
1856+
self.contents_manager.preferred_dir
18961857
self.session_manager = self.session_manager_class(
18971858
parent=self,
18981859
log=self.log,
@@ -2092,7 +2053,7 @@ def init_resources(self):
20922053
)
20932054
return
20942055

2095-
old_soft, old_hard = resource.getrlimit(resource.RLIMIT_NOFILE)
2056+
old_soft, old_hard = resource.getrlimit(resource.RLIMIT_NOFILE) # noqa
20962057
soft = self.min_open_files_limit
20972058
hard = old_hard
20982059
if old_soft < soft:
@@ -2103,7 +2064,7 @@ def init_resources(self):
21032064
old_soft, soft, old_hard, hard
21042065
)
21052066
)
2106-
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
2067+
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard)) # noqa
21072068

21082069
def _get_urlparts(self, path=None, include_token=False):
21092070
"""Constructs a urllib named tuple, ParseResult,
@@ -2508,10 +2469,6 @@ def initialize(
25082469
# Parse command line, load ServerApp config files,
25092470
# and update ServerApp config.
25102471
super().initialize(argv=argv)
2511-
if self._defer_preferred_dir_validation:
2512-
# If we're here, then preferred_dir is configured on the CLI and
2513-
# root_dir has the default value (cwd)
2514-
self._preferred_dir_validation(self.preferred_dir, self.root_dir)
25152472
if self._dispatching:
25162473
return
25172474
# 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) # type:ignore
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: 24 additions & 0 deletions
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 pathlib import Path
1113

1214
import nbformat
1315
from anyio.to_thread import run_sync
@@ -55,6 +57,28 @@ 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+
if value == self.parent.root_dir:
65+
value = None
66+
except AttributeError:
67+
pass
68+
else:
69+
if value is not None:
70+
warnings.warn(
71+
"ServerApp.preferred_dir config is deprecated in jupyter-server 2.0. Use ContentsManager.preferred_dir with a relative path instead",
72+
FutureWarning,
73+
stacklevel=3,
74+
)
75+
try:
76+
path = Path(value)
77+
return "/" + path.relative_to(self.root_dir).as_posix()
78+
except ValueError:
79+
raise TraitError("%s is outside root contents directory" % value)
80+
return "/"
81+
5882
@default("checkpoints_class")
5983
def _checkpoints_class_default(self):
6084
return FileCheckpoints

jupyter_server/services/contents/manager.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import warnings
88
from fnmatch import fnmatch
99

10+
from jupyter_client.utils import run_sync
1011
from jupyter_events import EventLogger
1112
from nbformat import ValidationError, sign
1213
from nbformat import validate as validate_nb
@@ -64,10 +65,30 @@ def emit(self, data):
6465

6566
root_dir = Unicode("/", config=True)
6667

68+
preferred_dir = Unicode(
69+
"/",
70+
config=True,
71+
help=_i18n(
72+
"Preferred starting directory to use for notebooks, relative to the server root dir."
73+
),
74+
)
75+
76+
@validate("preferred_dir")
77+
def _validate_preferred_dir(self, proposal):
78+
value = proposal["value"]
79+
try:
80+
dir_exists = run_sync(self.dir_exists)(value)
81+
except HTTPError as e:
82+
raise TraitError(e.log_message) from e
83+
if not dir_exists:
84+
raise TraitError(_i18n("Preferred directory not found: %r") % value)
85+
return value
86+
6787
allow_hidden = Bool(False, config=True, help="Allow access to hidden files")
6888

6989
notary = Instance(sign.NotebookNotary)
7090

91+
@default("notary")
7192
def _notary_default(self):
7293
return sign.NotebookNotary(parent=self)
7394

tests/test_gateway.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ async def test_gateway_session_lifecycle(init_gateway, jp_root_dir, jp_fetch, cu
427427
# Validate session lifecycle functions; create and delete.
428428

429429
# create
430-
session_id, kernel_id = await create_session(jp_root_dir, jp_fetch, "kspec_foo")
430+
session_id, kernel_id = await create_session(jp_fetch, "kspec_foo")
431431

432432
# ensure kernel still considered running
433433
assert await is_session_active(jp_fetch, session_id) is True
@@ -622,12 +622,12 @@ async def is_session_active(jp_fetch, session_id):
622622
return False
623623

624624

625-
async def create_session(root_dir, jp_fetch, kernel_name):
625+
async def create_session(jp_fetch, kernel_name):
626626
"""Creates a session for a kernel. The session is created against the server
627627
which then uses the gateway for kernel management.
628628
"""
629629
with mocked_gateway:
630-
nb_path = root_dir / "testgw.ipynb"
630+
nb_path = "/testgw.ipynb"
631631
body = json.dumps(
632632
{"path": str(nb_path), "type": "notebook", "kernel": {"name": kernel_name}}
633633
)

0 commit comments

Comments
 (0)