Skip to content

Commit a55bc58

Browse files
authored
Make preferred_dir content manager trait (#983)
* fix terminal test on Windows * 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. * Remove leading "/" from preferred_dir Also validates set value to strip any leading slashes * Allow filemanager to have OS path set Will transparently convert to API path in validator. * fix mypy * fix ruff
1 parent f52aa71 commit a55bc58

File tree

7 files changed

+134
-94
lines changed

7 files changed

+134
-94
lines changed

jupyter_server/serverapp.py

+3-46
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,
@@ -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

+3
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

+31
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
@@ -19,6 +21,7 @@
1921
from jupyter_server import _tz as tz
2022
from jupyter_server.base.handlers import AuthenticatedFileHandler
2123
from jupyter_server.transutils import _i18n
24+
from jupyter_server.utils import to_api_path
2225

2326
from .filecheckpoints import AsyncFileCheckpoints, FileCheckpoints
2427
from .fileio import AsyncFileManagerMixin, FileManagerMixin
@@ -55,6 +58,34 @@ def _validate_root_dir(self, proposal):
5558
raise TraitError("%r is not a directory" % value)
5659
return value
5760

61+
@default("preferred_dir")
62+
def _default_preferred_dir(self):
63+
try:
64+
value = self.parent.preferred_dir
65+
if value == self.parent.root_dir:
66+
value = None
67+
except AttributeError:
68+
pass
69+
else:
70+
if value is not None:
71+
warnings.warn(
72+
"ServerApp.preferred_dir config is deprecated in jupyter-server 2.0. Use FileContentsManager.preferred_dir instead",
73+
FutureWarning,
74+
stacklevel=3,
75+
)
76+
try:
77+
path = Path(value)
78+
return path.relative_to(self.root_dir).as_posix()
79+
except ValueError:
80+
raise TraitError("%s is outside root contents directory" % value) from None
81+
return ""
82+
83+
@validate("preferred_dir")
84+
def _validate_preferred_dir(self, proposal):
85+
# It should be safe to pass an API path through this method:
86+
proposal["value"] = to_api_path(proposal["value"], self.root_dir)
87+
return super()._validate_preferred_dir(proposal)
88+
5889
@default("checkpoints_class")
5990
def _checkpoints_class_default(self):
6091
return FileCheckpoints

jupyter_server/services/contents/manager.py

+21
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. This is an API path (`/` separated, relative to root dir)"
73+
),
74+
)
75+
76+
@validate("preferred_dir")
77+
def _validate_preferred_dir(self, proposal):
78+
value = proposal["value"].strip("/")
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

+3-3
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)