├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .readthedocs.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── doc ├── _config.yml ├── _toc.yml ├── github-actions.md ├── intro.md ├── requirements.txt ├── tutorial-image.md ├── tutorial.md ├── use-case-data.md ├── use-case-ml.md └── use-case-nbs.md ├── examples ├── constant.ipynb ├── image.ipynb ├── ml-classifier.ipynb └── normal.ipynb ├── header.png ├── pyproject.toml ├── setup.cfg ├── setup.py ├── src └── nbsnapshot │ ├── __init__.py │ ├── cli.py │ ├── compare.py │ └── exceptions.py ├── tasks.py └── tests ├── conftest.py ├── test_compare.py └── test_compare_image.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | unit-test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: [3.7, 3.8, 3.9, '3.10'] 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install . 22 | # check package is importable 23 | python -c "import nbsnapshot" 24 | python -c "import nbsnapshot.cli" 25 | pip install ".[dev]" 26 | - name: Lint with flake8 27 | run: | 28 | flake8 29 | - name: Test with pytest 30 | run: | 31 | pytest 32 | 33 | readme-test: 34 | runs-on: ubuntu-latest 35 | strategy: 36 | matrix: 37 | python-version: ['3.10'] 38 | 39 | steps: 40 | - uses: actions/checkout@v2 41 | - name: Set up Python ${{ matrix.python-version }} 42 | uses: actions/setup-python@v2 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | - name: Install jupyblog 46 | run: | 47 | pip install --upgrade pip 48 | pip install jupytext nbclient pkgmt 49 | pip install . 50 | - name: Test readme 51 | run: | 52 | pkgmt test-md --file README.md 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | doc/_build 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | *.json 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.9" 7 | 8 | jobs: 9 | pre_build: 10 | - "jupyter-book config sphinx doc/" 11 | 12 | python: 13 | install: 14 | - requirements: doc/requirements.txt 15 | 16 | sphinx: 17 | builder: html 18 | fail_on_warning: true -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 0.2.3dev 4 | 5 | ## 0.2.2 (2022-08-30) 6 | * Updates telemetry key 7 | 8 | ## 0.2.1 (2022-08-13) 9 | * Adds optional, anonymous telemetry 10 | 11 | ## 0.2 (2022-07-30) 12 | * Adds support for testing images 13 | 14 | ## 0.1 (2022-07-15) 15 | 16 | * First release 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright (c) 2022-Present Ploomber Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # use this file if you wish to include non-python files 2 | # reference: https://packaging.python.org/en/latest/guides/using-manifest-in/ 3 | include CHANGELOG.md 4 | include README.md 5 | graft src/pkgmt/assets 6 | global-exclude __pycache__ 7 | global-exclude *.py[co] 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nbsnapshot 2 | 3 | > [!TIP] 4 | > Deploy AI apps for free on [Ploomber Cloud!](https://ploomber.io/?utm_medium=github&utm_source=nbsnapshot) 5 | 6 | CLI for doing snapshot testing on Jupyter notebooks. [Blog post here.](https://ploomber.io/blog/snapshot-testing/) 7 | 8 | ![header](header.png) 9 | 10 | > **Note** 11 | > `nbsnapshot` is in an early stage of development. Join our [community](https://ploomber.io/community) to submit your feedback and follow me on [Twiter](https://twitter.com/intent/user?screen_name=edublancas) to get the latest news. 12 | 13 | ## Install 14 | 15 | ```sh 16 | pip install nbsnapshot 17 | ``` 18 | 19 | ## Documentation 20 | 21 | [Click here to see the documentation](https://nbsnapshot.readthedocs.io) 22 | 23 | ## Use Cases 24 | 25 | * [Testing example notebooks](https://nbsnapshot.readthedocs.io/en/latest/use-case-nbs.html) 26 | * [Machine Learning model re-training](https://nbsnapshot.readthedocs.io/en/latest/use-case-ml.html) 27 | * [Data ingestion monitoring](https://nbsnapshot.readthedocs.io/en/latest/use-case-data.html) 28 | 29 | ## Usage 30 | 31 | First, [tag some cells](https://papermill.readthedocs.io/en/latest/usage-parameterize.html). 32 | 33 | Or, get a sample notebook: 34 | 35 | ```sh 36 | curl -O https://raw.githubusercontent.com/ploomber/nbsnapshot/main/examples/normal.ipynb 37 | ``` 38 | 39 | Then, run the notebook and test it (pass `--run` to run the notebook before doing the snapshot test): 40 | 41 | ```sh 42 | # install dependencies 43 | pip install matplotlib numpy pandas 44 | 45 | # run test 46 | nbsnapshot test normal.ipynb --run 47 | ``` 48 | 49 | *Note:* You'll need to run the command a few times to start generating the history. If you want to fail the test, modify the notebook and add replace the cell that contains `np.random.normal()` with the number `100`. 50 | 51 | 52 | ## About Ploomber 53 | 54 | Ploomber is a big community of data enthusiasts pushing the boundaries of Data Science and Machine Learning tooling. 55 | 56 | Whatever your skillset is, you can contribute to our mission. So whether you're a beginner or an experienced professional, you're welcome to join us on this journey! 57 | 58 | [Click here to know how you can contribute to Ploomber.](https://github.com/ploomber/contributing/blob/main/README.md) 59 | 60 | 61 | 62 | ## Telemetry 63 | 64 | We collect optional, anonymous statistics to understand and improve usage. For details, [see here](https://docs.ploomber.io/en/latest/community/user-stats.html) 65 | -------------------------------------------------------------------------------- /doc/_config.yml: -------------------------------------------------------------------------------- 1 | # Book settings 2 | # Learn more at https://jupyterbook.org/customize/config.html 3 | 4 | title: nbsnapshot 5 | author: Ploomber 6 | copyright: '2023' 7 | # logo: logo.png 8 | 9 | # Force re-execution of notebooks on each build. 10 | # See https://jupyterbook.org/content/execute.html 11 | execute: 12 | execute_notebooks: force 13 | 14 | # Define the name of the latex output file for PDF builds 15 | latex: 16 | latex_documents: 17 | targetname: book.tex 18 | 19 | # Add a bibtex file so that we can create citations 20 | # bibtex_bibfiles: 21 | # - references.bib 22 | 23 | # Information about where the book exists on the web 24 | repository: 25 | url: https://github.com/ploomber/nbsnapshot # Online location of your book 26 | path_to_book: doc # Optional path to your book, relative to the repository root 27 | branch: main # Which branch of the repository should be used when creating links (optional) 28 | 29 | # Add GitHub buttons to your book 30 | # See https://jupyterbook.org/customize/config.html#add-a-link-to-your-repository 31 | html: 32 | use_issues_button: true 33 | use_repository_button: true 34 | 35 | sphinx: 36 | fail_on_warning: true 37 | config: 38 | execution_show_tb: true 39 | 40 | execute: 41 | run_in_temp: true 42 | timeout: 60 -------------------------------------------------------------------------------- /doc/_toc.yml: -------------------------------------------------------------------------------- 1 | # Table of contents 2 | # Learn more at https://jupyterbook.org/customize/toc.html 3 | 4 | format: jb-book 5 | root: intro 6 | chapters: 7 | - file: tutorial 8 | - file: tutorial-image 9 | - file: use-case-nbs 10 | - file: use-case-ml 11 | - file: use-case-data 12 | - file: github-actions 13 | -------------------------------------------------------------------------------- /doc/github-actions.md: -------------------------------------------------------------------------------- 1 | --- 2 | jupytext: 3 | text_representation: 4 | extension: .md 5 | format_name: myst 6 | format_version: 0.13 7 | jupytext_version: 1.14.0 8 | kernelspec: 9 | display_name: Python 3.9.13 64-bit 10 | language: python 11 | name: python3 12 | --- 13 | 14 | # Github Actions 15 | 16 | You can use GitHub actions and nbsnapshot to test your notebooks continuously, [see the sample project.](https://github.com/ploomber/notebooks-ci) -------------------------------------------------------------------------------- /doc/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | jupytext: 3 | cell_metadata_filter: -all 4 | formats: md:myst 5 | text_representation: 6 | extension: .md 7 | format_name: myst 8 | format_version: 0.13 9 | jupytext_version: 1.11.5 10 | kernelspec: 11 | display_name: Python 3 12 | language: python 13 | name: python3 14 | --- 15 | 16 | # nbsnapshot 17 | 18 | `nbsnapshot` tests Jupyter notebook by benchmarking outputs with historical values. 19 | 20 | ```{code-cell} ipython3 21 | :tags: ["remove-cell"] 22 | import tempfile, os, shutil, atexit 23 | 24 | tmp = tempfile.mkdtemp() 25 | os.chdir(tmp) 26 | 27 | @atexit.register 28 | def delete_tmp(): 29 | shutil.rmtree(tmp) 30 | ``` 31 | 32 | Download a notebook: 33 | 34 | ```{code-cell} ipython3 35 | import urllib.request 36 | from pathlib import Path 37 | ``` 38 | 39 | 40 | ```{code-cell} ipython3 41 | from nbsnapshot import compare 42 | ``` 43 | 44 | ```{code-cell} ipython3 45 | # download example notebook 46 | _ = urllib.request.urlretrieve("https://raw.githubusercontent.com/ploomber/nbsnapshot/main/examples/normal.ipynb", "example.ipynb") 47 | ``` 48 | 49 | 50 | Run it a few times (two needed to start testing): 51 | 52 | ```{code-cell} ipython3 53 | compare.main("example.ipynb", run=True) 54 | compare.main("example.ipynb", run=True) 55 | ``` 56 | 57 | Next call witll test the output: 58 | 59 | 60 | ```{code-cell} ipython3 61 | compare.main("example.ipynb", run=True) 62 | ``` 63 | 64 | 65 | -------------------------------------------------------------------------------- /doc/requirements.txt: -------------------------------------------------------------------------------- 1 | jupyter-book 2 | matplotlib 3 | numpy 4 | pandas 5 | scikit-learn 6 | . -------------------------------------------------------------------------------- /doc/tutorial-image.md: -------------------------------------------------------------------------------- 1 | --- 2 | jupytext: 3 | text_representation: 4 | extension: .md 5 | format_name: myst 6 | format_version: 0.13 7 | jupytext_version: 1.14.0 8 | kernelspec: 9 | display_name: Python 3 (ipykernel) 10 | language: python 11 | name: python3 12 | --- 13 | 14 | # Tutorial: testing images 15 | 16 | Beginning in `nbsnapshot` 0.2, testing images is supported. Let's download a notebook that plots an image using matplotlib: 17 | 18 | ```{code-cell} ipython3 19 | from pathlib import Path 20 | import urllib.request 21 | 22 | 23 | from nbsnapshot import compare 24 | from nbsnapshot.exceptions import SnapshotTestFailure 25 | 26 | # download example notebook 27 | _ = urllib.request.urlretrieve("https://raw.githubusercontent.com/ploomber/nbsnapshot/main/examples/image.ipynb", "image.ipynb") 28 | 29 | # delete history, if it exists 30 | history = Path("image.json") 31 | 32 | if history.exists(): 33 | history.unlink() 34 | ``` 35 | 36 | Run it once to generate the base image: 37 | 38 | ```{code-cell} ipython3 39 | compare.main("image.ipynb", run=True) 40 | ``` 41 | 42 | Next call witll test the output: 43 | 44 | ```{code-cell} ipython3 45 | compare.main("image.ipynb", run=True) 46 | ``` 47 | -------------------------------------------------------------------------------- /doc/tutorial.md: -------------------------------------------------------------------------------- 1 | --- 2 | jupytext: 3 | text_representation: 4 | extension: .md 5 | format_name: myst 6 | format_version: 0.13 7 | jupytext_version: 1.14.0 8 | kernelspec: 9 | display_name: Python 3 (ipykernel) 10 | language: python 11 | name: python3 12 | --- 13 | 14 | # Tutorial 15 | 16 | This tutorial will show you how to use `nbsnapshot`. 17 | 18 | To indicate that you want `nbsnapshot` to test a cell's output, you need to add a tag to the cell with any name ([see here](https://jupyterbook.org/en/stable/content/metadata.html) to learn how to add tags to cells). Then, when testing a notebook, `nbsnapshot` will create a JSON file and append the results. 19 | 20 | Let's create a fake history to simulate that we ran a notebook 10 times. The fake cell will create random values drawn from a normal distribution centered at `10`: 21 | 22 | ```{code-cell} ipython3 23 | import json 24 | from pathlib import Path 25 | import urllib.request 26 | 27 | import numpy as np 28 | import pandas as pd 29 | 30 | from nbsnapshot import compare 31 | from nbsnapshot.exceptions import SnapshotTestFailure 32 | 33 | def create_fake_history(mean): 34 | """Creates a fake notebook history 35 | """ 36 | history = [dict(metric=x) for x in np.random.normal(loc=mean, size=50)] 37 | _ = Path('constant.json').write_text(json.dumps(history)) 38 | 39 | def plot_history(): 40 | """Plots records stored in notebook history 41 | """ 42 | history = pd.read_json('constant.json') 43 | history['metric'].plot(kind='density') 44 | 45 | # download example notebook 46 | _ = urllib.request.urlretrieve("https://raw.githubusercontent.com/ploomber/nbsnapshot/main/examples/constant.ipynb", "constant.ipynb") 47 | ``` 48 | 49 | ```{code-cell} ipython3 50 | create_fake_history(mean=10) 51 | plot_history() 52 | ``` 53 | 54 | Let's now run the sample notebook (`constant.ipynb`), this notebook only contains a cell that prints the number 10. Since this number is within the boundaries of our fake history, the test passes: 55 | 56 | ```{code-cell} ipython3 57 | try: 58 | compare.main('constant.ipynb') 59 | except SnapshotTestFailure as e: 60 | print(e) 61 | ``` 62 | 63 | Now, overwrite the existing history, replace it with a simulation of numbers drawn from a normal distribution centered at 0: 64 | 65 | ```{code-cell} ipython3 66 | create_fake_history(mean=0) 67 | plot_history() 68 | ``` 69 | 70 | Run the notebook again. This time, the value in the notebook (10) deviates too much from the history, hence, the test fails: 71 | 72 | ```{code-cell} ipython3 73 | try: 74 | compare.main('constant.ipynb') 75 | except SnapshotTestFailure as e: 76 | print(e) 77 | ``` 78 | -------------------------------------------------------------------------------- /doc/use-case-data.md: -------------------------------------------------------------------------------- 1 | --- 2 | jupytext: 3 | text_representation: 4 | extension: .md 5 | format_name: myst 6 | format_version: 0.13 7 | jupytext_version: 1.14.0 8 | kernelspec: 9 | display_name: Python 3 (ipykernel) 10 | language: python 11 | name: python3 12 | --- 13 | 14 | # Use case: data ingestion monitoring 15 | 16 | You can use `nbsnapshot` for monitoring data ingestion pipelines. For example, if you're processing new batches of data with a notebook, `nbsnapshot` can help you detect deviations in the ingested data so you're alerted. 17 | 18 | [Work in progress] 19 | 20 | Do you want us to speed this up? [Let us know!](https://github.com/ploomber/nbsnapshot/issues/new?title=please%20add%20data%20monitoring%20example) -------------------------------------------------------------------------------- /doc/use-case-ml.md: -------------------------------------------------------------------------------- 1 | --- 2 | jupytext: 3 | text_representation: 4 | extension: .md 5 | format_name: myst 6 | format_version: 0.13 7 | jupytext_version: 1.14.0 8 | kernelspec: 9 | display_name: Python 3 (ipykernel) 10 | language: python 11 | name: python3 12 | --- 13 | 14 | # Use case: ML re-training 15 | 16 | You can use `nbsnapshot` to monitor ML re-training jobs, to get alerted when the training performance is unexpected. 17 | 18 | Let's download a notebook that train a classifier: 19 | 20 | ```{code-cell} ipython3 21 | from pathlib import Path 22 | import urllib.request 23 | 24 | import pandas as pd 25 | 26 | from nbsnapshot import compare 27 | from nbsnapshot.exceptions import SnapshotTestFailure 28 | 29 | # download example notebook 30 | _ = urllib.request.urlretrieve("https://raw.githubusercontent.com/ploomber/nbsnapshot/main/examples/ml-classifier.ipynb", "ml-classifier.ipynb") 31 | 32 | # delete history, if it exists 33 | history = Path("ml-classifier.json") 34 | 35 | if history.exists(): 36 | history.unlink() 37 | ``` 38 | 39 | Let's run the notebook 5 times: 40 | 41 | ```{code-cell} ipython3 42 | :tags: [hide-output] 43 | 44 | for i in range(5): 45 | print(f'**Iteration {i}**') 46 | compare.main('ml-classifier.ipynb', run=True) 47 | ``` 48 | 49 | The tests passed, let's look at the history: 50 | 51 | ```{code-cell} ipython3 52 | df = pd.read_json('ml-classifier.json') 53 | ``` 54 | 55 | ```{code-cell} ipython3 56 | df.plot(kind='density') 57 | ``` 58 | 59 | `nbsnapshot` uses 3 standard deviations from the mean as threshold. Let's compute the range: 60 | 61 | ```{code-cell} ipython3 62 | mean, std = df['accuracy'].mean(), df['accuracy'].std() 63 | low, high = mean - 3 * std, mean + 3 * std 64 | ``` 65 | 66 | ```{code-cell} ipython3 67 | print(f'Range: {low:.2f}, {high:.2f}') 68 | ``` 69 | 70 | If a new test falls outside this range, the test will fail. 71 | -------------------------------------------------------------------------------- /doc/use-case-nbs.md: -------------------------------------------------------------------------------- 1 | --- 2 | jupytext: 3 | text_representation: 4 | extension: .md 5 | format_name: myst 6 | format_version: 0.13 7 | jupytext_version: 1.14.0 8 | kernelspec: 9 | display_name: Python 3 (ipykernel) 10 | language: python 11 | name: python3 12 | --- 13 | 14 | # Use case: examples notebooks 15 | 16 | If you maintain a library and use Jupyter notebooks as examples, you can use `nbsnapshot` to test them. It'll allow you to ensure that the notebooks run and that the outputs match what you expect. 17 | 18 | [Work in progress] 19 | 20 | Do you want us to speed this up? [Let us know!](https://github.com/ploomber/nbsnapshot/issues/new?title=please%20add%20notebooks%20example) -------------------------------------------------------------------------------- /examples/constant.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "d604d0de-6bf2-4ea3-8ee1-b15240b05497", 7 | "metadata": { 8 | "execution": { 9 | "iopub.execute_input": "2022-07-03T21:38:49.901350Z", 10 | "iopub.status.busy": "2022-07-03T21:38:49.900845Z", 11 | "iopub.status.idle": "2022-07-03T21:38:49.976928Z", 12 | "shell.execute_reply": "2022-07-03T21:38:49.975990Z" 13 | }, 14 | "papermill": { 15 | "duration": 0.084241, 16 | "end_time": "2022-07-03T21:38:49.980576", 17 | "exception": false, 18 | "start_time": "2022-07-03T21:38:49.896335", 19 | "status": "completed" 20 | }, 21 | "tags": [] 22 | }, 23 | "outputs": [], 24 | "source": [ 25 | "import numpy as np" 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": 2, 31 | "id": "a287ce2c", 32 | "metadata": { 33 | "execution": { 34 | "iopub.execute_input": "2022-07-03T21:38:49.985737Z", 35 | "iopub.status.busy": "2022-07-03T21:38:49.985223Z", 36 | "iopub.status.idle": "2022-07-03T21:38:49.997552Z", 37 | "shell.execute_reply": "2022-07-03T21:38:49.996495Z" 38 | }, 39 | "papermill": { 40 | "duration": 0.017821, 41 | "end_time": "2022-07-03T21:38:50.000071", 42 | "exception": false, 43 | "start_time": "2022-07-03T21:38:49.982250", 44 | "status": "completed" 45 | }, 46 | "tags": [ 47 | "metric" 48 | ] 49 | }, 50 | "outputs": [ 51 | { 52 | "data": { 53 | "text/plain": [ 54 | "10" 55 | ] 56 | }, 57 | "execution_count": 2, 58 | "metadata": {}, 59 | "output_type": "execute_result" 60 | } 61 | ], 62 | "source": [ 63 | "10" 64 | ] 65 | } 66 | ], 67 | "metadata": { 68 | "kernelspec": { 69 | "display_name": "Python 3 (ipykernel)", 70 | "language": "python", 71 | "name": "python3" 72 | }, 73 | "language_info": { 74 | "codemirror_mode": { 75 | "name": "ipython", 76 | "version": 3 77 | }, 78 | "file_extension": ".py", 79 | "mimetype": "text/x-python", 80 | "name": "python", 81 | "nbconvert_exporter": "python", 82 | "pygments_lexer": "ipython3", 83 | "version": "3.9.13" 84 | }, 85 | "papermill": { 86 | "default_parameters": {}, 87 | "duration": 1.446273, 88 | "end_time": "2022-07-03T21:38:50.226784", 89 | "environment_variables": {}, 90 | "exception": null, 91 | "input_path": "normal.ipynb", 92 | "output_path": "normal.ipynb", 93 | "parameters": {}, 94 | "start_time": "2022-07-03T21:38:48.780511", 95 | "version": "2.3.4" 96 | } 97 | }, 98 | "nbformat": 4, 99 | "nbformat_minor": 5 100 | } 101 | -------------------------------------------------------------------------------- /examples/image.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "d64db168", 7 | "metadata": { 8 | "execution": { 9 | "iopub.execute_input": "2022-08-09T13:33:37.369697Z", 10 | "iopub.status.busy": "2022-08-09T13:33:37.368974Z", 11 | "iopub.status.idle": "2022-08-09T13:33:38.124861Z", 12 | "shell.execute_reply": "2022-08-09T13:33:38.123681Z" 13 | }, 14 | "papermill": { 15 | "duration": 0.764655, 16 | "end_time": "2022-08-09T13:33:38.127376", 17 | "exception": false, 18 | "start_time": "2022-08-09T13:33:37.362721", 19 | "status": "completed" 20 | }, 21 | "tags": [] 22 | }, 23 | "outputs": [], 24 | "source": [ 25 | "import matplotlib.pyplot as plt" 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": 2, 31 | "id": "458521c1", 32 | "metadata": { 33 | "execution": { 34 | "iopub.execute_input": "2022-08-09T13:33:38.133494Z", 35 | "iopub.status.busy": "2022-08-09T13:33:38.133075Z", 36 | "iopub.status.idle": "2022-08-09T13:33:38.293498Z", 37 | "shell.execute_reply": "2022-08-09T13:33:38.292744Z" 38 | }, 39 | "papermill": { 40 | "duration": 0.16541, 41 | "end_time": "2022-08-09T13:33:38.295809", 42 | "exception": false, 43 | "start_time": "2022-08-09T13:33:38.130399", 44 | "status": "completed" 45 | }, 46 | "tags": [ 47 | "image" 48 | ] 49 | }, 50 | "outputs": [ 51 | { 52 | "data": { 53 | "text/plain": [ 54 | "[]" 55 | ] 56 | }, 57 | "execution_count": 2, 58 | "metadata": {}, 59 | "output_type": "execute_result" 60 | }, 61 | { 62 | "data": { 63 | "image/png": "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\n", 64 | "text/plain": [ 65 | "
" 66 | ] 67 | }, 68 | "metadata": { 69 | "needs_background": "light" 70 | }, 71 | "output_type": "display_data" 72 | } 73 | ], 74 | "source": [ 75 | "# force image to always have the same size\n", 76 | "%config InlineBackend.print_figure_kwargs = {'bbox_inches':None}\n", 77 | "_, ax = plt.subplots(figsize=(8, 6))\n", 78 | "ax.plot([1, 2, 3])" 79 | ] 80 | } 81 | ], 82 | "metadata": { 83 | "kernelspec": { 84 | "display_name": "Python 3 (ipykernel)", 85 | "language": "python", 86 | "name": "python3" 87 | }, 88 | "language_info": { 89 | "codemirror_mode": { 90 | "name": "ipython", 91 | "version": 3 92 | }, 93 | "file_extension": ".py", 94 | "mimetype": "text/x-python", 95 | "name": "python", 96 | "nbconvert_exporter": "python", 97 | "pygments_lexer": "ipython3", 98 | "version": "3.9.13" 99 | }, 100 | "papermill": { 101 | "default_parameters": {}, 102 | "duration": 2.782564, 103 | "end_time": "2022-08-09T13:33:38.527935", 104 | "environment_variables": {}, 105 | "exception": null, 106 | "input_path": "image.ipynb", 107 | "output_path": "image.ipynb", 108 | "parameters": {}, 109 | "start_time": "2022-08-09T13:33:35.745371", 110 | "version": "2.3.4" 111 | }, 112 | "vscode": { 113 | "interpreter": { 114 | "hash": "6f8a5954c9cc03e4b93e8ed0214d3d8b621da2a57e569171f08a848840f6b399" 115 | } 116 | } 117 | }, 118 | "nbformat": 4, 119 | "nbformat_minor": 5 120 | } -------------------------------------------------------------------------------- /examples/ml-classifier.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 9, 6 | "id": "efd5343f-910e-4a46-b4c8-626821a78592", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "from sklearn.datasets import load_breast_cancer\n", 11 | "from sklearn.model_selection import train_test_split\n", 12 | "from sklearn.ensemble import RandomForestClassifier\n", 13 | "from sklearn.metrics import accuracy_score" 14 | ] 15 | }, 16 | { 17 | "cell_type": "code", 18 | "execution_count": 10, 19 | "id": "b550379e-4094-42a0-a8e2-89af42a5c722", 20 | "metadata": {}, 21 | "outputs": [ 22 | { 23 | "data": { 24 | "text/html": [ 25 | "
\n", 26 | "\n", 39 | "\n", 40 | " \n", 41 | " \n", 42 | " \n", 43 | " \n", 44 | " \n", 45 | " \n", 46 | " \n", 47 | " \n", 48 | " \n", 49 | " \n", 50 | " \n", 51 | " \n", 52 | " \n", 53 | " \n", 54 | " \n", 55 | " \n", 56 | " \n", 57 | " \n", 58 | " \n", 59 | " \n", 60 | " \n", 61 | " \n", 62 | " \n", 63 | " \n", 64 | " \n", 65 | " \n", 66 | " \n", 67 | " \n", 68 | " \n", 69 | " \n", 70 | " \n", 71 | " \n", 72 | " \n", 73 | " \n", 74 | " \n", 75 | " \n", 76 | " \n", 77 | " \n", 78 | " \n", 79 | " \n", 80 | " \n", 81 | " \n", 82 | " \n", 83 | " \n", 84 | " \n", 85 | " \n", 86 | " \n", 87 | " \n", 88 | " \n", 89 | " \n", 90 | " \n", 91 | " \n", 92 | " \n", 93 | " \n", 94 | " \n", 95 | " \n", 96 | " \n", 97 | " \n", 98 | " \n", 99 | " \n", 100 | " \n", 101 | " \n", 102 | " \n", 103 | " \n", 104 | " \n", 105 | " \n", 106 | " \n", 107 | " \n", 108 | " \n", 109 | " \n", 110 | " \n", 111 | " \n", 112 | " \n", 113 | " \n", 114 | " \n", 115 | " \n", 116 | " \n", 117 | " \n", 118 | " \n", 119 | " \n", 120 | " \n", 121 | " \n", 122 | " \n", 123 | " \n", 124 | " \n", 125 | " \n", 126 | " \n", 127 | " \n", 128 | " \n", 129 | " \n", 130 | " \n", 131 | " \n", 132 | " \n", 133 | " \n", 134 | " \n", 135 | " \n", 136 | " \n", 137 | " \n", 138 | " \n", 139 | " \n", 140 | " \n", 141 | " \n", 142 | " \n", 143 | " \n", 144 | " \n", 145 | " \n", 146 | " \n", 147 | " \n", 148 | " \n", 149 | " \n", 150 | " \n", 151 | " \n", 152 | " \n", 153 | " \n", 154 | " \n", 155 | " \n", 156 | " \n", 157 | " \n", 158 | " \n", 159 | " \n", 160 | " \n", 161 | " \n", 162 | " \n", 163 | " \n", 164 | " \n", 165 | " \n", 166 | " \n", 167 | " \n", 168 | " \n", 169 | " \n", 170 | " \n", 171 | " \n", 172 | " \n", 173 | " \n", 174 | " \n", 175 | " \n", 176 | " \n", 177 | " \n", 178 | " \n", 179 | " \n", 180 | " \n", 181 | " \n", 182 | " \n", 183 | " \n", 184 | " \n", 185 | " \n", 186 | " \n", 187 | " \n", 188 | "
mean radiusmean texturemean perimetermean areamean smoothnessmean compactnessmean concavitymean concave pointsmean symmetrymean fractal dimension...worst textureworst perimeterworst areaworst smoothnessworst compactnessworst concavityworst concave pointsworst symmetryworst fractal dimensiontarget
017.9910.38122.801001.00.118400.277600.30010.147100.24190.07871...17.33184.602019.00.16220.66560.71190.26540.46010.118900
120.5717.77132.901326.00.084740.078640.08690.070170.18120.05667...23.41158.801956.00.12380.18660.24160.18600.27500.089020
219.6921.25130.001203.00.109600.159900.19740.127900.20690.05999...25.53152.501709.00.14440.42450.45040.24300.36130.087580
311.4220.3877.58386.10.142500.283900.24140.105200.25970.09744...26.5098.87567.70.20980.86630.68690.25750.66380.173000
420.2914.34135.101297.00.100300.132800.19800.104300.18090.05883...16.67152.201575.00.13740.20500.40000.16250.23640.076780
\n", 189 | "

5 rows × 31 columns

\n", 190 | "
" 191 | ], 192 | "text/plain": [ 193 | " mean radius mean texture mean perimeter mean area mean smoothness \\\n", 194 | "0 17.99 10.38 122.80 1001.0 0.11840 \n", 195 | "1 20.57 17.77 132.90 1326.0 0.08474 \n", 196 | "2 19.69 21.25 130.00 1203.0 0.10960 \n", 197 | "3 11.42 20.38 77.58 386.1 0.14250 \n", 198 | "4 20.29 14.34 135.10 1297.0 0.10030 \n", 199 | "\n", 200 | " mean compactness mean concavity mean concave points mean symmetry \\\n", 201 | "0 0.27760 0.3001 0.14710 0.2419 \n", 202 | "1 0.07864 0.0869 0.07017 0.1812 \n", 203 | "2 0.15990 0.1974 0.12790 0.2069 \n", 204 | "3 0.28390 0.2414 0.10520 0.2597 \n", 205 | "4 0.13280 0.1980 0.10430 0.1809 \n", 206 | "\n", 207 | " mean fractal dimension ... worst texture worst perimeter worst area \\\n", 208 | "0 0.07871 ... 17.33 184.60 2019.0 \n", 209 | "1 0.05667 ... 23.41 158.80 1956.0 \n", 210 | "2 0.05999 ... 25.53 152.50 1709.0 \n", 211 | "3 0.09744 ... 26.50 98.87 567.7 \n", 212 | "4 0.05883 ... 16.67 152.20 1575.0 \n", 213 | "\n", 214 | " worst smoothness worst compactness worst concavity worst concave points \\\n", 215 | "0 0.1622 0.6656 0.7119 0.2654 \n", 216 | "1 0.1238 0.1866 0.2416 0.1860 \n", 217 | "2 0.1444 0.4245 0.4504 0.2430 \n", 218 | "3 0.2098 0.8663 0.6869 0.2575 \n", 219 | "4 0.1374 0.2050 0.4000 0.1625 \n", 220 | "\n", 221 | " worst symmetry worst fractal dimension target \n", 222 | "0 0.4601 0.11890 0 \n", 223 | "1 0.2750 0.08902 0 \n", 224 | "2 0.3613 0.08758 0 \n", 225 | "3 0.6638 0.17300 0 \n", 226 | "4 0.2364 0.07678 0 \n", 227 | "\n", 228 | "[5 rows x 31 columns]" 229 | ] 230 | }, 231 | "execution_count": 10, 232 | "metadata": {}, 233 | "output_type": "execute_result" 234 | } 235 | ], 236 | "source": [ 237 | "df = load_breast_cancer(as_frame=True)['frame']\n", 238 | "df.head()" 239 | ] 240 | }, 241 | { 242 | "cell_type": "code", 243 | "execution_count": 11, 244 | "id": "a260f632-d67c-4b19-8a43-b5c89d1868b4", 245 | "metadata": {}, 246 | "outputs": [], 247 | "source": [ 248 | "X = df.drop('target', axis='columns')\n", 249 | "y = df['target']" 250 | ] 251 | }, 252 | { 253 | "cell_type": "code", 254 | "execution_count": 12, 255 | "id": "901c040f-13bc-4f13-9130-2321125bab07", 256 | "metadata": {}, 257 | "outputs": [], 258 | "source": [ 259 | "X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33)" 260 | ] 261 | }, 262 | { 263 | "cell_type": "code", 264 | "execution_count": 25, 265 | "id": "20af2d34-4c8d-4a1b-b00b-98dd6b9a9e92", 266 | "metadata": {}, 267 | "outputs": [], 268 | "source": [ 269 | "clf = RandomForestClassifier(n_estimators=100)" 270 | ] 271 | }, 272 | { 273 | "cell_type": "code", 274 | "execution_count": 34, 275 | "id": "fc36079a-edb7-4116-888e-e9a5c8269b76", 276 | "metadata": {}, 277 | "outputs": [ 278 | { 279 | "data": { 280 | "text/html": [ 281 | "
RandomForestClassifier(n_estimators=10)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
" 282 | ], 283 | "text/plain": [ 284 | "RandomForestClassifier(n_estimators=10)" 285 | ] 286 | }, 287 | "execution_count": 34, 288 | "metadata": {}, 289 | "output_type": "execute_result" 290 | } 291 | ], 292 | "source": [ 293 | "clf.fit(X_train, y_train)" 294 | ] 295 | }, 296 | { 297 | "cell_type": "code", 298 | "execution_count": 35, 299 | "id": "3abcc4ba-936e-4dc1-86e4-75a1412b3810", 300 | "metadata": {}, 301 | "outputs": [], 302 | "source": [ 303 | "y_pred = clf.predict(X_test)" 304 | ] 305 | }, 306 | { 307 | "cell_type": "code", 308 | "execution_count": 36, 309 | "id": "395ec5ad-77cb-418a-bb07-8a91055f1df7", 310 | "metadata": { 311 | "tags": [ 312 | "accuracy" 313 | ] 314 | }, 315 | "outputs": [ 316 | { 317 | "name": "stdout", 318 | "output_type": "stream", 319 | "text": [ 320 | "0.963\n" 321 | ] 322 | } 323 | ], 324 | "source": [ 325 | "acc = accuracy_score(y_test, y_pred)\n", 326 | "print(f'{acc:.3f}')" 327 | ] 328 | } 329 | ], 330 | "metadata": { 331 | "kernelspec": { 332 | "display_name": "Python 3 (ipykernel)", 333 | "language": "python", 334 | "name": "python3" 335 | }, 336 | "language_info": { 337 | "codemirror_mode": { 338 | "name": "ipython", 339 | "version": 3 340 | }, 341 | "file_extension": ".py", 342 | "mimetype": "text/x-python", 343 | "name": "python", 344 | "nbconvert_exporter": "python", 345 | "pygments_lexer": "ipython3", 346 | "version": "3.9.13" 347 | } 348 | }, 349 | "nbformat": 4, 350 | "nbformat_minor": 5 351 | } 352 | -------------------------------------------------------------------------------- /examples/normal.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "3a26d91f-1545-42ec-9428-796c0afbcdd5", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "import numpy as np" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": 2, 16 | "id": "c2f44c8d-31cc-4fd2-8fa4-a4c9dae2c54b", 17 | "metadata": { 18 | "tags": [ 19 | "metric" 20 | ] 21 | }, 22 | "outputs": [ 23 | { 24 | "data": { 25 | "text/plain": [ 26 | "0.9705309721595207" 27 | ] 28 | }, 29 | "execution_count": 2, 30 | "metadata": {}, 31 | "output_type": "execute_result" 32 | } 33 | ], 34 | "source": [ 35 | "np.random.normal()" 36 | ] 37 | } 38 | ], 39 | "metadata": { 40 | "kernelspec": { 41 | "display_name": "Python 3 (ipykernel)", 42 | "language": "python", 43 | "name": "python3" 44 | }, 45 | "language_info": { 46 | "codemirror_mode": { 47 | "name": "ipython", 48 | "version": 3 49 | }, 50 | "file_extension": ".py", 51 | "mimetype": "text/x-python", 52 | "name": "python", 53 | "nbconvert_exporter": "python", 54 | "pygments_lexer": "ipython3", 55 | "version": "3.9.13" 56 | } 57 | }, 58 | "nbformat": 4, 59 | "nbformat_minor": 5 60 | } 61 | -------------------------------------------------------------------------------- /header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ploomber/nbsnapshot/423e65a5da4cd29c0e425b15101dcf718ceb656b/header.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | addopts = "--pdbcls=IPython.terminal.debugger:Pdb" 3 | 4 | [tool.pkgmt] 5 | github = "ploomber/nbsnapshot" -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [flake8] 5 | exclude = build/ -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | import ast 3 | from glob import glob 4 | from os.path import basename, splitext 5 | 6 | from setuptools import find_packages 7 | from setuptools import setup 8 | 9 | _version_re = re.compile(r'__version__\s+=\s+(.*)') 10 | 11 | with open('src/nbsnapshot/__init__.py', 'rb') as f: 12 | VERSION = str( 13 | ast.literal_eval( 14 | _version_re.search(f.read().decode('utf-8')).group(1))) 15 | 16 | REQUIRES = [ 17 | 'click', 18 | 'papermill', 19 | 'ploomber-core>=0.0.4', 20 | # we need it to use papermill 21 | 'ipykernel', 22 | 'sklearn-evaluation', 23 | ] 24 | 25 | DEV = [ 26 | 'pytest', 27 | 'flake8', 28 | 'nbformat', 29 | 'ipykernel', 30 | 'invoke', 31 | ] 32 | 33 | setup( 34 | name='nbsnapshot', 35 | version=VERSION, 36 | description=None, 37 | license=None, 38 | author=None, 39 | author_email=None, 40 | url=None, 41 | packages=find_packages('src'), 42 | package_dir={'': 'src'}, 43 | py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], 44 | include_package_data=True, 45 | classifiers=[], 46 | keywords=[], 47 | install_requires=REQUIRES, 48 | extras_require={ 49 | 'dev': DEV, 50 | }, 51 | entry_points={ 52 | 'console_scripts': ['nbsnapshot=nbsnapshot.cli:cli'], 53 | }, 54 | ) 55 | -------------------------------------------------------------------------------- /src/nbsnapshot/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.2.3dev' 2 | -------------------------------------------------------------------------------- /src/nbsnapshot/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from nbsnapshot import compare 4 | 5 | 6 | @click.group() 7 | def cli(): 8 | pass 9 | 10 | 11 | @cli.command() 12 | @click.argument('path', type=click.Path(exists=True)) 13 | @click.option('-r', 14 | '--run', 15 | is_flag=True, 16 | show_default=True, 17 | default=False, 18 | help="Run notebook before comparing.") 19 | def test(path, run): 20 | """Test a notebook comparing against historical results 21 | """ 22 | compare.main(path, run=run) 23 | -------------------------------------------------------------------------------- /src/nbsnapshot/compare.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from io import BytesIO 3 | import json 4 | from pathlib import Path 5 | import statistics 6 | from collections.abc import Mapping 7 | 8 | import click 9 | import papermill as pm 10 | from ploomber_core.telemetry.telemetry import Telemetry 11 | from sklearn_evaluation import NotebookIntrospector 12 | from sklearn_evaluation.nb.NotebookIntrospector import _safe_literal_eval 13 | 14 | from nbsnapshot import __version__ 15 | from nbsnapshot.exceptions import SnapshotTestFailure 16 | 17 | telemetry = Telemetry( 18 | api_key="phc_P9SpSeypyPwxrMdFn2edOOEooQioF2axppyEeDwtMSP", 19 | package_name="nbsnapshot", 20 | version=__version__, 21 | ) 22 | 23 | 24 | def _remove_non_supported_types(record): 25 | clean = dict() 26 | show_warning = False 27 | 28 | for key, value in record.items(): 29 | if isinstance(value, (int, float, bool)): 30 | clean[key] = value 31 | elif isinstance(value, Image): 32 | clean[key] = value.to_json_serializable() 33 | else: 34 | show_warning = True 35 | 36 | if show_warning: 37 | click.echo(f'Got unsupported data type ({type(value).__name__}), ' 38 | 'ignoring... (only int, float and bool are supported)') 39 | 40 | return clean 41 | 42 | 43 | def _str2arr(data): 44 | from PIL import Image 45 | import numpy as np 46 | 47 | bytes_ = base64.b64decode(data) 48 | image = Image.open(BytesIO(bytes_)) 49 | return np.array(image, dtype=np.float64) 50 | 51 | 52 | def _mean_squared_error(image0, image1): 53 | import numpy as np 54 | return np.mean((image0 - image1)**2, dtype=np.float64) 55 | 56 | 57 | def _compare_images(arrs): 58 | for image0, image1 in zip(arrs, arrs[1:]): 59 | yield _mean_squared_error(image0, image1) 60 | 61 | 62 | def _can_test(stored, key, is_number, echo=True): 63 | minimum = 3 if is_number else 2 64 | # we need at least two observations to compute standard deviation 65 | # plus the observation to compare 66 | len_ = len(stored) 67 | 68 | # TODO: add a CLI argument to control the minimum observations 69 | # to start testing 70 | if len_ < minimum: 71 | if echo: 72 | needed = (minimum - 1) - len_ 73 | if needed: 74 | click.echo(f'Added {key!r} to history, {needed} more ' 75 | 'needed for testing...') 76 | else: 77 | click.echo(f'Added {key!r} to history, next call ' 78 | 'will start testing...') 79 | 80 | # skip the rest of the function 81 | return False 82 | else: 83 | return True 84 | 85 | 86 | def _equal_sizes(arrs, key): 87 | shapes = set(arr.shape for arr in arrs) 88 | same_size = len(shapes) == 1 89 | 90 | if same_size: 91 | return True 92 | else: 93 | click.secho( 94 | f'Testing {key!r} - FAIL! Images ' 95 | f'with multiple sizes: {shapes}', 96 | fg='red') 97 | return False 98 | 99 | 100 | # NOTE: we could store the history as metadata in the same ipynb 101 | # this could even allow us to plot the history below each cell 102 | class History: 103 | 104 | def __init__(self, path): 105 | if not path.exists(): 106 | path.write_text(json.dumps([])) 107 | 108 | self._path = path 109 | self._data = _load_json(path) 110 | 111 | def __getitem__(self, key): 112 | return [ 113 | data.get(key) for data in self._data if data.get(key) is not None 114 | ] 115 | 116 | def keys(self): 117 | keys = set() 118 | 119 | for data in self._data: 120 | keys = keys | set(data.keys()) 121 | 122 | return list(keys) 123 | 124 | def append(self, record): 125 | # TODO: add _timestamp 126 | record_clean = _remove_non_supported_types(record) 127 | 128 | if record_clean: 129 | self._data.append(record_clean) 130 | Path(self._path).write_text(json.dumps(self._data)) 131 | 132 | def __len__(self): 133 | return len(self._data) 134 | 135 | def compare(self, key, current): 136 | stored = self[key] + [current] 137 | 138 | # no history 139 | if not len(stored): 140 | return True 141 | 142 | if isinstance(stored[0], (int, float)): 143 | is_number = True 144 | elif isinstance(stored[0], Mapping): 145 | is_number = False 146 | else: 147 | click.secho( 148 | 'Got unrecognized type of ' 149 | f'data: {type(stored[0]).__name__}, ignoring...', 150 | fg='yellow') 151 | 152 | return True 153 | 154 | # check if we can test - numbers need 3, image need 2 obs 155 | can_test = _can_test(stored, key, is_number) 156 | 157 | if can_test: 158 | if is_number: 159 | return self._compare_numeric(stored, key) 160 | else: 161 | return self._compare_image(stored, key) 162 | else: 163 | return True 164 | 165 | def _compare_image(self, stored, key): 166 | arrs = [_str2arr(image['data']) for image in stored] 167 | equal_sizes = _equal_sizes(arrs, key) 168 | 169 | if not equal_sizes: 170 | return False 171 | 172 | errors = list(_compare_images(arrs)) 173 | 174 | # check if we have enough errors to compare 175 | can_test = _can_test(errors, key, is_number=True, echo=False) 176 | 177 | if can_test: 178 | return self._compare_numeric(errors, key) 179 | else: 180 | # this means we only checked image size 181 | click.secho(f'Testing [image size]: {key!r} - OK!', fg='green') 182 | return True 183 | 184 | def _compare_numeric(self, stored, key): 185 | # ignore the last one since that's the same as the value 186 | stdev = statistics.stdev(stored[:-1]) 187 | mean = statistics.mean(stored[:-1]) 188 | low, high = mean - 3 * stdev, mean + 3 * stdev 189 | 190 | # last value is the one we'll use for comparison 191 | value = stored[-1] 192 | 193 | success = True 194 | 195 | if value < low: 196 | success = False 197 | click.secho( 198 | f"Testing {key!r} - FAIL! " 199 | "Value is too low " 200 | f"({value:.2f}), expected one " 201 | f"between {low:.2f} and {high:.2f}", 202 | fg='red') 203 | elif value > high: 204 | success = False 205 | click.secho( 206 | f"Testing {key!r} - FAIL! " 207 | "Value is too high " 208 | f"({value:.2f}), expected one " 209 | f"between {low:.2f} and {high:.2f}", 210 | fg='red') 211 | else: 212 | click.secho(f'Testing: {key!r} - OK!', fg='green') 213 | 214 | return success 215 | 216 | 217 | def _load_json(path): 218 | return json.loads(Path(path).read_text()) 219 | 220 | 221 | class Image: 222 | 223 | def __init__(self, data): 224 | self._data = data 225 | 226 | def to_json_serializable(self): 227 | return dict(mimetype='image/png', data=self._data) 228 | 229 | 230 | # modified from sklearn-evaluation's source code 231 | def _parse_output(output, literal_eval): 232 | if 'image/png' in output: 233 | return Image(output['image/png']) 234 | elif 'text/plain' in output: 235 | out = output['text/plain'] 236 | return out if not literal_eval else _safe_literal_eval(out, 237 | to_df=False) 238 | 239 | 240 | def _extract_data(path): 241 | nb = NotebookIntrospector(path) 242 | data = { 243 | k: _parse_output( 244 | v, 245 | literal_eval=True, 246 | ) 247 | for k, v in nb.tag2output_raw.items() 248 | } 249 | 250 | return data 251 | 252 | 253 | @telemetry.log_call('compare-main') 254 | def main(path_to_notebook: str, run: bool = False): 255 | if run: 256 | click.echo('Running notebook...') 257 | pm.execute_notebook(path_to_notebook, 258 | path_to_notebook, 259 | progress_bar=False) 260 | 261 | path_to_history = Path(path_to_notebook).with_suffix('.json') 262 | 263 | data = _extract_data(path_to_notebook) 264 | 265 | history = History(path_to_history) 266 | 267 | success = True 268 | 269 | for key, value in data.items(): 270 | if hasattr(value, 'to_json_serializable'): 271 | value = value.to_json_serializable() 272 | 273 | if not history.compare(key, value): 274 | success = False 275 | 276 | if not success: 277 | raise SnapshotTestFailure('Some tests failed.') 278 | else: 279 | history.append(data) 280 | -------------------------------------------------------------------------------- /src/nbsnapshot/exceptions.py: -------------------------------------------------------------------------------- 1 | from click.exceptions import ClickException 2 | 3 | 4 | class SnapshotTestFailure(ClickException): 5 | pass 6 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | from invoke import task 2 | 3 | 4 | @task 5 | def setup(c, version=None): 6 | """ 7 | Setup dev environment, requires conda 8 | """ 9 | version = version or '3.9' 10 | suffix = '' if version == '3.9' else version.replace('.', '') 11 | env_name = f'nbsnapshot{suffix}' 12 | 13 | c.run(f'conda create --name {env_name} python={version} --yes') 14 | c.run('eval "$(conda shell.bash hook)" ' 15 | f'&& conda activate {env_name} ' 16 | '&& pip install --editable .[dev]') 17 | 18 | print(f'Done! Activate your environment with:\nconda activate {env_name}') 19 | 20 | 21 | @task(aliases=['d']) 22 | def doc(c): 23 | """Build documentation 24 | """ 25 | c.run('jupyter-book build doc') 26 | 27 | 28 | @task(aliases=['v']) 29 | def version(c): 30 | """Release a new version 31 | """ 32 | from pkgmt import versioneer 33 | versioneer.version(project_root='.', tag=True) 34 | 35 | 36 | @task(aliases=['r']) 37 | def release(c, tag, production=True): 38 | """Upload to PyPI 39 | """ 40 | from pkgmt import versioneer 41 | versioneer.upload(tag, production=production) 42 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from pathlib import Path 4 | 5 | import papermill as pm 6 | import nbformat 7 | import pytest 8 | 9 | 10 | @pytest.fixture 11 | def tmp_empty(tmp_path): 12 | """ 13 | Create temporary path using pytest native fixture, 14 | them move it, yield, and restore the original path 15 | """ 16 | old = os.getcwd() 17 | os.chdir(str(tmp_path)) 18 | yield str(Path(tmp_path).resolve()) 19 | os.chdir(old) 20 | 21 | 22 | def _load_json(path): 23 | return json.loads(Path(path).read_text()) 24 | 25 | 26 | def _new_cell(source, tag): 27 | return nbformat.v4.new_code_cell(source=source, metadata=dict(tags=[tag])) 28 | 29 | 30 | def _make_notebook_with_cells(cells, name='nb', top_cell=None): 31 | nb = nbformat.v4.new_notebook() 32 | 33 | if top_cell: 34 | top = nbformat.v4.new_code_cell(source=top_cell) 35 | nb.cells = [top] 36 | else: 37 | nb.cells = [] 38 | 39 | nb.cells.extend(_new_cell(source, tag) for source, tag in cells) 40 | path = f'{name}.ipynb' 41 | nbformat.write(nb, path) 42 | pm.execute_notebook(path, path, kernel_name='python3') 43 | -------------------------------------------------------------------------------- /tests/test_compare.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from nbsnapshot import compare, exceptions 4 | 5 | from conftest import _make_notebook_with_cells, _load_json 6 | 7 | 8 | def test_compare_creates_history(tmp_empty): 9 | _make_notebook_with_cells([ 10 | ('1 + 1', 'first'), 11 | ('2.2', 'second'), 12 | ('print(True)', 'third'), 13 | ], 'nb') 14 | 15 | compare.main('nb.ipynb') 16 | 17 | history = _load_json('nb.json') 18 | 19 | assert history == [{'first': 2, 'second': 2.2, 'third': True}] 20 | 21 | 22 | def test_compare_appends_to_history(tmp_empty): 23 | _make_notebook_with_cells([ 24 | ('1 + 1', 'first'), 25 | ('2 + 2', 'second'), 26 | ], 'nb') 27 | 28 | compare.main('nb.ipynb') 29 | compare.main('nb.ipynb') 30 | 31 | history = _load_json('nb.json') 32 | 33 | assert history == [ 34 | { 35 | 'first': 2, 36 | 'second': 4 37 | }, 38 | { 39 | 'first': 2, 40 | 'second': 4 41 | }, 42 | ] 43 | 44 | 45 | @pytest.mark.parametrize('source, error', [ 46 | [ 47 | '100 + 100', 48 | ("Testing 'first' - FAIL! Value is too high " 49 | "(200.00), expected one between 2.00 and 2.00") 50 | ], 51 | [ 52 | '-100 - 100', 53 | ("Testing 'first' - FAIL! Value is too low " 54 | "(-200.00), expected one between 2.00 and 2.00") 55 | ], 56 | ]) 57 | def test_compare_raises_error_if_deviates(tmp_empty, source, error, capsys): 58 | _make_notebook_with_cells([ 59 | ('1 + 1', 'first'), 60 | ('2 + 2', 'second'), 61 | ], 'nb') 62 | 63 | compare.main('nb.ipynb') 64 | compare.main('nb.ipynb') 65 | compare.main('nb.ipynb') 66 | 67 | _make_notebook_with_cells([ 68 | (source, 'first'), 69 | ('2 + 2', 'second'), 70 | ], 'nb') 71 | 72 | with pytest.raises(exceptions.SnapshotTestFailure) as excinfo: 73 | compare.main('nb.ipynb') 74 | 75 | captured = capsys.readouterr() 76 | assert error in captured.out 77 | assert 'Some tests failed.' == str(excinfo.value) 78 | # should not add the new record to the history 79 | assert len(_load_json('nb.json')) == 3 80 | 81 | 82 | @pytest.mark.parametrize('cells, expected', [ 83 | [ 84 | [ 85 | ('[1, 2, 3]', 'first'), 86 | ('dict(a=1, b=2)', 'second'), 87 | ], 88 | [], 89 | ], 90 | [ 91 | [ 92 | ('[1, 2, 3]', 'first'), 93 | ('1', 'second'), 94 | ], 95 | [dict(second=1)], 96 | ], 97 | ]) 98 | def test_compare_ignores_non_numbers(tmp_empty, cells, expected): 99 | _make_notebook_with_cells(cells, 'nb') 100 | 101 | compare.main('nb.ipynb') 102 | 103 | history = _load_json('nb.json') 104 | 105 | assert history == expected 106 | 107 | 108 | def test_adds_new_cells_to_history(tmp_empty): 109 | _make_notebook_with_cells([ 110 | ('1', 'first'), 111 | ], 'nb') 112 | 113 | compare.main('nb.ipynb') 114 | 115 | assert _load_json('nb.json') == [{'first': 1}] 116 | 117 | _make_notebook_with_cells([ 118 | ('2', 'first'), 119 | ('3', 'second'), 120 | ], 'nb') 121 | compare.main('nb.ipynb') 122 | 123 | assert _load_json('nb.json') == [{'first': 1}, {'first': 2, 'second': 3}] 124 | -------------------------------------------------------------------------------- /tests/test_compare_image.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import ANY 2 | 3 | import pytest 4 | 5 | from nbsnapshot import compare, exceptions 6 | from conftest import _make_notebook_with_cells, _load_json 7 | 8 | 9 | def test_compare_creates_history(tmp_empty, capsys): 10 | _make_notebook_with_cells([ 11 | ('plt.plot([1, 2, 3])', 'plot'), 12 | ], 13 | top_cell=""" 14 | import matplotlib.pyplot as plt 15 | """) 16 | 17 | compare.main('nb.ipynb') 18 | 19 | history = _load_json('nb.json') 20 | 21 | assert history == [{'plot': {'mimetype': 'image/png', 'data': ANY}}] 22 | 23 | 24 | def test_compare_image(tmp_empty, capsys): 25 | _make_notebook_with_cells([ 26 | ('plt.plot([1, 2, 3])', 'plot'), 27 | ], 28 | top_cell=""" 29 | import matplotlib.pyplot as plt 30 | """) 31 | 32 | compare.main('nb.ipynb') 33 | 34 | _make_notebook_with_cells([ 35 | ('plt.plot([3, 2, 1])', 'plot'), 36 | ], 37 | top_cell=""" 38 | import matplotlib.pyplot as plt 39 | """) 40 | 41 | compare.main('nb.ipynb') 42 | 43 | _make_notebook_with_cells([ 44 | ('plt.plot([1, 2, 3])', 'plot'), 45 | ], 46 | top_cell=""" 47 | import matplotlib.pyplot as plt 48 | """) 49 | 50 | compare.main('nb.ipynb') 51 | 52 | _make_notebook_with_cells([ 53 | ('plt.plot([3, 1, 2])', 'plot'), 54 | ], 55 | top_cell=""" 56 | import matplotlib.pyplot as plt 57 | """) 58 | 59 | with pytest.raises(exceptions.SnapshotTestFailure) as excinfo: 60 | compare.main('nb.ipynb') 61 | 62 | captured = capsys.readouterr() 63 | assert "Testing 'plot' - FAIL!" in captured.out 64 | assert 'Some tests failed.' == str(excinfo.value) 65 | 66 | 67 | def test_error_if_different_sizes(tmp_empty, capsys): 68 | make_plot_cell = """ 69 | import matplotlib.pyplot as plt 70 | 71 | def make_plot(w, h): 72 | fig = plt.figure(figsize=(w, h)) 73 | ax = fig.add_axes([0.1, 0.1, 0.8, 0.8]) 74 | _ = ax.plot([1, 2, 3]) 75 | """ 76 | 77 | _make_notebook_with_cells([ 78 | ('make_plot(1, 1)', 'plot'), 79 | ], 80 | top_cell=make_plot_cell) 81 | 82 | compare.main('nb.ipynb') 83 | 84 | _make_notebook_with_cells([ 85 | ('make_plot(2, 2)', 'plot'), 86 | ], 87 | top_cell=make_plot_cell) 88 | 89 | with pytest.raises(exceptions.SnapshotTestFailure) as excinfo: 90 | compare.main('nb.ipynb') 91 | 92 | captured = capsys.readouterr() 93 | assert "Testing 'plot' - FAIL!" in captured.out 94 | assert 'Some tests failed.' == str(excinfo.value) 95 | --------------------------------------------------------------------------------