Skip to content

Commit 32b2f74

Browse files
Register pre/post save hooks, call them sequentially (#696)
1 parent c0dd949 commit 32b2f74

File tree

4 files changed

+167
-50
lines changed

4 files changed

+167
-50
lines changed

jupyter_server/services/contents/filemanager.py

Lines changed: 3 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,11 @@
1111

1212
import nbformat
1313
from anyio.to_thread import run_sync
14-
from ipython_genutils.importstring import import_item
1514
from jupyter_core.paths import exists
1615
from jupyter_core.paths import is_file_hidden
1716
from jupyter_core.paths import is_hidden
1817
from send2trash import send2trash
1918
from tornado import web
20-
from traitlets import Any
2119
from traitlets import Bool
2220
from traitlets import default
2321
from traitlets import TraitError
@@ -54,48 +52,6 @@ def _default_root_dir(self):
5452
except AttributeError:
5553
return os.getcwd()
5654

57-
post_save_hook = Any(
58-
None,
59-
config=True,
60-
allow_none=True,
61-
help="""Python callable or importstring thereof
62-
63-
to be called on the path of a file just saved.
64-
65-
This can be used to process the file on disk,
66-
such as converting the notebook to a script or HTML via nbconvert.
67-
68-
It will be called as (all arguments passed by keyword)::
69-
70-
hook(os_path=os_path, model=model, contents_manager=instance)
71-
72-
- path: the filesystem path to the file just written
73-
- model: the model representing the file
74-
- contents_manager: this ContentsManager instance
75-
""",
76-
)
77-
78-
@validate("post_save_hook")
79-
def _validate_post_save_hook(self, proposal):
80-
value = proposal["value"]
81-
if isinstance(value, str):
82-
value = import_item(value)
83-
if not callable(value):
84-
raise TraitError("post_save_hook must be callable")
85-
return value
86-
87-
def run_post_save_hook(self, model, os_path):
88-
"""Run the post-save hook if defined, and log errors"""
89-
if self.post_save_hook:
90-
try:
91-
self.log.debug("Running post-save hook on %s", os_path)
92-
self.post_save_hook(os_path=os_path, model=model, contents_manager=self)
93-
except Exception as e:
94-
self.log.error("Post-save hook failed o-n %s", os_path, exc_info=True)
95-
raise web.HTTPError(
96-
500, "Unexpected error while running post hook save: %s" % e
97-
) from e
98-
9955
@validate("root_dir")
10056
def _validate_root_dir(self, proposal):
10157
"""Do a bit of validation of the root_dir."""
@@ -451,7 +407,7 @@ def save(self, model, path=""):
451407
"""Save the file model and return the model with no content."""
452408
path = path.strip("/")
453409

454-
self.run_pre_save_hook(model=model, path=path)
410+
self.run_pre_save_hooks(model=model, path=path)
455411

456412
if "type" not in model:
457413
raise web.HTTPError(400, "No file type provided")
@@ -491,7 +447,7 @@ def save(self, model, path=""):
491447
if validation_message:
492448
model["message"] = validation_message
493449

494-
self.run_post_save_hook(model=model, os_path=os_path)
450+
self.run_post_save_hooks(model=model, os_path=os_path)
495451

496452
return model
497453

@@ -815,7 +771,7 @@ async def save(self, model, path=""):
815771
if validation_message:
816772
model["message"] = validation_message
817773

818-
self.run_post_save_hook(model=model, os_path=os_path)
774+
self.run_post_save_hooks(model=model, os_path=os_path)
819775

820776
return model
821777

jupyter_server/services/contents/largefilemanager.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def save(self, model, path=""):
5656

5757
# Last chunk
5858
if chunk == -1:
59-
self.run_post_save_hook(model=model, os_path=os_path)
59+
self.run_post_save_hooks(model=model, os_path=os_path)
6060
return model
6161
else:
6262
return super(LargeFileManager, self).save(model, path)
@@ -131,7 +131,7 @@ async def save(self, model, path=""):
131131

132132
# Last chunk
133133
if chunk == -1:
134-
self.run_post_save_hook(model=model, os_path=os_path)
134+
self.run_post_save_hooks(model=model, os_path=os_path)
135135
return model
136136
else:
137137
return await super(AsyncLargeFileManager, self).save(model, path)

jupyter_server/services/contents/manager.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import itertools
55
import json
66
import re
7+
import warnings
78
from fnmatch import fnmatch
89

910
from ipython_genutils.importstring import import_item
@@ -126,10 +127,55 @@ def _validate_pre_save_hook(self, proposal):
126127
value = import_item(self.pre_save_hook)
127128
if not callable(value):
128129
raise TraitError("pre_save_hook must be callable")
130+
if self.pre_save_hook is not None:
131+
warnings.warn(
132+
f"Overriding existing pre_save_hook ({self.pre_save_hook.__name__}) with a new one ({value.__name__}).",
133+
stacklevel=2,
134+
)
135+
return value
136+
137+
post_save_hook = Any(
138+
None,
139+
config=True,
140+
allow_none=True,
141+
help="""Python callable or importstring thereof
142+
143+
to be called on the path of a file just saved.
144+
145+
This can be used to process the file on disk,
146+
such as converting the notebook to a script or HTML via nbconvert.
147+
148+
It will be called as (all arguments passed by keyword)::
149+
150+
hook(os_path=os_path, model=model, contents_manager=instance)
151+
152+
- path: the filesystem path to the file just written
153+
- model: the model representing the file
154+
- contents_manager: this ContentsManager instance
155+
""",
156+
)
157+
158+
@validate("post_save_hook")
159+
def _validate_post_save_hook(self, proposal):
160+
value = proposal["value"]
161+
if isinstance(value, str):
162+
value = import_item(value)
163+
if not callable(value):
164+
raise TraitError("post_save_hook must be callable")
165+
if self.post_save_hook is not None:
166+
warnings.warn(
167+
f"Overriding existing post_save_hook ({self.post_save_hook.__name__}) with a new one ({value.__name__}).",
168+
stacklevel=2,
169+
)
129170
return value
130171

131172
def run_pre_save_hook(self, model, path, **kwargs):
132173
"""Run the pre-save hook if defined, and log errors"""
174+
warnings.warn(
175+
"run_pre_save_hook is deprecated, use run_pre_save_hooks instead.",
176+
DeprecationWarning,
177+
stacklevel=2,
178+
)
133179
if self.pre_save_hook:
134180
try:
135181
self.log.debug("Running pre-save hook on %s", path)
@@ -143,6 +189,77 @@ def run_pre_save_hook(self, model, path, **kwargs):
143189
# which could cause frustrating data loss
144190
self.log.error("Pre-save hook failed on %s", path, exc_info=True)
145191

192+
def run_post_save_hook(self, model, os_path):
193+
"""Run the post-save hook if defined, and log errors"""
194+
warnings.warn(
195+
"run_post_save_hook is deprecated, use run_post_save_hooks instead.",
196+
DeprecationWarning,
197+
stacklevel=2,
198+
)
199+
if self.post_save_hook:
200+
try:
201+
self.log.debug("Running post-save hook on %s", os_path)
202+
self.post_save_hook(os_path=os_path, model=model, contents_manager=self)
203+
except Exception as e:
204+
self.log.error("Post-save hook failed o-n %s", os_path, exc_info=True)
205+
raise HTTPError(500, "Unexpected error while running post hook save: %s" % e) from e
206+
207+
_pre_save_hooks = List()
208+
_post_save_hooks = List()
209+
210+
def register_pre_save_hook(self, hook):
211+
if isinstance(hook, str):
212+
hook = import_item(hook)
213+
if not callable(hook):
214+
raise RuntimeError("hook must be callable")
215+
self._pre_save_hooks.append(hook)
216+
217+
def register_post_save_hook(self, hook):
218+
if isinstance(hook, str):
219+
hook = import_item(hook)
220+
if not callable(hook):
221+
raise RuntimeError("hook must be callable")
222+
self._post_save_hooks.append(hook)
223+
224+
def run_pre_save_hooks(self, model, path, **kwargs):
225+
"""Run the pre-save hooks if any, and log errors"""
226+
pre_save_hooks = [self.pre_save_hook] if self.pre_save_hook is not None else []
227+
pre_save_hooks += self._pre_save_hooks
228+
for pre_save_hook in pre_save_hooks:
229+
try:
230+
self.log.debug("Running pre-save hook on %s", path)
231+
pre_save_hook(model=model, path=path, contents_manager=self, **kwargs)
232+
except HTTPError:
233+
# allow custom HTTPErrors to raise,
234+
# rejecting the save with a message.
235+
raise
236+
except Exception:
237+
# unhandled errors don't prevent saving,
238+
# which could cause frustrating data loss
239+
self.log.error(
240+
"Pre-save hook %s failed on %s",
241+
pre_save_hook.__name__,
242+
path,
243+
exc_info=True,
244+
)
245+
246+
def run_post_save_hooks(self, model, os_path):
247+
"""Run the post-save hooks if any, and log errors"""
248+
post_save_hooks = [self.post_save_hook] if self.post_save_hook is not None else []
249+
post_save_hooks += self._post_save_hooks
250+
for post_save_hook in post_save_hooks:
251+
try:
252+
self.log.debug("Running post-save hook on %s", os_path)
253+
post_save_hook(os_path=os_path, model=model, contents_manager=self)
254+
except Exception as e:
255+
self.log.error(
256+
"Post-save %s hook failed on %s",
257+
post_save_hook.__name__,
258+
os_path,
259+
exc_info=True,
260+
)
261+
raise HTTPError(500, "Unexpected error while running post hook save: %s" % e) from e
262+
146263
checkpoints_class = Type(Checkpoints, config=True)
147264
checkpoints = Instance(Checkpoints, config=True)
148265
checkpoints_kwargs = Dict(config=True)

tests/test_files.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import os
23
from pathlib import Path
34

@@ -58,7 +59,8 @@ async def test_hidden_files(jp_fetch, jp_serverapp, jp_root_dir, maybe_hidden):
5859

5960

6061
async def test_contents_manager(jp_fetch, jp_serverapp, jp_root_dir):
61-
"""make sure ContentsManager returns right files (ipynb, bin, txt)."""
62+
"""make sure ContentsManager returns right files (ipynb, bin, txt).
63+
Also test save file hooks."""
6264
nb = new_notebook(
6365
cells=[
6466
new_markdown_cell("Created by test ³"),
@@ -90,6 +92,48 @@ async def test_contents_manager(jp_fetch, jp_serverapp, jp_root_dir):
9092
assert r.body.decode() == "foobar"
9193

9294

95+
async def test_save_hooks(jp_fetch, jp_serverapp):
96+
# define a first pre-save hook that will change the content of the file before saving
97+
def pre_save_hook1(model, **kwargs):
98+
model["content"] += " was modified"
99+
100+
# define a second pre-save hook that will change the content of the file before saving
101+
def pre_save_hook2(model, **kwargs):
102+
model["content"] += " twice!"
103+
104+
# define a first post-save hook that will change the 'last_modified' date
105+
def post_save_hook1(model, **kwargs):
106+
model["last_modified"] = "yesterday"
107+
108+
# define a second post-save hook that will change the 'last_modified' date
109+
def post_save_hook2(model, **kwargs):
110+
model["last_modified"] += " or tomorrow!"
111+
112+
# register the pre-save hooks
113+
jp_serverapp.contents_manager.register_pre_save_hook(pre_save_hook1)
114+
jp_serverapp.contents_manager.register_pre_save_hook(pre_save_hook2)
115+
116+
# register the post-save hooks
117+
jp_serverapp.contents_manager.register_post_save_hook(post_save_hook1)
118+
jp_serverapp.contents_manager.register_post_save_hook(post_save_hook2)
119+
120+
# send a request to save a file, with an original content
121+
# the 'last_modified' returned model field should have been modified by post_save_hook1 then post_save_hook2
122+
r = await jp_fetch(
123+
"api/contents/test.txt",
124+
method="PUT",
125+
body=json.dumps(
126+
{"format": "text", "path": "test.txt", "type": "file", "content": "original content"}
127+
),
128+
)
129+
assert json.loads(r.body.decode())["last_modified"] == "yesterday or tomorrow!"
130+
131+
# read the file back
132+
# the original content should have been modified by pre_save_hook1 then pre_save_hook2
133+
r = await jp_fetch("files/test.txt", method="GET")
134+
assert r.body.decode() == "original content was modified twice!"
135+
136+
93137
async def test_download(jp_fetch, jp_serverapp, jp_root_dir):
94138
text = "hello"
95139
jp_root_dir.joinpath("test.txt").write_text(text)

0 commit comments

Comments
 (0)