From d0e5b7a193d29549628880c4f4124d75a9a6f557 Mon Sep 17 00:00:00 2001 From: Marc Wouts Date: Tue, 10 Dec 2024 22:30:12 +0000 Subject: [PATCH] Implement `require_hash` --- .github/workflows/step_tests-pip.yml | 8 +++ CHANGELOG.md | 7 ++- src/jupytext/contentsmanager.py | 60 +++++++++++++++++-- src/jupytext/version.py | 2 +- .../contents_manager/test_contentsmanager.py | 42 +++++++++++++ 5 files changed, 110 insertions(+), 9 deletions(-) diff --git a/.github/workflows/step_tests-pip.yml b/.github/workflows/step_tests-pip.yml index 8462bf2e..4bc7c9a1 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/CHANGELOG.md b/CHANGELOG.md index 054f364a..51e8de12 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/src/jupytext/contentsmanager.py b/src/jupytext/contentsmanager.py index dd6a9564..1a5b8f32 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 save_one_file(path, fmt): 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 read_one_file(alt_path, alt_fmt): # 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 @@ def get_config(self, path, use_cache=False): 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 86a823e3..ef2a7f5f 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" diff --git a/tests/integration/contents_manager/test_contentsmanager.py b/tests/integration/contents_manager/test_contentsmanager.py index 355a67d2..928b4cd6 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"]