Repository: mwouts/jupytext

Issue:
Incompatibility with jupyter_server 2.11.0
## Description

`jupyter_server` version 2.11.0 broke `JupytextContentsManager`, making jupytext unusable.

## Reproduce

1. Install both `jupytext` and `jupyter_server==2.11.0`
2. Start jupyterlab `jupyter lab` (the issue also occurs with notebook though)
3. Open the interface in a browser
4. The interface fails to load properly (e.g. no file list) and errors appear in the console, most notably

` TypeError: build_jupytext_contents_manager_class.<locals>.JupytextContentsManager.get() got an unexpected keyword argument 'md5'`

This is most likely caused by the API change in https://github.com/jupyter-server/jupyter_server/pull/1363 which made `md5` part of the expected arguments to the contents managers `get` hook. A proper fix would be to update `JupytextContentsManager` accordingly, but in the meantime a hotfix would be to pin `jupyter_server` to `<2.11`.

Discussion:
github-actions[bot]: Thank you for making this pull request.

Did you know? You can try it on Binder: [![Binder:lab](https://img.shields.io/badge/binder-jupyterlab-0172B2.svg)](https://mybinder.org/v2/gh/mwouts/jupytext/implement_require_hash?urlpath=lab/tree/demo/get_started.ipynb) or [![Binder:notebook](https://img.shields.io/badge/binder-notebook-0172B2.svg)](https://mybinder.org/v2/gh/mwouts/jupytext/implement_require_hash?filepath=demo).

Also, the version of Jupytext developed in this PR can be installed with `pip`:
```
pip install git+https://github.com/mwouts/jupytext.git@implement_require_hash
```
(this requires `nodejs`, see more at [Developing Jupytext](https://jupytext.readthedocs.io/en/latest/developing.html))
<!-- thollander/actions-comment-pull-request "binder_link" -->
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/mwouts/jupytext/pull/1202?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Marc+Wouts) Report
Attention: Patch coverage is `94.44444%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 95.43%. Comparing base [(`af4a8eb`)](https://app.codecov.io/gh/mwouts/jupytext/commit/af4a8eb2d2d5f0fda255f30b6e546776b74e6436?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Marc+Wouts) to head [(`e019b91`)](https://app.codecov.io/gh/mwouts/jupytext/commit/e019b911e13c26b1ecee8f4cdd24f3dbf31c40b7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Marc+Wouts).

| [Files with missing lines](https://app.codecov.io/gh/mwouts/jupytext/pull/1202?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Marc+Wouts) | Patch % | Lines |
|---|---|---|
| [src/jupytext/contentsmanager.py](https://app.codecov.io/gh/mwouts/jupytext/pull/1202?src=pr&el=tree&filepath=src%2Fjupytext%2Fcontentsmanager.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Marc+Wouts#diff-c3JjL2p1cHl0ZXh0L2NvbnRlbnRzbWFuYWdlci5weQ==) | 94.44% | [1 Missing :warning: ](https://app.codecov.io/gh/mwouts/jupytext/pull/1202?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Marc+Wouts) |

<details><summary>Additional details and impacted files</summary>


```diff
@@                                  Coverage Diff                                   @@
##           fix_notebook_corruption_with_jupyter_collaboration    #1202      +/-   ##
======================================================================================
- Coverage                                               97.03%   95.43%   -1.60%     
======================================================================================
  Files                                                      29       29              
  Lines                                                    4479     4493      +14     
======================================================================================
- Hits                                                     4346     4288      -58     
- Misses                                                    133      205      +72     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/mwouts/jupytext/pull/1202?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Marc+Wouts).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Marc+Wouts).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 054f364..51e8de1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,12 @@
 Jupytext ChangeLog
 ==================
 
-1.16.5-dev
-----------
+1.16.5 (2024-12-15)
+-------------------
 
 **Fixed**
-- We have fixed a notebook corruption issue when using Jupytext with Jupyter-Collaboration ([#1124](https://github.com/mwouts/jupytext/issues/1124), [jupyter-collaboration #214](https://github.com/jupyterlab/jupyter-collaboration/issues/214)).
+- We have fixed a notebook corruption issue when using Jupytext with Jupyter-Collaboration ([#1124](https://github.com/mwouts/jupytext/issues/1124), [jupyter-collaboration 214](https://github.com/mwouts/jupytext/issues/214)).
+- We have added the `require_hash` argument on the Jupytext contents manager. The hash of a paired file is the concatenation of the hash of the text file and the hash for the `.ipynb` file ([#1165](https://github.com/mwouts/jupytext/issues/1165))
 - The `rst2md` tests have been fixed by requiring `sphinx<8` ([#1266](https://github.com/mwouts/jupytext/issues/1266))
 - Some dependencies of the JupyterLab extensions were updated ([#1272](https://github.com/mwouts/jupytext/issues/1272), [#1273](https://github.com/mwouts/jupytext/issues/1273), [#1280](https://github.com/mwouts/jupytext/issues/1280), [#1285](https://github.com/mwouts/jupytext/issues/1285), [#1290](https://github.com/mwouts/jupytext/issues/1290))
 - The pre-commit hook is now compatible with log.showsignature=True ([#1281](https://github.com/mwouts/jupytext/issues/1281)). Thanks to [Justin Lecher](https://github.com/jlec) for this fix.
diff --git a/README.md b/README.md
index 62ac115..93614a3 100755
--- a/README.md
+++ b/README.md
@@ -5,6 +5,7 @@
 [![CI](https://github.com/mwouts/jupytext/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/mwouts/jupytext/actions)
 [![Documentation Status](https://readthedocs.org/projects/jupytext/badge/?version=latest)](https://jupytext.readthedocs.io/en/latest/?badge=latest)
 [![codecov.io](https://codecov.io/github/mwouts/jupytext/coverage.svg?branch=main)](https://codecov.io/gh/mwouts/jupytext/branch/main)
+[![MIT License](https://img.shields.io/github/license/mwouts/jupytext)](LICENSE)
 [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
 [![GitHub language count](https://img.shields.io/github/languages/count/mwouts/jupytext)](docs/languages.md)
 [![Conda Version](https://img.shields.io/conda/vn/conda-forge/jupytext.svg)](https://anaconda.org/conda-forge/jupytext)
diff --git a/src/jupytext/contentsmanager.py b/src/jupytext/contentsmanager.py
index dd6a956..1a5b8f3 100644
--- a/src/jupytext/contentsmanager.py
+++ b/src/jupytext/contentsmanager.py
@@ -1,5 +1,6 @@
 """ContentsManager that allows to open Rmd, py, R and ipynb files as notebooks
 """
+import inspect
 import itertools
 import os
 
@@ -184,35 +185,60 @@ def build_jupytext_contents_manager_class(base_contents_manager_class):
                 self.log.error("Error while saving file: %s %s", path, e, exc_info=True)
                 raise HTTPError(500, f"Unexpected error while saving file: {path} {e}")
 
-        def get(
+        def _get_with_no_require_hash_argument(
             self,
             path,
             content=True,
             type=None,
             format=None,
             load_alternative_format=True,
+        ):
+            return self._get_with_require_hash_argument(
+                path,
+                content=content,
+                type=type,
+                format=format,
+                require_hash=False,
+                load_alternative_format=load_alternative_format,
+            )
+
+        def _get_with_require_hash_argument(
+            self,
+            path,
+            content=True,
+            type=None,
+            format=None,
+            require_hash=False,
+            load_alternative_format=True,
         ):
             """Takes a path for an entity and returns its model"""
             path = path.strip("/")
             ext = os.path.splitext(path)[1]
 
+            super_kwargs = {"content": content, "type": type, "format": format}
+            if require_hash:
+                super_kwargs["require_hash"] = require_hash
+
             # Not a notebook?
             if (
                 not self.file_exists(path)
                 or self.dir_exists(path)
                 or (type is not None and type != "notebook")
             ):
-                return self.super.get(path, content, type, format)
+                return self.super.get(path, **super_kwargs)
 
             config = self.get_config(path, use_cache=content is False)
             if ext not in self.all_nb_extensions(config):
-                return self.super.get(path, content, type, format)
+                return self.super.get(path, **super_kwargs)
 
             fmt = preferred_format(ext, config.preferred_jupytext_formats_read)
             if ext == ".ipynb":
-                model = self.super.get(path, content, type="notebook", format=format)
+                super_kwargs["type"] = "notebook"
+                model = self.super.get(path, **super_kwargs)
             else:
-                model = self.super.get(path, content, type="file", format="text")
+                super_kwargs["type"] = "file"
+                super_kwargs["format"] = "text"
+                model = self.super.get(path, **super_kwargs)
                 model["type"] = "notebook"
                 if content:
                     # We may need to update these keys, inherited from text files formats
@@ -314,6 +340,21 @@ def build_jupytext_contents_manager_class(base_contents_manager_class):
             # Modification time of a paired notebook is the timestamp of inputs #118 #978
             model["last_modified"] = inputs.timestamp
 
+            if require_hash:
+                if inputs.path is None or outputs.path is None:
+                    return model
+                model_other = self.super.get(
+                    inputs.path if path == outputs.path else outputs.path,
+                    require_hash=True,
+                )
+                # The hash of a paired file is the concatenation of
+                # the hashes of the input and output files
+                if path == outputs.path:
+                    model["hash"] = model_other["hash"] + model["hash"]
+                else:
+                    model["hash"] = model["hash"] + model_other["hash"]
+                return model
+
             if not content:
                 return model
 
@@ -612,6 +653,15 @@ to your jupytext.toml file
                 self.notebook_extensions = self.notebook_extensions.split(",")
             return self
 
+    if "require_hash" in inspect.signature(base_contents_manager_class.get).parameters:
+        JupytextContentsManager.get = (
+            JupytextContentsManager._get_with_require_hash_argument
+        )
+    else:
+        JupytextContentsManager.get = (
+            JupytextContentsManager._get_with_no_require_hash_argument
+        )
+
     return JupytextContentsManager
 
 
diff --git a/src/jupytext/version.py b/src/jupytext/version.py
index 86a823e..ef2a7f5 100644
--- a/src/jupytext/version.py
+++ b/src/jupytext/version.py
@@ -1,3 +1,3 @@
 """Jupytext's version number"""
 
-__version__ = "1.16.5-dev"
+__version__ = "1.16.5"


Test patch:
diff --git a/.github/workflows/step_tests-pip.yml b/.github/workflows/step_tests-pip.yml
index 8462bf2..4bc7c9a 100644
--- a/.github/workflows/step_tests-pip.yml
+++ b/.github/workflows/step_tests-pip.yml
@@ -21,6 +21,7 @@ jobs:
       ${{ matrix.no_kernel && '(No kernel)' }}
       ${{ matrix.markdown-it-py && format('(markdown-it-py {0})', matrix.markdown-it-py) }}
       ${{ matrix.no_markdown-it-py && '(No markdown-it-py)'}}
+      ${{ matrix.jupyter_server && '(Jupyer-server {0})'}}
     runs-on: ubuntu-latest
 
     strategy:
@@ -29,6 +30,9 @@ jobs:
         python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
         experimental: [false]
         include:
+          # Test with jupyter-server=2.10 that does not have the 'require_hash' argument
+          - python-version: "3.x"
+            jupyter_server: "2.10.0"
           # Test minimum markdown-it-py supported (otherwise the most recent version is used)
           - python-version: "3.x"
             markdown-it-py: "~=2.0"
@@ -58,6 +62,10 @@ jobs:
           jupyterlab
           ${{ format('markdown-it-py{0}', matrix.markdown-it-py) }}
 
+      - name: Install Jupyter Server
+        if: ${{ matrix.jupyter_server }}
+        run: python -m pip install 'jupyter_server~=${{ matrix.jupyter_server }}'
+
       - name: List the versions of the Jupyter components
         run: jupyter --version
 
diff --git a/tests/integration/contents_manager/test_contentsmanager.py b/tests/integration/contents_manager/test_contentsmanager.py
index 355a67d..928b4cd 100644
--- a/tests/integration/contents_manager/test_contentsmanager.py
+++ b/tests/integration/contents_manager/test_contentsmanager.py
@@ -1,3 +1,4 @@
+import inspect
 import os
 import re
 import shutil
@@ -1851,3 +1852,44 @@ def test_move_paired_notebook_to_subdir_1059(tmp_path, python_notebook):
     model = cm.get("scripts/subdir/my_notebook.py")
     nb = model["content"]
     compare_notebooks(nb, python_notebook, fmt="py:percent")
+
+
+def test_hash_changes_if_paired_file_is_edited(tmp_path, python_notebook):
+    # 1. write py ipynb
+    cm = jupytext.TextFileContentsManager()
+
+    if "require_hash" not in inspect.signature(cm.get).parameters:
+        pytest.skip(
+            reason="This JupytextContentsManager does not have a 'require_hash' parameter in cm.get"
+        )
+
+    cm.formats = "ipynb,py:percent"
+    cm.root_dir = str(tmp_path)
+
+    # save ipynb
+    nb = python_notebook
+    nb_name = "notebook.ipynb"
+    cm.save(model=notebook_model(nb), path=nb_name)
+    org_model = cm.get(nb_name, require_hash=True)
+
+    py_file = tmp_path / "notebook.py"
+
+    text = py_file.read_text()
+    assert "# %% [markdown]" in text.splitlines(), text
+
+    # modify the timestamp of the paired file
+    time.sleep(0.5)
+    py_file.write_text(text)
+    model = cm.get(nb_name, require_hash=True)
+    # not sure why the hash changes on Windows?
+    assert (model["hash"] == org_model["hash"]) or (os.name == "nt")
+
+    # modify the paired file
+    py_file.write_text(text + "\n# %%\n1 + 1\n")
+
+    new_model = cm.get(nb_name, require_hash=True)
+    assert new_model["hash"] != org_model["hash"]
+
+    # the hash is for the pair (inputs first)
+    model_from_py_file = cm.get("notebook.py", require_hash=True)
+    assert model_from_py_file["hash"] == new_model["hash"]