Skip to content

Commit d8ebeb1

Browse files
authored
Fallback to direct write for readonly dirs and use temp path for checkpoints (#1516)
1 parent bacb08a commit d8ebeb1

File tree

3 files changed

+40
-0
lines changed

3 files changed

+40
-0
lines changed

jupyter_server/services/contents/filecheckpoints.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import os
66
import shutil
7+
import tempfile
78

89
from anyio.to_thread import run_sync
910
from jupyter_core.utils import ensure_dir_exists
@@ -111,6 +112,10 @@ def checkpoint_path(self, checkpoint_id, path):
111112
filename = f"{basename}-{checkpoint_id}{ext}"
112113
os_path = self._get_os_path(path=parent)
113114
cp_dir = os.path.join(os_path, self.checkpoint_dir)
115+
# If parent directory isn't writable, use system temp
116+
if not os.access(os.path.dirname(cp_dir), os.W_OK):
117+
rel = os.path.relpath(os_path, start=self.root_dir)
118+
cp_dir = os.path.join(tempfile.gettempdir(), "jupyter_checkpoints", rel)
114119
with self.perm_to_403():
115120
ensure_dir_exists(cp_dir)
116121
cp_path = os.path.join(cp_dir, filename)

jupyter_server/services/contents/fileio.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,21 @@ def atomic_writing(path, text=True, encoding="utf-8", log=None, **kwargs):
112112
if os.path.islink(path):
113113
path = os.path.join(os.path.dirname(path), os.readlink(path))
114114

115+
# Fall back to direct write for existing file in a non-writable dir
116+
dirpath = os.path.dirname(path) or os.getcwd()
117+
if os.path.isfile(path) and not os.access(dirpath, os.W_OK) and os.access(path, os.W_OK):
118+
mode = "w" if text else "wb"
119+
# direct open on the target file
120+
if text:
121+
fileobj = open(path, mode, encoding=encoding, **kwargs) # noqa: SIM115
122+
else:
123+
fileobj = open(path, mode, **kwargs) # noqa: SIM115
124+
try:
125+
yield fileobj
126+
finally:
127+
fileobj.close()
128+
return
129+
115130
tmp_path = path_to_intermediate(path)
116131

117132
if os.path.isfile(path):

tests/services/contents/test_fileio.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,26 @@ def test_path_to_invalid(tmpdir):
136136
assert path_to_invalid(tmpdir) == str(tmpdir) + ".invalid"
137137

138138

139+
@pytest.mark.skipif(sys.platform.startswith("win"), reason="requires POSIX directory perms")
140+
def test_atomic_writing_in_readonly_dir(tmp_path):
141+
# Setup: non-writable dir but a writable file inside
142+
nonw = tmp_path / "nonwritable"
143+
nonw.mkdir()
144+
f = nonw / "file.txt"
145+
f.write_text("original content")
146+
os.chmod(str(nonw), 0o500)
147+
os.chmod(str(f), 0o700)
148+
149+
# direct write fallback succeeds
150+
with atomic_writing(str(f)) as ff:
151+
ff.write("new content")
152+
assert f.read_text() == "new content"
153+
154+
# dir perms unchanged
155+
mode = stat.S_IMODE(os.stat(str(nonw)).st_mode)
156+
assert mode == 0o500
157+
158+
139159
@pytest.mark.skipif(os.name == "nt", reason="test fails on Windows")
140160
def test_file_manager_mixin(tmp_path):
141161
mixin = FileManagerMixin()

0 commit comments

Comments
 (0)