Skip to content

Commit 5ed6074

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 4552011 commit 5ed6074

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

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

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

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

2090-
old_soft, old_hard = resource.getrlimit(resource.RLIMIT_NOFILE)
2051+
old_soft, old_hard = resource.getrlimit(resource.RLIMIT_NOFILE) # noqa
20912052
soft = self.min_open_files_limit
20922053
hard = old_hard
20932054
if old_soft < soft:
@@ -2098,7 +2059,7 @@ def init_resources(self):
20982059
old_soft, soft, old_hard, hard
20992060
)
21002061
)
2101-
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
2062+
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard)) # noqa
21022063

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