Skip to content

Commit 01e7d2e

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 5280377 commit 01e7d2e

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
@@ -1641,22 +1641,11 @@ def _normalize_dir(self, value):
16411641
value = os.path.abspath(value)
16421642
return value
16431643

1644-
# Because the validation of preferred_dir depends on root_dir and validation
1645-
# occurs when the trait is loaded, there are times when we should defer the
1646-
# validation of preferred_dir (e.g., when preferred_dir is defined via CLI
1647-
# and root_dir is defined via a config file).
1648-
_defer_preferred_dir_validation = False
1649-
16501644
@validate("root_dir")
16511645
def _root_dir_validate(self, proposal):
16521646
value = self._normalize_dir(proposal["value"])
16531647
if not os.path.isdir(value):
16541648
raise TraitError(trans.gettext("No such directory: '%r'") % value)
1655-
1656-
if self._defer_preferred_dir_validation:
1657-
# If we're here, then preferred_dir is configured on the CLI and
1658-
# root_dir is configured in a file
1659-
self._preferred_dir_validation(self.preferred_dir, value)
16601649
return value
16611650

16621651
preferred_dir = Unicode(
@@ -1673,39 +1662,8 @@ def _preferred_dir_validate(self, proposal):
16731662
value = self._normalize_dir(proposal["value"])
16741663
if not os.path.isdir(value):
16751664
raise TraitError(trans.gettext("No such preferred dir: '%r'") % value)
1676-
1677-
# Before we validate against root_dir, check if this trait is defined on the CLI
1678-
# and root_dir is not. If that's the case, we'll defer it's further validation
1679-
# until root_dir is validated or the server is starting (the latter occurs when
1680-
# the default root_dir (cwd) is used).
1681-
cli_config = self.cli_config.get("ServerApp", {})
1682-
if "preferred_dir" in cli_config and "root_dir" not in cli_config:
1683-
self._defer_preferred_dir_validation = True
1684-
1685-
if not self._defer_preferred_dir_validation: # Validate now
1686-
self._preferred_dir_validation(value, self.root_dir)
16871665
return value
16881666

1689-
def _preferred_dir_validation(self, preferred_dir: str, root_dir: str) -> None:
1690-
"""Validate preferred dir relative to root_dir - preferred_dir must be equal or a subdir of root_dir"""
1691-
if not preferred_dir.startswith(root_dir):
1692-
raise TraitError(
1693-
trans.gettext(
1694-
"preferred_dir must be equal or a subdir of root_dir. preferred_dir: '%r' root_dir: '%r'"
1695-
)
1696-
% (preferred_dir, root_dir)
1697-
)
1698-
self._defer_preferred_dir_validation = False
1699-
1700-
@observe("root_dir")
1701-
def _root_dir_changed(self, change):
1702-
self._root_dir_set = True
1703-
if not self.preferred_dir.startswith(change["new"]):
1704-
self.log.warning(
1705-
trans.gettext("Value of preferred_dir updated to use value of root_dir")
1706-
)
1707-
self.preferred_dir = change["new"]
1708-
17091667
@observe("server_extensions")
17101668
def _update_server_extensions(self, change):
17111669
self.log.warning(_i18n("server_extensions is deprecated, use jpserver_extensions"))
@@ -1887,6 +1845,9 @@ def init_configurables(self):
18871845
parent=self,
18881846
log=self.log,
18891847
)
1848+
# Trigger a default/validation here explicitly while we still support the
1849+
# deprecated trait on ServerApp (FIXME remove when deprecation finalized)
1850+
self.contents_manager.preferred_dir
18901851
self.session_manager = self.session_manager_class(
18911852
parent=self,
18921853
log=self.log,
@@ -2086,7 +2047,7 @@ def init_resources(self):
20862047
)
20872048
return
20882049

2089-
old_soft, old_hard = resource.getrlimit(resource.RLIMIT_NOFILE)
2050+
old_soft, old_hard = resource.getrlimit(resource.RLIMIT_NOFILE) # noqa
20902051
soft = self.min_open_files_limit
20912052
hard = old_hard
20922053
if old_soft < soft:
@@ -2097,7 +2058,7 @@ def init_resources(self):
20972058
old_soft, soft, old_hard, hard
20982059
)
20992060
)
2100-
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
2061+
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard)) # noqa
21012062

21022063
def _get_urlparts(self, path=None, include_token=False):
21032064
"""Constructs a urllib named tuple, ParseResult,
@@ -2505,10 +2466,6 @@ def initialize(
25052466
# Parse command line, load ServerApp config files,
25062467
# and update ServerApp config.
25072468
super().initialize(argv=argv)
2508-
if self._defer_preferred_dir_validation:
2509-
# If we're here, then preferred_dir is configured on the CLI and
2510-
# root_dir has the default value (cwd)
2511-
self._preferred_dir_validation(self.preferred_dir, self.root_dir)
25122469
if self._dispatching:
25132470
return
25142471
# 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: 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
@@ -75,10 +76,30 @@ def emit(self, data):
7576

7677
root_dir = Unicode("/", config=True)
7778

79+
preferred_dir = Unicode(
80+
"/",
81+
config=True,
82+
help=_i18n(
83+
"Preferred starting directory to use for notebooks, relative to the server root dir."
84+
),
85+
)
86+
87+
@validate("preferred_dir")
88+
def _validate_preferred_dir(self, proposal):
89+
value = proposal["value"]
90+
try:
91+
dir_exists = run_sync(self.dir_exists)(value)
92+
except HTTPError as e:
93+
raise TraitError(e.log_message) from e
94+
if not dir_exists:
95+
raise TraitError(_i18n("Preferred directory not found: %r") % value)
96+
return value
97+
7898
allow_hidden = Bool(False, config=True, help="Allow access to hidden files")
7999

80100
notary = Instance(sign.NotebookNotary)
81101

102+
@default("notary")
82103
def _notary_default(self):
83104
return sign.NotebookNotary(parent=self)
84105

tests/test_gateway.py

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

433433
# create
434-
session_id, kernel_id = await create_session(jp_root_dir, jp_fetch, "kspec_foo")
434+
session_id, kernel_id = await create_session(jp_fetch, "kspec_foo")
435435

436436
# ensure kernel still considered running
437437
assert await is_kernel_running(jp_fetch, kernel_id) is True
@@ -583,12 +583,12 @@ async def test_channel_queue_get_msg_when_response_router_had_finished():
583583
#
584584
# Test methods below...
585585
#
586-
async def create_session(root_dir, jp_fetch, kernel_name):
586+
async def create_session(jp_fetch, kernel_name):
587587
"""Creates a session for a kernel. The session is created against the server
588588
which then uses the gateway for kernel management.
589589
"""
590590
with mocked_gateway:
591-
nb_path = root_dir / "testgw.ipynb"
591+
nb_path = "/testgw.ipynb"
592592
body = json.dumps(
593593
{"path": str(nb_path), "type": "notebook", "kernel": {"name": kernel_name}}
594594
)

0 commit comments

Comments
 (0)