Repository: jupyter-server/jupyter_server

Issue:
pre/post save hook registration
### Problem

The way `pre_save_hook` and `post_save_hook` work is by direct attribute assignment in a `FileContentsManager` instance, e.g. `file_contents_manager.pre_save_hook = my_hook` (see [the documentation](https://jupyter-server.readthedocs.io/en/latest/developers/savehooks.html)). This doesn't play well when multiple hooks have to be registered e.g. by different server extensions.

### Proposed Solution

I was thinking about implementing a setter, that would append hooks to a list under the hood, and then call them in sequence. But they are currently implemented as a traitlet. If we register hooks, that also raises the question of how to unregister them.

### Additional context

See https://github.com/jupyterlab/jupyterlab/pull/11599#discussion_r801963771.

Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server) Report
> Merging [#696](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server) (c0cd02c) into [main](https://codecov.io/gh/jupyter-server/jupyter_server/commit/0ab0e1e6a1fa98d63f6ee7c32d3e79450aa192a2?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server) (0ab0e1e) will **decrease** coverage by `0.00%`.
> The diff coverage is `50.00%`.

[![Impacted file tree graph](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696/graphs/tree.svg?width=650&height=150&src=pr&token=S9WiBg2iL0&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server)](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server)

```diff
@@            Coverage Diff             @@
##             main     #696      +/-   ##
==========================================
- Coverage   77.34%   77.34%   -0.01%     
==========================================
  Files         110      110              
  Lines       10542    10548       +6     
  Branches     1427     1270     -157     
==========================================
+ Hits         8154     8158       +4     
+ Misses       1988     1984       -4     
- Partials      400      406       +6     
```


| [Impacted Files](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server) | Coverage Δ | |
|---|---|---|
| [jupyter\_server/services/contents/filemanager.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvc2VydmljZXMvY29udGVudHMvZmlsZW1hbmFnZXIucHk=) | `69.46% <50.00%> (-0.77%)` | :arrow_down: |
| [jupyter\_server/services/contents/manager.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvc2VydmljZXMvY29udGVudHMvbWFuYWdlci5weQ==) | `88.03% <50.00%> (-0.16%)` | :arrow_down: |
| [jupyter\_server/base/zmqhandlers.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvYmFzZS96bXFoYW5kbGVycy5weQ==) | `52.43% <0.00%> (-0.55%)` | :arrow_down: |
| [...ter\_server/tests/services/contents/test\_manager.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvdGVzdHMvc2VydmljZXMvY29udGVudHMvdGVzdF9tYW5hZ2VyLnB5) | `96.72% <0.00%> (-0.24%)` | :arrow_down: |
| [jupyter\_server/serverapp.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvc2VydmVyYXBwLnB5) | `65.38% <0.00%> (-0.19%)` | :arrow_down: |
| [jupyter\_server/gateway/managers.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvZ2F0ZXdheS9tYW5hZ2Vycy5weQ==) | `60.59% <0.00%> (-0.11%)` | :arrow_down: |
| [jupyter\_server/tests/services/kernels/test\_api.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvdGVzdHMvc2VydmljZXMva2VybmVscy90ZXN0X2FwaS5weQ==) | `96.52% <0.00%> (+1.38%)` | :arrow_up: |
| [jupyter\_server/services/kernels/kernelmanager.py](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server#diff-anVweXRlcl9zZXJ2ZXIvc2VydmljZXMva2VybmVscy9rZXJuZWxtYW5hZ2VyLnB5) | `82.48% <0.00%> (+1.84%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server). Last update [0ab0e1e...c0cd02c](https://codecov.io/gh/jupyter-server/jupyter_server/pull/696?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jupyter-server).

davidbrochart: Should we go ahead and merge it? This will allow to move forward with https://github.com/jupyterlab/jupyterlab/pull/11599.
davidbrochart: I just saw [the comments in the team compass](https://github.com/jupyter-server/team-compass/issues/15#issuecomment-1035237276):

> Should probably have different ways to add/remove hooks as needed

I'm not sure, as this would allow e.g. a server extension to remove the hooks added by another extension?

> Order is probably important

It will probably be impossible to enforce a particular order. If e.g. multiple server extensions register hooks, then it is going to be the order in which the extensions are loaded.
I think we should mention that the order in which hooks are called is not known.

> Uniqueness should probably be enforced in case the validator triggers multiple times?

Maybe it should also be possible e.g. or different server extensions to register the same hook?

> Maybe expose a new trait exposing the collection of hooks for manipulation.

I thinks this could be part of another PR, as it seems to require more discussion and specification.
The goal of this PR is to keep the same API, and just allow for multiple hooks instead of overriding an unique hook. I don't want to block https://github.com/jupyterlab/jupyterlab/pull/11599 if a more substantial work is needed.
vidartf: > > Uniqueness should probably be enforced in case the validator triggers multiple times?
> Maybe it should also be possible e.g. or different server extensions to register the same hook?

I'm not sure if we are talking about the same thing. I think the original meaning here was "the same function should not be called twice by the same event". Two different server extensions can install a hook, but if they both register the same function, then the hook should only be called once. For example by making the collection of hooks a set.

> It will probably be impossible to enforce a particular order. If e.g. multiple server extensions register hooks, then it is going to be the order in which the extensions are loaded.

If this is true, then maybe the RTC handler's use of this hook needs to be rethought? AFAICT, order will be very significant for that to function as expected.
davidbrochart: > If this is true, then maybe the RTC handler's use of this hook needs to be rethought? AFAICT, order will be very significant for that to function as expected.

But if the RTC handler won't be able to use this hook, then will any server extension? How can we enforce hook calling order?
davidbrochart: I think it is urgent to move on with https://github.com/jupyterlab/jupyterlab/pull/11599, after what was reported in https://github.com/jupyterlab/jupyterlab/issues/12154, since it will likely solve the issue, but this PR is blocking. I suggest that either:
- we merge this PR as-is,
- or we drop this PR while we work on a better solution, and we show a warning each time a file hook overwrites an existing one.

I am not aware of anyone using the file hooks (did some GitHub search, but I agree it's not enough), so I would be in favor of merging this PR.
Thoughts?
Zsailer: What is `self.post_save_hook` after it has been set multiple times? It's not a list—it's the value of the last time it was called, correct? The list is stored under `self._post_save_hooks`. 

Here's an problem I see using an example: 

If anyone has configured a post_save_hook, which likely lives in a deployment's custom _config_ file (and likely not shared on Github if it's a company), they will expect the value of `post_save_hook` to be the value coming from their config file. However, since extensions would set this trait **after** user config is loaded in the server's start-up sequence ([`init_configurables` happens before `load_server_extension`](https://github.com/jupyter-server/jupyter_server/blob/55d9f6841b73f15d067a7f1f6979e084310014fb/jupyter_server/serverapp.py#L2426-L2432)), the value of this trait would be the post_save_hook provided by the last extension to set it, not user's configuration.
davidbrochart: Which is not different from the current situation: an extension configures a `post_save_hook` and then another one configures a new `post_save_hook`. The first extension expects the value to be the one it has configured, but it's going to be the one from the second extension. Worst, the `post_save_hook` the first extension configured will never be called.
davidbrochart: I think this PR only improves on the current situation, and I'm planning to merge it soon if there is no feedback.
Zsailer: > Which is not different from the current situation: an extension configures a post_save_hook and then another one configures a new post_save_hook. The first extension expects the value to be the one it has configured, but it's going to be the one from the second extension. Worse, the post_save_hook the first extension configured will never be called.

Yes, but this is a bug. That's my point. We shouldn't perpetuate this issue by capitalizing on it here.

This trait wasn't intended to be owned by extensions. As a user—you would expect that your custom configuration supersedes any config set by an extension. In jupyter_server land, where extensions are more prevalent (i.e. everything is an extension), traits like this become an issue. I think we need to fix/deprecate/etc this trait rather than exploit it here.

I would suggest we create a new trait for JupyterLab to use that's a List and deprecate the old. We can iterate on the new trait (e.g. enforcing order, removing duplicates, etc.) to make it more robust over time. 
davidbrochart: Now I'm confused, what is the audience that this feature targets? It seems to me that it's not usable by anybody, and that it should even be removed from the documentation. In my case, it would have saved me a lot of time.
davidbrochart: I can create a new trait that is a `List`, but it could be dangerous to expose this list and allow anybody to change it. Ideally, one would just append to it, but it is easy to override it. Also, I don't know if appending is supported when the trait is set through a config file?
Zsailer: > As a user—you would expect that your custom configuration supersedes any config set by an extension

Sorry for the confusion... "user" is a misleading term to use here. 

The intended us is: a person who runs Jupyter Server or JupyterLab can write a custom function and pass it to the contents manager through the config system using the `ContentsManager.pre_save_hook` in a jupyter config file, e.g.:
```python
# jupyter_server_config.py file 
c.FileContentsManager.pre_save_hook = my_custom_function
```
That's what the documentation is (attempting) to communicate 😅.

I think a good rule of thumb is, configurable traits (i.e. traits with `config=True`) are intended to be configured by **user config** (from config files or CLI), not extensions. If we're going to break this rule, we should carefully consider the consequences.

If JupyterLab overrides this trait after the user config is loaded, `pre_save_hook` will not return `my_custom_function` but JupyterLab's hook. IMO, that is a _bug_ in its current form.

If we change this trait to be a List and allow multiple sources, including extensions, to provide hooks to that list—that seems okay to me. We probably want to add some extra logic to this List trait to address the issues that the comments above mention.
Zsailer: I think we're just debating the "trait-ness" or "configurability" of this API. 😆  Let's just add a new, separate API for registering hooks from extensions? It doesn't have to collide with or go through this trait?
davidbrochart: Yes you're right, a new "non-trait" API sounds good :+1: 
davidbrochart: Not sure what's happening in the CI:
```
File "/opt/hostedtoolcache/Python/3.10.2/x64/lib/python3.10/site-packages/zmq/sugar/context.py", line 190, in term
    return super(Context, self).term()

+++++++++++++++++++++++++++++++++++ Timeout ++++++++++++++++++++++++++++++++++++
tests/services/sessions/test_api.py::test_restart_kernel[jp_server_config0] 
```
blink1073: > Not sure what's happening in the CI

I think this is related to https://github.com/jupyter/jupyter_client/pull/751
davidbrochart: Looks like the CI failures on Windows happen on `main` too.
davidbrochart: I'd like to get this in soon so that we can move forward with https://github.com/jupyterlab/jupyterlab/pull/11599.
Zsailer: I think we need to add unit tests to cover these new APIs, but I'm fine if we do this in a follow up PR given the urgency. I'd just ask that you open an issue so we can track this. 
davidbrochart: I added a test for `register_pre_save_hook`, since that's the one I will use in https://github.com/jupyterlab/jupyterlab/pull/11599.
Post-save hooks seems a bit more complicated to test, since we would need to access the return value (the `model`) of the `save` method.
davidbrochart: Actually this should be the response to the request, I'll try to add a test for post-hooks then.
davidbrochart: I think this is ready to be merged, if someone could hit the button :smile: 
Zsailer: Great work here @davidbrochart! I'm happy to merge this.

We picked up some merge conflicts here after merging #716—do you mind rebasing here? 

I don't think we can release until #717 is merged, because we learned from #713 that #165 introduced some backwards incompatible changes. 

I propose that we wait until after the weekend to cut a new release with these additions, since releasing before/over a weekend can be dangerous 😅 
davidbrochart: Sounds good @Zsailer, I'll rebase and we'll release next week hopefully!
davidbrochart: I just rebased, I think the CI failures are unrelated.

Patch:
diff --git a/.github/workflows/downstream.yml b/.github/workflows/downstream.yml
index a92d24f2b..c1e9276ae 100644
--- a/.github/workflows/downstream.yml
+++ b/.github/workflows/downstream.yml
@@ -7,7 +7,7 @@ on:
 jobs:
   tests:
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 20
 
     steps:
       - name: Checkout
@@ -16,22 +16,25 @@ jobs:
       - name: Base Setup
         uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
 
+      - name: Test nbclassic
+        uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1
+        with:
+          package_name: nbclassic
+          test_command: pytest --pyargs nbclassic
+
       - name: Test jupyterlab_server
         uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1
         with:
           package_name: jupyterlab_server
+          test_command: pytest --pyargs jupyterlab_server
 
       - name: Test jupyterlab
         uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1
         with:
           package_name: jupyterlab
+          package_spec: '".[test]"'
           test_command: "python -m jupyterlab.browser_check --no-browser-test"
 
-      - name: Test nbclassic
-        uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1
-        with:
-          package_name: nbclassic
-
       - name: Test run nbclassic
         run: |
           pip install nbclassic
diff --git a/.github/workflows/python-linux.yml b/.github/workflows/python-linux.yml
index aa7da6efd..ff7e91681 100644
--- a/.github/workflows/python-linux.yml
+++ b/.github/workflows/python-linux.yml
@@ -33,6 +33,7 @@ jobs:
 
   build:
     runs-on: ${{ matrix.os }}
+    timeout-minutes: 20
     strategy:
       fail-fast: false
       matrix:
@@ -53,11 +54,11 @@ jobs:
       - name: Run the tests
         if: ${{ matrix.python-version != 'pypy-3.7' }}
         run: |
-          pytest -vv --cov jupyter_server --cov-branch --cov-report term-missing:skip-covered
+          pytest -vv --cov jupyter_server --cov-branch --cov-report term-missing:skip-covered || pytest -vv --cov jupyter_server --cov-branch --cov-report term-missing:skip-covered --lf
       - name: Run the tests on pypy
         if: ${{ matrix.python-version == 'pypy-3.7' }}
         run: |
-          pytest -vv
+          pytest -vv || pytest -vv -lf
       - name: Install the Python dependencies for the examples
         run: |
           cd examples/simple && pip install -e .
@@ -76,6 +77,7 @@ jobs:
 
   test_miniumum_verisons:
     name: Test Minimum Versions
+    timeout-minutes: 20
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v2
@@ -86,11 +88,12 @@ jobs:
       - name: Install miniumum versions
         uses: jupyterlab/maintainer-tools/.github/actions/install-minimums@v1
       - name: Run the unit tests
-        run: pytest -vv
+        run: pytest -vv|| pytest -vv --lf
 
   test_prereleases:
     name: Test Prereleases
     runs-on: ubuntu-latest
+    timeout-minutes: 20
     steps:
       - name: Checkout
         uses: actions/checkout@v2
@@ -105,11 +108,12 @@ jobs:
           pip check
       - name: Run the tests
         run: |
-          pytest -vv
+          pytest -vv || pytest -vv --lf
 
   make_sdist:
     name: Make SDist
     runs-on: ubuntu-latest
+    timeout-minutes: 20
     steps:
       - uses: actions/checkout@v2
       - name: Base Setup
@@ -127,6 +131,7 @@ jobs:
     runs-on: ubuntu-latest
     needs: [make_sdist]
     name: Install from SDist and Test
+    timeout-minutes: 20
     steps:
       - name: Base Setup
         uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
@@ -144,4 +149,4 @@ jobs:
       - name: Run Test
         run: |
           cd sdist/test
-          pytest -vv
+          pytest -vv || pytest -vv --lf
diff --git a/.github/workflows/python-macos.yml b/.github/workflows/python-macos.yml
index 2a3dbbdb8..f12c62676 100644
--- a/.github/workflows/python-macos.yml
+++ b/.github/workflows/python-macos.yml
@@ -7,6 +7,7 @@ on:
 jobs:
   build:
     runs-on: ${{ matrix.os }}
+    timeout-minutes: 20
     strategy:
       fail-fast: false
       matrix:
@@ -28,11 +29,11 @@ jobs:
       - name: Run the tests
         if: ${{ !startsWith( matrix.python-version, 'pypy' ) }}
         run: |
-          pytest -vv --cov jupyter_server --cov-branch --cov-report term-missing:skip-covered
+          pytest -vv --cov jupyter_server --cov-branch --cov-report term-missing:skip-covered || pytest -vv --cov jupyter_server --cov-branch --cov-report term-missing:skip-covered --lf
       - name: Run the tests on pypy
         if: ${{ startsWith( matrix.python-version, 'pypy' ) }}
         run: |
-          pytest -vv
+          pytest -vv || pytest -vv --lf
       - name: Install the Python dependencies for the examples
         run: |
           cd examples/simple && pip install -e .
diff --git a/.github/workflows/python-windows.yml b/.github/workflows/python-windows.yml
index f766c08f1..c4b078c04 100644
--- a/.github/workflows/python-windows.yml
+++ b/.github/workflows/python-windows.yml
@@ -7,6 +7,7 @@ on:
 jobs:
   build:
     runs-on: ${{ matrix.os }}
+    timeout-minutes: 20
     strategy:
       fail-fast: false
       matrix:
@@ -32,7 +33,7 @@ jobs:
           # the file descriptions opened by the asyncio IOLoop.
           # This leads to a nasty, flaky race condition that we haven't
           # been able to solve.
-          pytest -vv -s
+          pytest -vv -s || pytest -vv -s --lf
       - name: Install the Python dependencies for the examples
         run: |
           cd examples/simple && pip install -e .
diff --git a/jupyter_server/services/contents/filemanager.py b/jupyter_server/services/contents/filemanager.py
index 21584935e..4a70c0117 100644
--- a/jupyter_server/services/contents/filemanager.py
+++ b/jupyter_server/services/contents/filemanager.py
@@ -11,13 +11,11 @@ from datetime import datetime
 
 import nbformat
 from anyio.to_thread import run_sync
-from ipython_genutils.importstring import import_item
 from jupyter_core.paths import exists
 from jupyter_core.paths import is_file_hidden
 from jupyter_core.paths import is_hidden
 from send2trash import send2trash
 from tornado import web
-from traitlets import Any
 from traitlets import Bool
 from traitlets import default
 from traitlets import TraitError
@@ -54,48 +52,6 @@ class FileContentsManager(FileManagerMixin, ContentsManager):
         except AttributeError:
             return os.getcwd()
 
-    post_save_hook = Any(
-        None,
-        config=True,
-        allow_none=True,
-        help="""Python callable or importstring thereof
-
-        to be called on the path of a file just saved.
-
-        This can be used to process the file on disk,
-        such as converting the notebook to a script or HTML via nbconvert.
-
-        It will be called as (all arguments passed by keyword)::
-
-            hook(os_path=os_path, model=model, contents_manager=instance)
-
-        - path: the filesystem path to the file just written
-        - model: the model representing the file
-        - contents_manager: this ContentsManager instance
-        """,
-    )
-
-    @validate("post_save_hook")
-    def _validate_post_save_hook(self, proposal):
-        value = proposal["value"]
-        if isinstance(value, str):
-            value = import_item(value)
-        if not callable(value):
-            raise TraitError("post_save_hook must be callable")
-        return value
-
-    def run_post_save_hook(self, model, os_path):
-        """Run the post-save hook if defined, and log errors"""
-        if self.post_save_hook:
-            try:
-                self.log.debug("Running post-save hook on %s", os_path)
-                self.post_save_hook(os_path=os_path, model=model, contents_manager=self)
-            except Exception as e:
-                self.log.error("Post-save hook failed o-n %s", os_path, exc_info=True)
-                raise web.HTTPError(
-                    500, "Unexpected error while running post hook save: %s" % e
-                ) from e
-
     @validate("root_dir")
     def _validate_root_dir(self, proposal):
         """Do a bit of validation of the root_dir."""
@@ -451,7 +407,7 @@ class FileContentsManager(FileManagerMixin, ContentsManager):
         """Save the file model and return the model with no content."""
         path = path.strip("/")
 
-        self.run_pre_save_hook(model=model, path=path)
+        self.run_pre_save_hooks(model=model, path=path)
 
         if "type" not in model:
             raise web.HTTPError(400, "No file type provided")
@@ -491,7 +447,7 @@ class FileContentsManager(FileManagerMixin, ContentsManager):
         if validation_message:
             model["message"] = validation_message
 
-        self.run_post_save_hook(model=model, os_path=os_path)
+        self.run_post_save_hooks(model=model, os_path=os_path)
 
         return model
 
@@ -815,7 +771,7 @@ class AsyncFileContentsManager(FileContentsManager, AsyncFileManagerMixin, Async
         if validation_message:
             model["message"] = validation_message
 
-        self.run_post_save_hook(model=model, os_path=os_path)
+        self.run_post_save_hooks(model=model, os_path=os_path)
 
         return model
 
diff --git a/jupyter_server/services/contents/largefilemanager.py b/jupyter_server/services/contents/largefilemanager.py
index 75e773b87..de1801eec 100644
--- a/jupyter_server/services/contents/largefilemanager.py
+++ b/jupyter_server/services/contents/largefilemanager.py
@@ -56,7 +56,7 @@ class LargeFileManager(FileContentsManager):
 
             # Last chunk
             if chunk == -1:
-                self.run_post_save_hook(model=model, os_path=os_path)
+                self.run_post_save_hooks(model=model, os_path=os_path)
             return model
         else:
             return super(LargeFileManager, self).save(model, path)
@@ -131,7 +131,7 @@ class AsyncLargeFileManager(AsyncFileContentsManager):
 
             # Last chunk
             if chunk == -1:
-                self.run_post_save_hook(model=model, os_path=os_path)
+                self.run_post_save_hooks(model=model, os_path=os_path)
             return model
         else:
             return await super(AsyncLargeFileManager, self).save(model, path)
diff --git a/jupyter_server/services/contents/manager.py b/jupyter_server/services/contents/manager.py
index 3e47a637a..b3bdad04f 100644
--- a/jupyter_server/services/contents/manager.py
+++ b/jupyter_server/services/contents/manager.py
@@ -4,6 +4,7 @@
 import itertools
 import json
 import re
+import warnings
 from fnmatch import fnmatch
 
 from ipython_genutils.importstring import import_item
@@ -126,10 +127,55 @@ class ContentsManager(LoggingConfigurable):
             value = import_item(self.pre_save_hook)
         if not callable(value):
             raise TraitError("pre_save_hook must be callable")
+        if self.pre_save_hook is not None:
+            warnings.warn(
+                f"Overriding existing pre_save_hook ({self.pre_save_hook.__name__}) with a new one ({value.__name__}).",
+                stacklevel=2,
+            )
+        return value
+
+    post_save_hook = Any(
+        None,
+        config=True,
+        allow_none=True,
+        help="""Python callable or importstring thereof
+
+        to be called on the path of a file just saved.
+
+        This can be used to process the file on disk,
+        such as converting the notebook to a script or HTML via nbconvert.
+
+        It will be called as (all arguments passed by keyword)::
+
+            hook(os_path=os_path, model=model, contents_manager=instance)
+
+        - path: the filesystem path to the file just written
+        - model: the model representing the file
+        - contents_manager: this ContentsManager instance
+        """,
+    )
+
+    @validate("post_save_hook")
+    def _validate_post_save_hook(self, proposal):
+        value = proposal["value"]
+        if isinstance(value, str):
+            value = import_item(value)
+        if not callable(value):
+            raise TraitError("post_save_hook must be callable")
+        if self.post_save_hook is not None:
+            warnings.warn(
+                f"Overriding existing post_save_hook ({self.post_save_hook.__name__}) with a new one ({value.__name__}).",
+                stacklevel=2,
+            )
         return value
 
     def run_pre_save_hook(self, model, path, **kwargs):
         """Run the pre-save hook if defined, and log errors"""
+        warnings.warn(
+            "run_pre_save_hook is deprecated, use run_pre_save_hooks instead.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
         if self.pre_save_hook:
             try:
                 self.log.debug("Running pre-save hook on %s", path)
@@ -143,6 +189,77 @@ class ContentsManager(LoggingConfigurable):
                 # which could cause frustrating data loss
                 self.log.error("Pre-save hook failed on %s", path, exc_info=True)
 
+    def run_post_save_hook(self, model, os_path):
+        """Run the post-save hook if defined, and log errors"""
+        warnings.warn(
+            "run_post_save_hook is deprecated, use run_post_save_hooks instead.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        if self.post_save_hook:
+            try:
+                self.log.debug("Running post-save hook on %s", os_path)
+                self.post_save_hook(os_path=os_path, model=model, contents_manager=self)
+            except Exception as e:
+                self.log.error("Post-save hook failed o-n %s", os_path, exc_info=True)
+                raise HTTPError(500, "Unexpected error while running post hook save: %s" % e) from e
+
+    _pre_save_hooks = List()
+    _post_save_hooks = List()
+
+    def register_pre_save_hook(self, hook):
+        if isinstance(hook, str):
+            hook = import_item(hook)
+        if not callable(hook):
+            raise RuntimeError("hook must be callable")
+        self._pre_save_hooks.append(hook)
+
+    def register_post_save_hook(self, hook):
+        if isinstance(hook, str):
+            hook = import_item(hook)
+        if not callable(hook):
+            raise RuntimeError("hook must be callable")
+        self._post_save_hooks.append(hook)
+
+    def run_pre_save_hooks(self, model, path, **kwargs):
+        """Run the pre-save hooks if any, and log errors"""
+        pre_save_hooks = [self.pre_save_hook] if self.pre_save_hook is not None else []
+        pre_save_hooks += self._pre_save_hooks
+        for pre_save_hook in pre_save_hooks:
+            try:
+                self.log.debug("Running pre-save hook on %s", path)
+                pre_save_hook(model=model, path=path, contents_manager=self, **kwargs)
+            except HTTPError:
+                # allow custom HTTPErrors to raise,
+                # rejecting the save with a message.
+                raise
+            except Exception:
+                # unhandled errors don't prevent saving,
+                # which could cause frustrating data loss
+                self.log.error(
+                    "Pre-save hook %s failed on %s",
+                    pre_save_hook.__name__,
+                    path,
+                    exc_info=True,
+                )
+
+    def run_post_save_hooks(self, model, os_path):
+        """Run the post-save hooks if any, and log errors"""
+        post_save_hooks = [self.post_save_hook] if self.post_save_hook is not None else []
+        post_save_hooks += self._post_save_hooks
+        for post_save_hook in post_save_hooks:
+            try:
+                self.log.debug("Running post-save hook on %s", os_path)
+                post_save_hook(os_path=os_path, model=model, contents_manager=self)
+            except Exception as e:
+                self.log.error(
+                    "Post-save %s hook failed on %s",
+                    post_save_hook.__name__,
+                    os_path,
+                    exc_info=True,
+                )
+                raise HTTPError(500, "Unexpected error while running post hook save: %s" % e) from e
+
     checkpoints_class = Type(Checkpoints, config=True)
     checkpoints = Instance(Checkpoints, config=True)
     checkpoints_kwargs = Dict(config=True)
diff --git a/package-lock.json b/package-lock.json
index 3e30b1eb8..32df0cb75 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,8 +1,405 @@
 {
   "name": "jupyter_server",
   "version": "1.0.0",
-  "lockfileVersion": 1,
+  "lockfileVersion": 2,
   "requires": true,
+  "packages": {
+    "": {
+      "name": "jupyter_server",
+      "version": "1.0.0",
+      "license": "BSD",
+      "dependencies": {
+        "bootstrap": "^3.4.0",
+        "copyfiles": "^2.4.1"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
+    },
+    "node_modules/bootstrap": {
+      "version": "3.4.1",
+      "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-3.4.1.tgz",
+      "integrity": "sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/cliui": {
+      "version": "7.0.4",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+      "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^7.0.0"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+    },
+    "node_modules/copyfiles": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz",
+      "integrity": "sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==",
+      "dependencies": {
+        "glob": "^7.0.5",
+        "minimatch": "^3.0.3",
+        "mkdirp": "^1.0.4",
+        "noms": "0.0.0",
+        "through2": "^2.0.1",
+        "untildify": "^4.0.0",
+        "yargs": "^16.1.0"
+      },
+      "bin": {
+        "copyfiles": "copyfiles",
+        "copyup": "copyfiles"
+      }
+    },
+    "node_modules/core-util-is": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+      "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
+    },
+    "node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+    },
+    "node_modules/escalade": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+      "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+    },
+    "node_modules/get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
+      }
+    },
+    "node_modules/glob": {
+      "version": "7.1.7",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+      "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+      "dependencies": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/isarray": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+      "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
+    },
+    "node_modules/minimatch": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+      "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/mkdirp": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+      "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+      "bin": {
+        "mkdirp": "bin/cmd.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/noms": {
+      "version": "0.0.0",
+      "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz",
+      "integrity": "sha1-2o69nzr51nYJGbJ9nNyAkqczKFk=",
+      "dependencies": {
+        "inherits": "^2.0.1",
+        "readable-stream": "~1.0.31"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+    },
+    "node_modules/readable-stream": {
+      "version": "1.0.34",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
+      "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.1",
+        "isarray": "0.0.1",
+        "string_decoder": "~0.10.x"
+      }
+    },
+    "node_modules/require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+    },
+    "node_modules/string_decoder": {
+      "version": "0.10.31",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+      "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+    },
+    "node_modules/string-width": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz",
+      "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
+      "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
+      "dependencies": {
+        "ansi-regex": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/through2": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
+      "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
+      "dependencies": {
+        "readable-stream": "~2.3.6",
+        "xtend": "~4.0.1"
+      }
+    },
+    "node_modules/through2/node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+    },
+    "node_modules/through2/node_modules/readable-stream": {
+      "version": "2.3.7",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+      "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/through2/node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "node_modules/untildify": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz",
+      "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
+    },
+    "node_modules/wrap-ansi": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "engines": {
+        "node": ">=0.4"
+      }
+    },
+    "node_modules/y18n": {
+      "version": "5.0.8",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yargs": {
+      "version": "16.2.0",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
+      "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
+      "dependencies": {
+        "cliui": "^7.0.2",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.0",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^20.2.2"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "20.2.9",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
+      "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
+      "engines": {
+        "node": ">=10"
+      }
+    }
+  },
   "dependencies": {
     "ansi-regex": {
       "version": "5.0.1",
@@ -201,6 +598,11 @@
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
       "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
     },
+    "string_decoder": {
+      "version": "0.10.31",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+      "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+    },
     "string-width": {
       "version": "4.2.2",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz",
@@ -211,11 +613,6 @@
         "strip-ansi": "^6.0.0"
       }
     },
-    "string_decoder": {
-      "version": "0.10.31",
-      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
-      "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
-    },
     "strip-ansi": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
diff --git a/pyproject.toml b/pyproject.toml
index dcb7c30e9..791defe67 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,7 +14,8 @@ testpaths = [
     "tests/"
 ]
 timeout = 300
-timeout_method = "thread"
+# Restore this setting to debug failures
+# timeout_method = "thread"
 
 [tool.jupyter-releaser]
 skip = ["check-links"]


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index ede080345..4fc60c404 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,12 @@
+import os
+
 import pytest
 
 from tests.extension.mockextensions.app import MockExtensionApp
 
+# Enforce WinPTY for Windows terminals, since the ConPTY backend
+# dones not work in CI.
+os.environ["PYWINPTY_BACKEND"] = "1"
 
 pytest_plugins = ["jupyter_server.pytest_plugin"]
 
diff --git a/tests/services/kernels/test_api.py b/tests/services/kernels/test_api.py
index 50be5fb20..d0fed006d 100644
--- a/tests/services/kernels/test_api.py
+++ b/tests/services/kernels/test_api.py
@@ -12,7 +12,7 @@ from ...utils import expected_http_error
 from jupyter_server.utils import url_path_join
 
 
-TEST_TIMEOUT = 20
+TEST_TIMEOUT = 60
 
 
 @pytest.fixture
diff --git a/tests/services/sessions/test_api.py b/tests/services/sessions/test_api.py
index 3acaadaac..1711a50b2 100644
--- a/tests/services/sessions/test_api.py
+++ b/tests/services/sessions/test_api.py
@@ -17,7 +17,7 @@ from jupyter_server.services.kernels.kernelmanager import AsyncMappingKernelMana
 from jupyter_server.utils import url_path_join
 
 
-TEST_TIMEOUT = 20
+TEST_TIMEOUT = 60
 
 
 j = lambda r: json.loads(r.body.decode())
diff --git a/tests/test_files.py b/tests/test_files.py
index c592d89fa..a7b063a92 100644
--- a/tests/test_files.py
+++ b/tests/test_files.py
@@ -1,3 +1,4 @@
+import json
 import os
 from pathlib import Path
 
@@ -58,7 +59,8 @@ async def test_hidden_files(jp_fetch, jp_serverapp, jp_root_dir, maybe_hidden):
 
 
 async def test_contents_manager(jp_fetch, jp_serverapp, jp_root_dir):
-    """make sure ContentsManager returns right files (ipynb, bin, txt)."""
+    """make sure ContentsManager returns right files (ipynb, bin, txt).
+    Also test save file hooks."""
     nb = new_notebook(
         cells=[
             new_markdown_cell("Created by test ³"),
@@ -90,6 +92,48 @@ async def test_contents_manager(jp_fetch, jp_serverapp, jp_root_dir):
     assert r.body.decode() == "foobar"
 
 
+async def test_save_hooks(jp_fetch, jp_serverapp):
+    # define a first pre-save hook that will change the content of the file before saving
+    def pre_save_hook1(model, **kwargs):
+        model["content"] += " was modified"
+
+    # define a second pre-save hook that will change the content of the file before saving
+    def pre_save_hook2(model, **kwargs):
+        model["content"] += " twice!"
+
+    # define a first post-save hook that will change the 'last_modified' date
+    def post_save_hook1(model, **kwargs):
+        model["last_modified"] = "yesterday"
+
+    # define a second post-save hook that will change the 'last_modified' date
+    def post_save_hook2(model, **kwargs):
+        model["last_modified"] += " or tomorrow!"
+
+    # register the pre-save hooks
+    jp_serverapp.contents_manager.register_pre_save_hook(pre_save_hook1)
+    jp_serverapp.contents_manager.register_pre_save_hook(pre_save_hook2)
+
+    # register the post-save hooks
+    jp_serverapp.contents_manager.register_post_save_hook(post_save_hook1)
+    jp_serverapp.contents_manager.register_post_save_hook(post_save_hook2)
+
+    # send a request to save a file, with an original content
+    # the 'last_modified' returned model field should have been modified by post_save_hook1 then post_save_hook2
+    r = await jp_fetch(
+        "api/contents/test.txt",
+        method="PUT",
+        body=json.dumps(
+            {"format": "text", "path": "test.txt", "type": "file", "content": "original content"}
+        ),
+    )
+    assert json.loads(r.body.decode())["last_modified"] == "yesterday or tomorrow!"
+
+    # read the file back
+    # the original content should have been modified by pre_save_hook1 then pre_save_hook2
+    r = await jp_fetch("files/test.txt", method="GET")
+    assert r.body.decode() == "original content was modified twice!"
+
+
 async def test_download(jp_fetch, jp_serverapp, jp_root_dir):
     text = "hello"
     jp_root_dir.joinpath("test.txt").write_text(text)