Skip to content

Commit 8e4b7ed

Browse files
authored
Reapply preferred_dir fix, now with better backwards compatability (#1162)
* Revert "Revert "Make preferred_dir content manager trait" (#1140)" This reverts commit b5b7c5e. * Write back pref dir to app for backwards compat This way, anyone that reads out the old value will not break (except if the path is not a FS path, in which case it doesn't currently work anyways). * Fix: serverapp needs absolute path
1 parent 88c9339 commit 8e4b7ed

File tree

7 files changed

+140
-94
lines changed

7 files changed

+140
-94
lines changed

jupyter_server/serverapp.py

Lines changed: 3 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1670,22 +1670,11 @@ def _normalize_dir(self, value):
16701670
value = os.path.abspath(value)
16711671
return value
16721672

1673-
# Because the validation of preferred_dir depends on root_dir and validation
1674-
# occurs when the trait is loaded, there are times when we should defer the
1675-
# validation of preferred_dir (e.g., when preferred_dir is defined via CLI
1676-
# and root_dir is defined via a config file).
1677-
_defer_preferred_dir_validation = False
1678-
16791673
@validate("root_dir")
16801674
def _root_dir_validate(self, proposal):
16811675
value = self._normalize_dir(proposal["value"])
16821676
if not os.path.isdir(value):
16831677
raise TraitError(trans.gettext("No such directory: '%r'") % value)
1684-
1685-
if self._defer_preferred_dir_validation:
1686-
# If we're here, then preferred_dir is configured on the CLI and
1687-
# root_dir is configured in a file
1688-
self._preferred_dir_validation(self.preferred_dir, value)
16891678
return value
16901679

16911680
preferred_dir = Unicode(
@@ -1702,39 +1691,8 @@ def _preferred_dir_validate(self, proposal):
17021691
value = self._normalize_dir(proposal["value"])
17031692
if not os.path.isdir(value):
17041693
raise TraitError(trans.gettext("No such preferred dir: '%r'") % value)
1705-
1706-
# Before we validate against root_dir, check if this trait is defined on the CLI
1707-
# and root_dir is not. If that's the case, we'll defer it's further validation
1708-
# until root_dir is validated or the server is starting (the latter occurs when
1709-
# the default root_dir (cwd) is used).
1710-
cli_config = self.cli_config.get("ServerApp", {})
1711-
if "preferred_dir" in cli_config and "root_dir" not in cli_config:
1712-
self._defer_preferred_dir_validation = True
1713-
1714-
if not self._defer_preferred_dir_validation: # Validate now
1715-
self._preferred_dir_validation(value, self.root_dir)
17161694
return value
17171695

1718-
def _preferred_dir_validation(self, preferred_dir: str, root_dir: str) -> None:
1719-
"""Validate preferred dir relative to root_dir - preferred_dir must be equal or a subdir of root_dir"""
1720-
if not preferred_dir.startswith(root_dir):
1721-
raise TraitError(
1722-
trans.gettext(
1723-
"preferred_dir must be equal or a subdir of root_dir. preferred_dir: '%r' root_dir: '%r'"
1724-
)
1725-
% (preferred_dir, root_dir)
1726-
)
1727-
self._defer_preferred_dir_validation = False
1728-
1729-
@observe("root_dir")
1730-
def _root_dir_changed(self, change):
1731-
self._root_dir_set = True
1732-
if not self.preferred_dir.startswith(change["new"]):
1733-
self.log.warning(
1734-
trans.gettext("Value of preferred_dir updated to use value of root_dir")
1735-
)
1736-
self.preferred_dir = change["new"]
1737-
17381696
@observe("server_extensions")
17391697
def _update_server_extensions(self, change):
17401698
self.log.warning(_i18n("server_extensions is deprecated, use jpserver_extensions"))
@@ -1916,6 +1874,9 @@ def init_configurables(self):
19161874
parent=self,
19171875
log=self.log,
19181876
)
1877+
# Trigger a default/validation here explicitly while we still support the
1878+
# deprecated trait on ServerApp (FIXME remove when deprecation finalized)
1879+
self.contents_manager.preferred_dir
19191880
self.session_manager = self.session_manager_class(
19201881
parent=self,
19211882
log=self.log,
@@ -2551,10 +2512,6 @@ def initialize(
25512512
# Parse command line, load ServerApp config files,
25522513
# and update ServerApp config.
25532514
super().initialize(argv=argv)
2554-
if self._defer_preferred_dir_validation:
2555-
# If we're here, then preferred_dir is configured on the CLI and
2556-
# root_dir has the default value (cwd)
2557-
self._preferred_dir_validation(self.preferred_dir, self.root_dir)
25582515
if self._dispatching:
25592516
return
25602517
# 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: 31 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
@@ -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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
# Distributed under the terms of the Modified BSD License.
44
import itertools
55
import json
6+
import os
67
import re
78
import warnings
89
from fnmatch import fnmatch
910

11+
from jupyter_client.utils import run_sync
1012
from jupyter_core.utils import ensure_async
1113
from jupyter_events import EventLogger
1214
from nbformat import ValidationError, sign
@@ -65,10 +67,35 @@ def emit(self, data):
6567

6668
root_dir = Unicode("/", config=True)
6769

70+
preferred_dir = Unicode(
71+
"",
72+
config=True,
73+
help=_i18n(
74+
"Preferred starting directory to use for notebooks. This is an API path (`/` separated, relative to root dir)"
75+
),
76+
)
77+
78+
@validate("preferred_dir")
79+
def _validate_preferred_dir(self, proposal):
80+
value = proposal["value"].strip("/")
81+
try:
82+
dir_exists = run_sync(self.dir_exists)(value)
83+
except HTTPError as e:
84+
raise TraitError(e.log_message) from e
85+
if not dir_exists:
86+
raise TraitError(_i18n("Preferred directory not found: %r") % value)
87+
try:
88+
if value != self.parent.preferred_dir:
89+
self.parent.preferred_dir = os.path.join(self.root_dir, *value.split("/"))
90+
except (AttributeError, TraitError):
91+
pass
92+
return value
93+
6894
allow_hidden = Bool(False, config=True, help="Allow access to hidden files")
6995

7096
notary = Instance(sign.NotebookNotary)
7197

98+
@default("notary")
7299
def _notary_default(self):
73100
return sign.NotebookNotary(parent=self)
74101

tests/test_gateway.py

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

436436
# create
437-
session_id, kernel_id = await create_session(jp_root_dir, jp_fetch, "kspec_foo")
437+
session_id, kernel_id = await create_session(jp_fetch, "kspec_foo")
438438

439439
# ensure kernel still considered running
440440
assert await is_session_active(jp_fetch, session_id) is True
@@ -629,12 +629,12 @@ async def is_session_active(jp_fetch, session_id):
629629
return False
630630

631631

632-
async def create_session(root_dir, jp_fetch, kernel_name):
632+
async def create_session(jp_fetch, kernel_name):
633633
"""Creates a session for a kernel. The session is created against the server
634634
which then uses the gateway for kernel management.
635635
"""
636636
with mocked_gateway:
637-
nb_path = root_dir / "testgw.ipynb"
637+
nb_path = "/testgw.ipynb"
638638
body = json.dumps(
639639
{"path": str(nb_path), "type": "notebook", "kernel": {"name": kernel_name}}
640640
)

0 commit comments

Comments
 (0)