[
  {
    "path": ".github/CODEOWNERS",
    "content": "* @srstevenson\n"
  },
  {
    "path": ".github/CONTRIBUTING.md",
    "content": "# Contributing\n\nThanks for considering contributing! The following is a set of guidelines for\ndoing so. They're guidelines rather than rules, so follow your best judgement,\nbut reading them will help make the contribution process easier and more\neffective for both you and the maintainers.\n\n## Reporting issues\n\nGitHub issues are used for managing bug reports and feature requests, except\nsecurity vulnerabilities: these should be emailed to the maintainers instead.\n\nSearch for existing issues before creating a new one, to ensure your problem\nhasn't already been reported. If it has, you're welcome to comment on the\nexisting issue with extra information that might help reproduce and fix the\nproblem, or sharing why a feature would be useful, but refrain from \"+1\" type\ncomments. Duplicate issues will be closed with a reference to the existing\nissue.\n\nIn your report describe what you did, what you expected to happen, and what\nhappened instead. Provide a [minimal reproducible example][mre] that the\nmaintainers can run. Provide as much detail as you can in your description of\nthe problem, including the version of the project you're using, and details of\nyour operating system and environment, and other information which might help\ndiagnose the problem, such as what you've already tried to fix it.\n\n## Contributing changes\n\n### Planning\n\nWhen you contribute a new change, the responsibility for maintenance is (by\ndefault) transferred to the existing project maintainers. The benefit of the\ncontribution must be weighed against the cost of maintaining it.\n\nIf you're considering contributing a non-trivial bugfix or feature, discuss the\nchanges you plan to make before you start coding by opening an issue. This\nensures your proposed change will be accepted, and provides the maintainers the\nopportunity to help you.\n\n### Implementation\n\nChanges are managed using GitHub pull requests. If you're new to pull requests,\nread the [documentation][pr docs] to learn how they work.\n\n[uv] is used for managing dependencies and packaging, and you will need it\ninstalled. If you're not familiar with uv, we suggest reading its documentation\nbefore you begin.\n\nAfter cloning the repository, you can implement your changes as follows:\n\n1. Install the project and its dependencies into an isolated virtual environment\n   with `uv sync`.\n2. Before making your changes, run the tests with `just test`, and ensure they\n   pass. This checks your development environment is correctly configured, and\n   there aren't outstanding issues before you start coding. If they don't pass,\n   you can open a GitHub issue for help debugging.\n3. Checkout a new branch for your changes, branching from `main`, with a\n   sensible name for your changes.\n4. Implement your changes.\n5. If you introduced new functionality or fixed a bug, add appropriate automated\n   tests to prevent future regressions.\n6. Ensure you've updated any docstrings or documentation files (including\n   `README.md`) which are affected by your change.\n7. Run the formatter, linter and type checker with `just fmt lint`, and tests\n   with `just test`, and fix any problems.\n8. Commit your changes, following [these guidelines][commit guidelines] for your\n   commit messages.\n9. Fork the base repository on GitHub, push your branch to your fork, and open a\n   pull request against the base repository. Make sure your pull request has a\n   clear title and description. The easier your changes are to understand, the\n   easier it is for the maintainers to approve and merge them.\n10. Your pull request will be reviewed by the maintainers and either merged, or\n    feedback will be provided on changes that are required.\n\n[commit guidelines]:\n  https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html\n[mre]: https://stackoverflow.com/help/minimal-reproducible-example\n[pr docs]: https://docs.github.com/en/github/collaborating-with-pull-requests\n[uv]: https://docs.astral.sh/uv/\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"pip\"\n    directory: \"/\"\n    schedule:\n      interval: \"monthly\"\n    cooldown:\n      default-days: 7\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"monthly\"\n    cooldown:\n      default-days: 7\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n\njobs:\n  checks:\n    name: Run checks\n    runs-on: ubuntu-slim\n    strategy:\n      matrix:\n        python:\n          - \"3.10\"\n          - \"3.11\"\n          - \"3.12\"\n          - \"3.13\"\n          - \"3.14\"\n    env:\n      UV_PYTHON: ${{ matrix.python }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Setup uv\n        uses: astral-sh/setup-uv@v7\n\n      - name: Setup Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ matrix.python }}\n\n      - name: Install dependencies\n        run: uv sync --dev\n\n      - name: Run formatter\n        run: uv run ruff format --check .\n\n      - name: Run linter\n        run: uv run ruff check .\n\n      - name: Run type checker\n        run: uv run ty check .\n\n      - name: Run tests\n        run: uv run coverage run -m pytest\n\n      - name: Print test coverage report\n        run: uv run coverage report\n"
  },
  {
    "path": ".gitignore",
    "content": "*.egg-info/\n.ipynb_checkpoints/\n/.coverage\n/build/\n/coverage.xml\n/dist/\n__pycache__/\n"
  },
  {
    "path": ".pre-commit-hooks.yaml",
    "content": "- id: nb-clean\n  name: nb-clean\n  entry: nb-clean clean\n  language: python\n  types_or: [jupyter]\n  minimum_pre_commit_version: 2.9.2\n"
  },
  {
    "path": ".prettierrc.toml",
    "content": "proseWrap = \"always\"\n"
  },
  {
    "path": ".python-version",
    "content": "3.10\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright © Scott Stevenson <scott@stevenson.io>\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM\nLOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR\nOTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR\nPERFORMANCE OF THIS SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\"><img src=\"images/nb-clean.png\" width=300></p>\n\n[![License](https://img.shields.io/github/license/srstevenson/nb-clean?label=License&color=blue)](https://github.com/srstevenson/nb-clean/blob/main/LICENSE)\n[![GitHub release](https://img.shields.io/github/v/release/srstevenson/nb-clean?label=GitHub)](https://github.com/srstevenson/nb-clean)\n[![PyPI version](https://img.shields.io/pypi/v/nb-clean?label=PyPI)](https://pypi.org/project/nb-clean/)\n[![Python versions](https://img.shields.io/pypi/pyversions/nb-clean?label=Python)](https://pypi.org/project/nb-clean/)\n[![CI status](https://github.com/srstevenson/nb-clean/workflows/CI/badge.svg)](https://github.com/srstevenson/nb-clean/actions)\n\nnb-clean cleans Jupyter notebooks of cell execution counts, metadata, outputs,\nand (optionally) empty cells, preparing them for committing to version control.\nIt provides both a Git filter and pre-commit hook to automatically clean\nnotebooks before they're staged, and can also be used with other version control\nsystems, as a command line tool, and as a Python library. It can determine if a\nnotebook is clean or not, which can be used as a check in your continuous\nintegration pipelines.\n\nJupyter notebooks contain execution metadata that changes every time you run a\ncell, including execution counts, timestamps, and output data. When committed to\nversion control, these elements create unnecessary diff noise, make meaningful\ncode review difficult, and can accidentally expose sensitive information in cell\noutputs. By cleaning notebooks before committing, you preserve only the\nessential code and markdown content, leading to cleaner diffs, more focused\nreviews, and better collaboration.\n\nFor a detailed discussion of the challenges notebooks present for version\ncontrol and collaborative development, see my [PyCon UK 2017 talk][pycon talk]\nand accompanying [blog post][blog post].\n\n> [!NOTE]\n>\n> nb-clean 2.0.0 introduced a new command line interface to make cleaning\n> notebooks in place easier. If you upgrade from a previous release, you'll need\n> to migrate to the new interface as described under\n> [Migrating to nb-clean 2](#migrating-to-nb-clean-2).\n\n## Installation\n\nnb-clean requires Python 3.10 or later. To run the latest release of nb-clean in\nan ephemeral virtual environment, use [uv]:\n\n```bash\nuvx nb-clean\n```\n\nTo add nb-clean as a dependency to a Python project managed with uv, use:\n\n```bash\nuv add --dev nb-clean\n```\n\n## Command line usage\n\n### Understanding notebook metadata\n\nJupyter notebooks contain several types of metadata that nb-clean can handle:\n\n**Cell metadata** includes information attached to individual cells, such as\ntags, slideshow settings, and execution timing. Cell metadata fields like\n`collapsed`, `scrolled`, `deletable`, and `editable` control notebook interface\nbehaviour, whilst `tags` and custom fields support workflow automation.\n\n**Notebook metadata** contains document-level information including the kernel\nspecification, language version, and notebook format version. The language\nversion information (`metadata.language_info.version`) frequently changes\nbetween Python versions and creates unnecessary version control noise.\n\n**Execution metadata** encompasses execution counts for code cells and their\noutputs, along with execution timestamps and output data. This metadata changes\nevery time you run cells, regardless of whether the actual code has changed.\n\n### Checking\n\nYou can check if a notebook is clean with:\n\n```bash\nnb-clean check notebook.ipynb\n```\n\nYou can also process notebooks through standard input and output streams, which\nis useful for integrating with shell pipelines or processing notebooks without\nwriting to disk:\n\n```bash\nnb-clean check < notebook.ipynb\n```\n\nWhen reading from standard input, nb-clean processes the notebook content\ndirectly without accessing the filesystem. This approach is particularly useful\nfor automated workflows, continuous integration pipelines, or when you want to\ncheck notebooks without creating temporary files.\n\nThe check can be run with the following flags:\n\n- To check for empty cells use `--remove-empty-cells` or the short form `-e`.\n- To ignore cell metadata use `--preserve-cell-metadata` or the short form `-m`.\n  This will ignore all metadata fields. You can also pass a list of fields to\n  ignore with `--preserve-cell-metadata field1 field2` or `-m field1 field2`.\n  Note that when _not_ passing a list of fields, either the `-m` or\n  `--preserve-cell-metadata` flag must be passed _after_ the notebook paths to\n  process, or the notebook paths should be preceded with `--` so they are not\n  interpreted as metadata fields.\n- To ignore cell outputs use `--preserve-cell-outputs` or the short form `-o`.\n- To ignore cell execution counts use `--preserve-execution-counts` or the short\n  form `-c`.\n- To ignore language version notebook metadata use\n  `--preserve-notebook-metadata` or the short form `-n`.\n- To check the notebook does not contain any notebook metadata use\n  `--remove-all-notebook-metadata` or the short form `-M`.\n\nFor example, to check if a notebook is clean whilst ignoring notebook metadata:\n\n```bash\nnb-clean check --preserve-notebook-metadata notebook.ipynb\n```\n\nTo check if a notebook is clean whilst ignoring all cell metadata:\n\n```bash\nnb-clean check --preserve-cell-metadata -- notebook.ipynb\n```\n\nTo check if a notebook is clean whilst ignoring only the `tags` cell metadata\nfield:\n\n```bash\nnb-clean check --preserve-cell-metadata tags -- notebook.ipynb\n```\n\nnb-clean will exit with status code 0 if the notebook is clean, and status code\n1 if it is not. nb-clean will also print details of cell execution counts,\nmetadata, outputs, and empty cells it finds.\n\nNote that the conflicting options `--preserve-notebook-metadata` and\n`--remove-all-notebook-metadata` cannot be used together, as they represent\ncontradictory instructions.\n\n### Cleaning (interactive)\n\nYou can clean a Jupyter notebook with:\n\n```bash\nnb-clean clean notebook.ipynb\n```\n\nThis cleans the notebook in place. You can also pass the notebook content on\nstandard input, in which case the cleaned notebook is written to standard\noutput:\n\n```bash\nnb-clean clean < original.ipynb > cleaned.ipynb\n```\n\nThe cleaning can be run with the following flags:\n\n- To remove empty cells use `--remove-empty-cells` or the short form `-e`.\n- To preserve cell metadata use `--preserve-cell-metadata` or the short form\n  `-m`. This will preserve all metadata fields. You can also pass a list of\n  fields to preserve with `--preserve-cell-metadata field1 field2` or\n  `-m field1 field2`. Note that when _not_ passing a list of fields, either the\n  `-m` or `--preserve-cell-metadata` flag must be passed _after_ the notebook\n  paths to process, or the notebook paths should be preceded with `--` so they\n  are not interpreted as metadata fields.\n- To preserve cell outputs use `--preserve-cell-outputs` or the short form `-o`.\n- To preserve cell execution counts use `--preserve-execution-counts` or the\n  short form `-c`.\n- To preserve notebook metadata (such as language version) use\n  `--preserve-notebook-metadata` or the short form `-n`.\n- To remove all notebook metadata use `--remove-all-notebook-metadata` or the\n  short form `-M`.\n\nFor example, to clean a notebook whilst preserving notebook metadata:\n\n```bash\nnb-clean clean --preserve-notebook-metadata notebook.ipynb\n```\n\nTo clean a notebook whilst preserving all cell metadata:\n\n```bash\nnb-clean clean --preserve-cell-metadata -- notebook.ipynb\n```\n\nTo clean a notebook whilst preserving only the `tags` cell metadata field:\n\n```bash\nnb-clean clean --preserve-cell-metadata tags -- notebook.ipynb\n```\n\n#### Directory processing\n\nBoth the `check` and `clean` commands can operate on directories as well as\nindividual notebook files. When you provide a directory path, nb-clean will\nrecursively find all `.ipynb` files within that directory and process them. For\nexample:\n\n```bash\nnb-clean check notebooks/\n```\n\nor\n\n```bash\nnb-clean clean experiments/\n```\n\nThis is particularly useful for batch processing entire project directories or\nensuring all notebooks in a repository are clean.\n\n### Cleaning (Git filter)\n\nTo add a filter to an existing Git repository to automatically clean notebooks\nwhen they're staged, run the following from the working tree:\n\n```bash\nnb-clean add-filter\n```\n\nThis will configure a filter to remove cell execution counts, metadata, and\noutputs. The same flags as described above for\n[interactive cleaning](#cleaning-interactive) can be passed to customise the\nbehaviour.\n\nThe Git filter operates by configuring the `filter.nb-clean.clean` setting in\nyour repository's local Git configuration and adding the line\n`*.ipynb filter=nb-clean` to `.git/info/attributes`. This ensures that all\nnotebook files are automatically processed through nb-clean when staged for\ncommit. The filter configuration is local to the repository and won't affect\nyour global or system Git settings.\n\nTo remove the filter, run:\n\n```bash\nnb-clean remove-filter\n```\n\n### Cleaning (Jujutsu)\n\nnb-clean can be used to clean notebooks tracked with [Jujutsu] rather than Git.\nConfigure Jujutsu to use nb-clean as a fix tool by adding the following snippet\nto `~/.config/jj/config.toml`:\n\n```toml\n[fix.tools.nb-clean]\ncommand = [\"nb-clean\", \"clean\"]\npatterns = [\"glob:'**/*.ipynb'\"]\n```\n\nThe same flags as described above for\n[interactive cleaning](#cleaning-interactive) can be appended to the `command`\narray to customise the behaviour.\n\nTracked notebooks can then be cleaned by running `jj fix`. See the [Jujutsu\ndocumentation][jujutsu docs] for further details of how to invoke and configure\nfix tools.\n\n### Cleaning (pre-commit hook)\n\nnb-clean can also be used as a [pre-commit] hook. You may prefer this to the Git\nfilter if your project already uses the pre-commit framework.\n\nNote that the Git filter and pre-commit hook work differently, with different\neffects on your working directory. The pre-commit hook operates on the notebook\non disk, cleaning the copy in your working directory. The Git filter cleans\nnotebooks as they are added to the index, leaving the copy in your working\ndirectory dirty. This means cell outputs are still visible to you in your local\nJupyter instance when using the Git filter, but not when using the pre-commit\nhook.\n\nAfter installing [pre-commit], add the nb-clean hook by adding the following\nsnippet to `.pre-commit-config.yaml` in the root of your repository:\n\n```yaml\nrepos:\n  - repo: https://github.com/srstevenson/nb-clean\n    rev: 4.0.1\n    hooks:\n      - id: nb-clean\n```\n\nYou can pass additional arguments to nb-clean with an `args` array. The\nfollowing example shows how to preserve only two specific metadata fields. Note\nthat, in the example, the final item `--` in the arg list is mandatory. The\noption `--preserve-cell-metadata` may take an arbitrary number of field\narguments, and the `--` argument is needed to separate them from notebook\nfilenames, which `pre-commit` will append to the list of arguments.\n\n```yaml\nrepos:\n  - repo: https://github.com/srstevenson/nb-clean\n    rev: 4.0.1\n    hooks:\n      - id: nb-clean\n        args:\n          - --remove-empty-cells\n          - --preserve-cell-metadata\n          - tags\n          - slideshow\n          - --\n```\n\nRun `pre-commit install` to ensure the hook is installed, and\n`pre-commit autoupdate` to update the hook to the latest release of nb-clean.\n\n### Preserving all nbformat metadata\n\nTo ignore or preserve specifically the metadata defined in the\n[`nbformat` documentation](https://nbformat.readthedocs.io/en/latest/format_description.html#cell-metadata),\nuse the following options:\n`--preserve-cell-metadata collapsed scrolled deletable editable format name tags jupyter execution`.\n\n## Python library usage\n\nnb-clean can be used programmatically as a Python library, allowing integration\ninto other tools.\n\n```python\nimport nbformat\n\nimport nb_clean\n\n# Load a notebook\nwith open(\"notebook.ipynb\") as f:\n    notebook = nbformat.read(f, as_version=nbformat.NO_CONVERT)\n\n# Check if the notebook is clean\nis_clean = nb_clean.check_notebook(\n    notebook, preserve_cell_outputs=True, filename=\"notebook.ipynb\"\n)\n\n# Clean the notebook\ncleaned_notebook = nb_clean.clean_notebook(\n    notebook, remove_empty_cells=True, preserve_cell_metadata=[\"tags\", \"slideshow\"]\n)\n```\n\nThe library functions accept the same configuration options as the command-line\ninterface. The `check_notebook()` function returns a boolean indicating whether\nthe notebook is clean, whilst `clean_notebook()` returns a cleaned copy of the\nnotebook.\n\n## Migrating to nb-clean 2\n\nThe following table maps from the command line interface of nb-clean 1.6.0 to\nthat of nb-clean >=2.0.0.\n\nThe examples in the table use long flags, but short flags can also be used\ninstead.\n\n| Description                                 | nb-clean 1.6.0                                                   | nb-clean >=2.0.0                                            |\n| ------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------- |\n| Clean notebook                              | `nb-clean clean --input notebook.ipynb \\| sponge notebook.ipynb` | `nb-clean clean notebook.ipynb`                             |\n| Clean notebook (remove empty cells)         | `nb-clean clean --input notebook.ipynb --remove-empty`           | `nb-clean clean --remove-empty-cells notebook.ipynb`        |\n| Clean notebook (preserve all cell metadata) | `nb-clean clean --input notebook.ipynb --preserve-metadata`      | `nb-clean clean --preserve-cell-metadata -- notebook.ipynb` |\n| Check notebook                              | `nb-clean check --input notebook.ipynb`                          | `nb-clean check notebook.ipynb`                             |\n| Check notebook (ignore non-empty cells)     | `nb-clean check --input notebook.ipynb --remove-empty`           | `nb-clean check --remove-empty-cells notebook.ipynb`        |\n| Check notebook (ignore all cell metadata)   | `nb-clean check --input notebook.ipynb --preserve-metadata`      | `nb-clean check --preserve-cell-metadata -- notebook.ipynb` |\n| Add Git filter to clean notebooks           | `nb-clean configure-git`                                         | `nb-clean add-filter`                                       |\n| Remove Git filter                           | `nb-clean unconfigure-git`                                       | `nb-clean remove-filter`                                    |\n\n## Copyright\n\nCopyright © Scott Stevenson.\n\nnb-clean is distributed under the terms of the [ISC license].\n\n[blog post]: https://srstevenson.com/posts/jupyter-notebooks-and-collaboration/\n[isc license]: https://opensource.org/licenses/ISC\n[jujutsu docs]: https://jj-vcs.github.io/jj/latest/cli-reference/#jj-fix\n[jujutsu]: https://jj-vcs.github.io/jj/\n[pre-commit]: https://pre-commit.com/\n[pycon talk]: https://www.youtube.com/watch?v=J3k3HkVnd2c\n[uv]: https://docs.astral.sh/uv/\n"
  },
  {
    "path": "justfile",
    "content": "# show this help message (default)\nhelp:\n    @just -l\n\n# format with ruff\nfmt:\n    uv run ruff check --fix\n    uv run ruff format\n\n# lint with ruff and type-check with ty\nlint:\n    uv run ruff check\n    uv run ruff format --check\n    uv run ty check\n\n# run tests with pytest and report coverage\ntest:\n    uv run coverage run -m pytest\n    uv run coverage report\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"nb-clean\"\nversion = \"4.0.1\"\ndescription = \"Clean Jupyter notebooks for versioning\"\nauthors = [{ name = \"Scott Stevenson\", email = \"scott@stevenson.io\" }]\nreadme = \"README.md\"\nlicense = \"ISC\"\nlicense-files = [\"LICENSE\"]\nrequires-python = \">=3.10\"\nkeywords = [\"jupyter\", \"notebook\", \"clean\", \"filter\", \"git\"]\nclassifiers = [\n  \"Development Status :: 5 - Production/Stable\",\n  \"Intended Audience :: Science/Research\",\n  \"Natural Language :: English\",\n]\ndependencies = [\"nbformat>=5.9.2\"]\n\n[project.urls]\nHomepage = \"https://github.com/srstevenson/nb-clean\"\nRepository = \"https://github.com/srstevenson/nb-clean\"\nIssues = \"https://github.com/srstevenson/nb-clean/issues\"\n\n[project.scripts]\nnb-clean = \"nb_clean.cli:main\"\n\n[dependency-groups]\ndev = [\n  \"coverage>=7.6.10\",\n  \"pytest>=7.2.1\",\n  \"pytest-mock>=3.11.1\",\n  \"ruff>=0.1.6\",\n  \"ty>=0.0.19\",\n  \"typing-extensions>=4.14.1\",\n]\n\n[build-system]\nrequires = [\"uv_build>=0.7.19,<0.12\"]\nbuild-backend = \"uv_build\"\n\n[tool.coverage.report]\nexclude_also = [\"if __name__ == .__main__.:\", \"if TYPE_CHECKING:\"]\n\n[tool.ruff]\ntarget-version = \"py310\"\n\n[tool.ruff.format]\ndocstring-code-format = true\nskip-magic-trailing-comma = true\n\n[tool.ruff.lint]\nselect = [\"ALL\"]\nignore = [\n  \"COM812\",  # Trailing comma missing\n  \"C901\",    # Function is too complex\n  \"E501\",    # Line too long\n  \"PLR0912\", # Too many branches\n  \"PLR0913\", # Too many arguments in function definition\n  \"PLR2004\", # Magic value used in comparison\n  \"S603\",    # subprocess call: check for execution of untrusted input\n  \"S607\",    # Starting a process with a partial executable path\n  \"T201\",    # print found\n]\n\n[tool.ruff.lint.flake8-tidy-imports]\nban-relative-imports = \"all\"\n\n[tool.ruff.lint.isort]\nsplit-on-trailing-comma = false\n\n[tool.ruff.lint.per-file-ignores]\n\"tests/**.py\" = [\n  \"D\",      # pydocstyle\n  \"INP001\", # Implicit namespace package\n  \"S101\",   # Magic value used in comparison\n]\n\n[tool.ruff.lint.pydocstyle]\nconvention = \"google\"\n\n[tool.ty.rules]\nall = \"error\"\n"
  },
  {
    "path": "src/nb_clean/__init__.py",
    "content": "\"\"\"Clean Jupyter notebooks of execution counts, metadata, and outputs.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport subprocess\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any, Final, cast\n\nif TYPE_CHECKING:\n    from collections.abc import Collection\n\n    import nbformat\n    from typing_extensions import Self\n\nVERSION: Final = \"4.0.1\"\nGIT_ATTRIBUTES_LINE: Final = \"*.ipynb filter=nb-clean\"\n\n\nclass GitProcessError(Exception):\n    \"\"\"Exception for errors executing Git.\"\"\"\n\n    def __init__(self: Self, message: str, return_code: int) -> None:\n        \"\"\"Exception for errors executing Git.\n\n        Args:\n            message: Error message.\n            return_code: Return code.\n        \"\"\"\n        super().__init__(message)\n        self.message: str = message\n        self.return_code: int = return_code\n\n\ndef git(*args: str) -> str:\n    \"\"\"Execute a Git subcommand with the provided arguments.\n\n    Args:\n        *args: Git subcommand and arguments to execute.\n\n    Returns:\n        Standard output from the Git command, stripped of whitespace.\n\n    Raises:\n        GitProcessError: If the Git command fails with a non-zero exit code.\n\n    Examples:\n        >>> git(\"rev-parse\", \"--git-dir\")\n        '.git'\n    \"\"\"\n    try:\n        process = subprocess.run([\"git\", *list(args)], capture_output=True, check=True)\n    except subprocess.CalledProcessError as exc:\n        raise GitProcessError(exc.stderr.decode(), exc.returncode) from exc\n\n    return process.stdout.decode().strip()\n\n\ndef git_attributes_path() -> Path:\n    \"\"\"Get path to the attributes file in the current Git repository.\n\n    Returns:\n        Path to the attributes file.\n\n    Examples:\n        >>> git_attributes_path()\n        PosixPath('.git/info/attributes')\n    \"\"\"\n    git_dir = git(\"rev-parse\", \"--git-dir\")\n    return Path(git_dir, \"info\", \"attributes\")\n\n\ndef add_git_filter(\n    *,\n    remove_empty_cells: bool = False,\n    remove_all_notebook_metadata: bool = False,\n    preserve_cell_metadata: Collection[str] | None = None,\n    preserve_cell_outputs: bool = False,\n    preserve_execution_counts: bool = False,\n    preserve_notebook_metadata: bool = False,\n) -> None:\n    \"\"\"Configure and add a Git filter to automatically clean Jupyter notebooks.\n\n    This function sets up a Git filter that will automatically clean notebooks\n    when they are staged for commit, removing execution counts, outputs, and\n    metadata according to the specified options.\n\n    Args:\n        remove_empty_cells: If True, remove empty cells. Defaults to False.\n        remove_all_notebook_metadata: If True, remove all notebook metadata. Defaults to False.\n        preserve_cell_metadata: Controls cell metadata handling. If None, clean all cell metadata.\n            If [], preserve all cell metadata.\n            (This corresponds to the `-m` CLI option without specifying any fields.)\n            If list of str, these are the cell metadata fields to preserve.\n            Defaults to None.\n        preserve_cell_outputs: If True, preserve cell outputs. Defaults to False.\n        preserve_execution_counts: If True, preserve cell execution counts. Defaults to False.\n        preserve_notebook_metadata: If True, preserve notebook metadata such as language version.\n            Defaults to False.\n\n    Raises:\n        ValueError: If both preserve_notebook_metadata and remove_all_notebook_metadata are True.\n    \"\"\"\n    if preserve_notebook_metadata and remove_all_notebook_metadata:\n        msg = \"`preserve_notebook_metadata` and `remove_all_notebook_metadata` cannot both be `True`\"\n        raise ValueError(msg)\n\n    command = [\"nb-clean\", \"clean\"]\n\n    if remove_empty_cells:\n        command.append(\"--remove-empty-cells\")\n\n    if preserve_cell_metadata is not None:\n        if len(preserve_cell_metadata) > 0:\n            command.append(\n                f\"--preserve-cell-metadata {' '.join(preserve_cell_metadata)}\"\n            )\n        else:\n            command.append(\"--preserve-cell-metadata\")\n\n    if preserve_cell_outputs:\n        command.append(\"--preserve-cell-outputs\")\n\n    if preserve_execution_counts:\n        command.append(\"--preserve-execution-counts\")\n\n    if preserve_notebook_metadata:\n        command.append(\"--preserve-notebook-metadata\")\n\n    if remove_all_notebook_metadata:\n        command.append(\"--remove-all-notebook-metadata\")\n\n    git(\"config\", \"filter.nb-clean.clean\", \" \".join(command))\n\n    attributes_path = git_attributes_path()\n\n    if attributes_path.is_file() and GIT_ATTRIBUTES_LINE in attributes_path.read_text(\n        encoding=\"UTF-8\"\n    ):\n        return\n\n    with attributes_path.open(\"a\", encoding=\"UTF-8\") as file:\n        file.write(f\"\\n{GIT_ATTRIBUTES_LINE}\\n\")\n\n\ndef remove_git_filter() -> None:\n    \"\"\"Remove the nb-clean filter from the current Git repository.\n\n    This function removes the nb-clean filter configuration from the Git repository\n    and cleans up the attributes file by removing the filter directive.\n\n    Raises:\n        GitProcessError: If Git command execution fails.\n    \"\"\"\n    attributes_path = git_attributes_path()\n\n    if attributes_path.is_file():\n        original_contents = attributes_path.read_text(encoding=\"UTF-8\").split(\"\\n\")\n        revised_contents = [\n            line for line in original_contents if line != GIT_ATTRIBUTES_LINE\n        ]\n        attributes_path.write_text(\"\\n\".join(revised_contents), encoding=\"UTF-8\")\n\n    git(\"config\", \"--remove-section\", \"filter.nb-clean\")\n\n\ndef check_notebook(\n    notebook: nbformat.NotebookNode,\n    *,\n    remove_empty_cells: bool = False,\n    remove_all_notebook_metadata: bool = False,\n    preserve_cell_metadata: Collection[str] | None = None,\n    preserve_cell_outputs: bool = False,\n    preserve_execution_counts: bool = False,\n    preserve_notebook_metadata: bool = False,\n    filename: str = \"notebook\",\n) -> bool:\n    \"\"\"Check notebook is clean of execution counts, metadata, and outputs.\n\n    Args:\n        notebook: The notebook to check.\n        remove_empty_cells: If True, also check for the presence of empty cells. Defaults to False.\n        remove_all_notebook_metadata: If True, also check for the presence of any notebook metadata.\n            Defaults to False.\n        preserve_cell_metadata: If None, check for all cell metadata.\n            If [], don't check for any cell metadata.\n            (This corresponds to the `-m` CLI option without specifying any fields.)\n            If list of str, these are the cell metadata fields to ignore.\n            Defaults to None.\n        preserve_cell_outputs: If True, don't check for cell outputs. Defaults to False.\n        preserve_execution_counts: If True, don't check for cell execution counts. Defaults to False.\n        preserve_notebook_metadata: If True, preserve notebook metadata such as language version.\n            Defaults to False.\n        filename: Notebook filename to use in log messages. Defaults to \"notebook\".\n\n    Returns:\n        True if the notebook is clean, False otherwise.\n    \"\"\"\n    if preserve_notebook_metadata and remove_all_notebook_metadata:\n        msg = \"`preserve_notebook_metadata` and `remove_all_notebook_metadata` cannot both be `True`\"\n        raise ValueError(msg)\n\n    is_clean = True\n\n    for index, cell in enumerate(notebook.cells):\n        prefix = f\"{filename} cell {index}\"\n\n        if remove_empty_cells and not cell[\"source\"]:\n            print(f\"{prefix}: empty cell\")\n            is_clean = False\n\n        if preserve_cell_metadata is None:\n            if cell[\"metadata\"]:\n                print(f\"{prefix}: metadata\")\n                is_clean = False\n        elif len(preserve_cell_metadata) > 0:\n            for field in cell[\"metadata\"]:\n                if field not in preserve_cell_metadata:\n                    print(f\"{prefix}: metadata {field}\")\n                    is_clean = False\n\n        if cell[\"cell_type\"] == \"code\":\n            if not preserve_execution_counts and cell[\"execution_count\"]:\n                print(f\"{prefix}: execution count\")\n                is_clean = False\n\n            if preserve_cell_outputs:\n                if not preserve_execution_counts:\n                    for output in cell[\"outputs\"]:\n                        if output.get(\"execution_count\") is not None:\n                            print(f\"{prefix}: output execution count\")\n                            is_clean = False\n            elif cell[\"outputs\"]:\n                print(f\"{prefix}: outputs\")\n                is_clean = False\n\n    if remove_all_notebook_metadata and cast(\"dict[str, Any]\", notebook.metadata):\n        print(f\"{filename}: metadata\")\n        is_clean = False\n\n    if not preserve_notebook_metadata:\n        with contextlib.suppress(KeyError):\n            notebook[\"metadata\"][\"language_info\"][\"version\"]\n            print(f\"{filename} metadata: language_info.version\")\n            is_clean = False\n\n    return is_clean\n\n\ndef clean_notebook(\n    notebook: nbformat.NotebookNode,\n    *,\n    remove_empty_cells: bool = False,\n    remove_all_notebook_metadata: bool = False,\n    preserve_cell_metadata: Collection[str] | None = None,\n    preserve_cell_outputs: bool = False,\n    preserve_execution_counts: bool = False,\n    preserve_notebook_metadata: bool = False,\n) -> nbformat.NotebookNode:\n    \"\"\"Clean notebook of execution counts, metadata, and outputs.\n\n    Args:\n        notebook: The notebook to clean.\n        remove_empty_cells: If True, remove empty cells. Defaults to False.\n        remove_all_notebook_metadata: If True, remove all notebook metadata. Defaults to False.\n        preserve_cell_metadata: If None, clean all cell metadata.\n            If [], preserve all cell metadata.\n            (This corresponds to the `-m` CLI option without specifying any fields.)\n            If list of str, these are the cell metadata fields to preserve.\n            Defaults to None.\n        preserve_cell_outputs: If True, preserve cell outputs. Defaults to False.\n        preserve_execution_counts: If True, preserve cell execution counts. Defaults to False.\n        preserve_notebook_metadata: If True, preserve notebook metadata such as language version.\n            Defaults to False.\n\n    Returns:\n        The cleaned notebook.\n    \"\"\"\n    if preserve_notebook_metadata and remove_all_notebook_metadata:\n        msg = \"`preserve_notebook_metadata` and `remove_all_notebook_metadata` cannot both be `True`\"\n        raise ValueError(msg)\n\n    if remove_empty_cells:\n        notebook.cells = [cell for cell in notebook.cells if cell[\"source\"]]\n\n    for cell in notebook.cells:\n        if preserve_cell_metadata is None:\n            cell[\"metadata\"] = {}\n        elif len(preserve_cell_metadata) > 0:\n            cell[\"metadata\"] = {\n                field: value\n                for field, value in cell[\"metadata\"].items()\n                if field in preserve_cell_metadata\n            }\n        if cell[\"cell_type\"] == \"code\":\n            if not preserve_execution_counts:\n                cell[\"execution_count\"] = None\n            if preserve_cell_outputs:\n                if not preserve_execution_counts:\n                    for output in cell[\"outputs\"]:\n                        if \"execution_count\" in output:\n                            output[\"execution_count\"] = None\n            else:\n                cell[\"outputs\"] = []\n\n    if remove_all_notebook_metadata:\n        notebook.metadata = {}\n    elif not preserve_notebook_metadata:\n        with contextlib.suppress(KeyError):\n            del notebook[\"metadata\"][\"language_info\"][\"version\"]\n\n    return notebook\n"
  },
  {
    "path": "src/nb_clean/__main__.py",
    "content": "\"\"\"Top-level script to run nb-clean.\"\"\"\n\nfrom nb_clean.cli import main\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "src/nb_clean/cli.py",
    "content": "\"\"\"Command line interface to nb-clean.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport os\nimport sys\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, NoReturn, TextIO, cast\n\nimport nbformat\n\nimport nb_clean\n\nif TYPE_CHECKING:\n    from collections.abc import Collection, Iterable, Sequence\n\n\n@dataclass\nclass Args(argparse.Namespace):\n    \"\"\"Arguments parsed from the command-line.\"\"\"\n\n    subcommand: str = \"\"\n    inputs: list[Path] = field(default_factory=list)\n    remove_empty_cells: bool = False\n    remove_all_notebook_metadata: bool = False\n    preserve_cell_metadata: list[str] | None = None\n    preserve_cell_outputs: bool = False\n    preserve_execution_counts: bool = False\n    preserve_notebook_metadata: bool = False\n\n\ndef expand_directories(paths: Iterable[Path]) -> list[Path]:\n    \"\"\"Expand paths to directories into paths to notebooks contained within.\n\n    Args:\n        paths: Paths to expand, including directories.\n\n    Returns:\n        Paths with directories expanded into notebooks contained within.\n    \"\"\"\n    expanded: set[Path] = set()\n    for path in paths:\n        if path.is_dir():\n            expanded.update(path.rglob(\"*.ipynb\"))\n        else:\n            expanded.add(path)\n    return list(expanded)\n\n\ndef exit_with_error(message: str, return_code: int) -> NoReturn:\n    \"\"\"Print an error message to standard error and exit.\n\n    Args:\n        message: Error message to print to standard error.\n        return_code: Return code with which to exit.\n    \"\"\"\n    print(f\"nb-clean: error: {message}\", file=sys.stderr)\n    sys.exit(return_code)\n\n\ndef add_filter(\n    *,\n    remove_empty_cells: bool,\n    remove_all_notebook_metadata: bool,\n    preserve_cell_metadata: Collection[str] | None,\n    preserve_cell_outputs: bool,\n    preserve_execution_counts: bool,\n    preserve_notebook_metadata: bool,\n) -> None:\n    \"\"\"Add the nb-clean filter to the current Git repository.\n\n    Args:\n        remove_empty_cells: Configure the filter to remove empty cells.\n        remove_all_notebook_metadata: Configure the filter to remove all notebook metadata.\n        preserve_cell_metadata: Configure the filter to preserve cell metadata.\n        preserve_cell_outputs: Configure the filter to preserve cell outputs.\n        preserve_execution_counts: Configure the filter to preserve cell execution counts.\n        preserve_notebook_metadata: Configure the filter to preserve notebook metadata such as language version.\n    \"\"\"\n    try:\n        nb_clean.add_git_filter(\n            remove_empty_cells=remove_empty_cells,\n            remove_all_notebook_metadata=remove_all_notebook_metadata,\n            preserve_cell_metadata=preserve_cell_metadata,\n            preserve_cell_outputs=preserve_cell_outputs,\n            preserve_execution_counts=preserve_execution_counts,\n            preserve_notebook_metadata=preserve_notebook_metadata,\n        )\n    except nb_clean.GitProcessError as exc:\n        exit_with_error(exc.message, exc.return_code)\n\n\ndef remove_filter() -> None:\n    \"\"\"Remove the nb-clean filter from the current Git repository.\n\n    This function removes the nb-clean filter configuration and cleans up\n    the Git attributes file. If Git command execution fails, the program\n    will exit with an appropriate error code.\n    \"\"\"\n    try:\n        nb_clean.remove_git_filter()\n    except nb_clean.GitProcessError as exc:\n        exit_with_error(exc.message, exc.return_code)\n\n\ndef check(\n    inputs: Iterable[Path],\n    *,\n    remove_empty_cells: bool,\n    remove_all_notebook_metadata: bool,\n    preserve_cell_metadata: Collection[str] | None,\n    preserve_cell_outputs: bool,\n    preserve_execution_counts: bool,\n    preserve_notebook_metadata: bool,\n) -> None:\n    \"\"\"Check notebooks are clean of execution counts, metadata, and outputs.\n\n    Args:\n        inputs: Input notebook paths to check, empty list for stdin.\n        remove_empty_cells: Check for the presence of empty cells.\n        remove_all_notebook_metadata: Check for any notebook metadata.\n        preserve_cell_metadata: Don't check for cell metadata.\n        preserve_cell_outputs: Don't check for cell outputs.\n        preserve_execution_counts: Don't check for cell execution counts.\n        preserve_notebook_metadata: Don't check for notebook metadata such as language version.\n    \"\"\"\n    if inputs:\n        processed_inputs: list[Path] | list[TextIO] = expand_directories(inputs)\n    else:\n        processed_inputs = [sys.stdin]\n\n    all_clean = True\n    for input_ in processed_inputs:\n        name = \"stdin\" if input_ is sys.stdin else os.fspath(cast(\"Path\", input_))\n\n        notebook = cast(\n            \"nbformat.NotebookNode\",\n            nbformat.read(input_, as_version=nbformat.NO_CONVERT),\n        )\n        is_clean = nb_clean.check_notebook(\n            notebook,\n            remove_empty_cells=remove_empty_cells,\n            remove_all_notebook_metadata=remove_all_notebook_metadata,\n            preserve_cell_metadata=preserve_cell_metadata,\n            preserve_cell_outputs=preserve_cell_outputs,\n            preserve_execution_counts=preserve_execution_counts,\n            preserve_notebook_metadata=preserve_notebook_metadata,\n            filename=name,\n        )\n        all_clean &= is_clean\n\n    if not all_clean:\n        sys.exit(1)\n\n\ndef clean(\n    inputs: Iterable[Path],\n    *,\n    remove_empty_cells: bool,\n    remove_all_notebook_metadata: bool,\n    preserve_cell_metadata: Collection[str] | None,\n    preserve_cell_outputs: bool,\n    preserve_execution_counts: bool,\n    preserve_notebook_metadata: bool,\n) -> None:\n    \"\"\"Clean notebooks of execution counts, metadata, and outputs.\n\n    Args:\n        inputs: Input notebook paths to clean, empty list for stdin.\n        remove_empty_cells: Remove empty cells.\n        remove_all_notebook_metadata: Remove all notebook metadata.\n        preserve_cell_metadata: Don't clean cell metadata.\n        preserve_cell_outputs: Don't clean cell outputs.\n        preserve_execution_counts: Don't clean cell execution counts.\n        preserve_notebook_metadata: Don't clean notebook metadata such as language version.\n    \"\"\"\n    if inputs:\n        processed_inputs: list[Path] | list[TextIO] = expand_directories(inputs)\n        outputs = processed_inputs\n    else:\n        processed_inputs = [sys.stdin]\n        outputs = [sys.stdout]\n\n    for input_, output in zip(processed_inputs, outputs, strict=True):\n        notebook = cast(\n            \"nbformat.NotebookNode\",\n            nbformat.read(input_, as_version=nbformat.NO_CONVERT),\n        )\n\n        notebook = nb_clean.clean_notebook(\n            notebook,\n            remove_empty_cells=remove_empty_cells,\n            remove_all_notebook_metadata=remove_all_notebook_metadata,\n            preserve_cell_metadata=preserve_cell_metadata,\n            preserve_cell_outputs=preserve_cell_outputs,\n            preserve_execution_counts=preserve_execution_counts,\n            preserve_notebook_metadata=preserve_notebook_metadata,\n        )\n        nbformat.write(notebook, output)\n\n\ndef parse_args(args: Sequence[str]) -> Args:\n    \"\"\"Parse command line arguments and call corresponding function.\n\n    Args:\n        args: Command line arguments to parse.\n\n    Returns:\n        Parsed command line arguments.\n    \"\"\"\n    parser = argparse.ArgumentParser(description=__doc__)\n    subparsers = parser.add_subparsers(dest=\"subcommand\", required=True)\n\n    subparsers.add_parser(\"version\", help=\"print version number\")\n\n    add_filter_parser = subparsers.add_parser(\n        \"add-filter\", help=\"add Git filter to clean notebooks before staging\"\n    )\n    add_filter_parser.add_argument(\n        \"-e\", \"--remove-empty-cells\", action=\"store_true\", help=\"remove empty cells\"\n    )\n    add_filter_parser.add_argument(\n        \"-M\",\n        \"--remove-all-notebook-metadata\",\n        action=\"store_true\",\n        help=\"remove all notebook metadata\",\n    )\n    add_filter_parser.add_argument(\n        \"-m\",\n        \"--preserve-cell-metadata\",\n        default=None,\n        nargs=\"*\",\n        help=\"preserve cell metadata, all unless fields are specified\",\n    )\n    add_filter_parser.add_argument(\n        \"-o\",\n        \"--preserve-cell-outputs\",\n        action=\"store_true\",\n        help=\"preserve cell outputs\",\n    )\n    add_filter_parser.add_argument(\n        \"-c\",\n        \"--preserve-execution-counts\",\n        action=\"store_true\",\n        help=\"preserve cell execution counts\",\n    )\n    add_filter_parser.add_argument(\n        \"-n\",\n        \"--preserve-notebook-metadata\",\n        action=\"store_true\",\n        help=\"preserve notebook metadata\",\n    )\n\n    subparsers.add_parser(\n        \"remove-filter\", help=\"remove Git filter that cleans notebooks before staging\"\n    )\n\n    check_parser = subparsers.add_parser(\n        \"check\",\n        help=(\n            \"check a notebook is clean of cell execution counts, metadata, and outputs\"\n        ),\n    )\n    check_parser.add_argument(\n        \"inputs\", nargs=\"*\", metavar=\"PATH\", type=Path, help=\"input file\"\n    )\n    check_parser.add_argument(\n        \"-e\", \"--remove-empty-cells\", action=\"store_true\", help=\"check for empty cells\"\n    )\n    check_parser.add_argument(\n        \"-M\",\n        \"--remove-all-notebook-metadata\",\n        action=\"store_true\",\n        help=\"check for any notebook metadata\",\n    )\n    check_parser.add_argument(\n        \"-m\",\n        \"--preserve-cell-metadata\",\n        default=None,\n        nargs=\"*\",\n        help=\"preserve cell metadata, all unless fields are specified\",\n    )\n    check_parser.add_argument(\n        \"-o\",\n        \"--preserve-cell-outputs\",\n        action=\"store_true\",\n        help=\"preserve cell outputs\",\n    )\n    check_parser.add_argument(\n        \"-c\",\n        \"--preserve-execution-counts\",\n        action=\"store_true\",\n        help=\"preserve cell execution counts\",\n    )\n    check_parser.add_argument(\n        \"-n\",\n        \"--preserve-notebook-metadata\",\n        action=\"store_true\",\n        help=\"preserve notebook metadata\",\n    )\n\n    clean_parser = subparsers.add_parser(\n        \"clean\", help=\"clean notebook of cell execution counts, metadata, and outputs\"\n    )\n    clean_parser.add_argument(\n        \"inputs\", nargs=\"*\", metavar=\"PATH\", type=Path, help=\"input path\"\n    )\n    clean_parser.add_argument(\n        \"-e\", \"--remove-empty-cells\", action=\"store_true\", help=\"remove empty cells\"\n    )\n    clean_parser.add_argument(\n        \"-M\",\n        \"--remove-all-notebook-metadata\",\n        action=\"store_true\",\n        help=\"remove all notebook metadata\",\n    )\n    clean_parser.add_argument(\n        \"-m\",\n        \"--preserve-cell-metadata\",\n        default=None,\n        nargs=\"*\",\n        help=\"preserve cell metadata, all unless fields are specified\",\n    )\n    clean_parser.add_argument(\n        \"-o\",\n        \"--preserve-cell-outputs\",\n        action=\"store_true\",\n        help=\"preserve cell outputs\",\n    )\n    clean_parser.add_argument(\n        \"-c\",\n        \"--preserve-execution-counts\",\n        action=\"store_true\",\n        help=\"preserve cell execution counts\",\n    )\n    clean_parser.add_argument(\n        \"-n\",\n        \"--preserve-notebook-metadata\",\n        action=\"store_true\",\n        help=\"preserve notebook metadata\",\n    )\n\n    return parser.parse_args(args, namespace=Args())\n\n\ndef main() -> None:  # pragma: no cover\n    \"\"\"Command line entrypoint for nb-clean.\n\n    Parses command line arguments and dispatches to the appropriate\n    subcommand handler (version, add-filter, remove-filter, check, or clean).\n    \"\"\"\n    args = parse_args(sys.argv[1:])\n\n    if args.subcommand == \"version\":\n        print(f\"nb-clean {nb_clean.VERSION}\")\n    elif args.subcommand == \"add-filter\":\n        add_filter(\n            remove_empty_cells=args.remove_empty_cells,\n            remove_all_notebook_metadata=args.remove_all_notebook_metadata,\n            preserve_cell_metadata=args.preserve_cell_metadata,\n            preserve_cell_outputs=args.preserve_cell_outputs,\n            preserve_execution_counts=args.preserve_execution_counts,\n            preserve_notebook_metadata=args.preserve_notebook_metadata,\n        )\n    elif args.subcommand == \"remove-filter\":\n        remove_filter()\n    elif args.subcommand == \"check\":\n        check(\n            args.inputs,\n            remove_empty_cells=args.remove_empty_cells,\n            remove_all_notebook_metadata=args.remove_all_notebook_metadata,\n            preserve_cell_metadata=args.preserve_cell_metadata,\n            preserve_cell_outputs=args.preserve_cell_outputs,\n            preserve_execution_counts=args.preserve_execution_counts,\n            preserve_notebook_metadata=args.preserve_notebook_metadata,\n        )\n    elif args.subcommand == \"clean\":\n        clean(\n            args.inputs,\n            remove_empty_cells=args.remove_empty_cells,\n            remove_all_notebook_metadata=args.remove_all_notebook_metadata,\n            preserve_cell_metadata=args.preserve_cell_metadata,\n            preserve_cell_outputs=args.preserve_cell_outputs,\n            preserve_execution_counts=args.preserve_execution_counts,\n            preserve_notebook_metadata=args.preserve_notebook_metadata,\n        )\n    else:\n        # This should never happen due to argparse validation, but be defensive\n        exit_with_error(f\"Unknown subcommand: {args.subcommand}\", 1)\n"
  },
  {
    "path": "src/nb_clean/py.typed",
    "content": ""
  },
  {
    "path": "tests/conftest.py",
    "content": "from pathlib import Path\nfrom typing import Final, cast\n\nimport nbformat\nimport pytest\n\nNOTEBOOKS_DIR: Final = Path(__file__).parent / \"notebooks\"\n\n\ndef _read_notebook(filename: str) -> nbformat.NotebookNode:\n    return cast(\n        \"nbformat.NotebookNode\",\n        nbformat.read(NOTEBOOKS_DIR / filename, as_version=nbformat.NO_CONVERT),\n    )\n\n\n@pytest.fixture\ndef dirty_notebook() -> nbformat.NotebookNode:\n    return _read_notebook(\"dirty.ipynb\")\n\n\n@pytest.fixture\ndef dirty_notebook_with_version() -> nbformat.NotebookNode:\n    return _read_notebook(\"dirty_with_version.ipynb\")\n\n\n@pytest.fixture\ndef clean_notebook() -> nbformat.NotebookNode:\n    return _read_notebook(\"clean.ipynb\")\n\n\n@pytest.fixture\ndef clean_notebook_with_notebook_metadata() -> nbformat.NotebookNode:\n    return _read_notebook(\"clean_with_notebook_metadata.ipynb\")\n\n\n@pytest.fixture\ndef clean_notebook_without_empty_cells() -> nbformat.NotebookNode:\n    return _read_notebook(\"clean_without_empty_cells.ipynb\")\n\n\n@pytest.fixture\ndef clean_notebook_with_empty_cells() -> nbformat.NotebookNode:\n    return _read_notebook(\"clean_with_empty_cells.ipynb\")\n\n\n@pytest.fixture\ndef clean_notebook_with_counts() -> nbformat.NotebookNode:\n    return _read_notebook(\"clean_with_counts.ipynb\")\n\n\n@pytest.fixture\ndef clean_notebook_with_cell_metadata() -> nbformat.NotebookNode:\n    return _read_notebook(\"clean_with_cell_metadata.ipynb\")\n\n\n@pytest.fixture\ndef clean_notebook_with_tags_metadata() -> nbformat.NotebookNode:\n    return _read_notebook(\"clean_with_tags_metadata.ipynb\")\n\n\n@pytest.fixture\ndef clean_notebook_with_tags_special_metadata() -> nbformat.NotebookNode:\n    return _read_notebook(\"clean_with_tags_special_metadata.ipynb\")\n\n\n@pytest.fixture\ndef clean_notebook_with_outputs() -> nbformat.NotebookNode:\n    return _read_notebook(\"clean_with_outputs.ipynb\")\n\n\n@pytest.fixture\ndef clean_notebook_with_outputs_with_counts() -> nbformat.NotebookNode:\n    return _read_notebook(\"clean_with_outputs_with_counts.ipynb\")\n\n\n@pytest.fixture\ndef clean_notebook_without_notebook_metadata() -> nbformat.NotebookNode:\n    return _read_notebook(\"clean_without_notebook_metadata.ipynb\")\n"
  },
  {
    "path": "tests/notebooks/clean.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"text = \\\"Hello, world\\\"\\n\",\n    \"text\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"print(text)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python [conda env:Python3] *\",\n   \"language\": \"python\",\n   \"name\": \"conda-env-Python3-py\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "tests/notebooks/clean_with_cell_metadata.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {\n    \"nbclean\": \"test\",\n    \"special\": \"my special metadata\",\n    \"tags\": [\n     \"before-import\",\n     \"answer\"\n    ]\n   },\n   \"outputs\": [],\n   \"source\": [\n    \"text = \\\"Hello, world\\\"\\n\",\n    \"text\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"print(text)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python [conda env:Python3] *\",\n   \"language\": \"python\",\n   \"name\": \"conda-env-Python3-py\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "tests/notebooks/clean_with_counts.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 3,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"text = \\\"Hello, world\\\"\\n\",\n    \"text\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 2,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"print(text)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python [conda env:Python3] *\",\n   \"language\": \"python\",\n   \"name\": \"conda-env-Python3-py\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "tests/notebooks/clean_with_empty_cells.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"text = \\\"Hello, world\\\"\\n\",\n    \"text\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"print(text)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python [conda env:Python3] *\",\n   \"language\": \"python\",\n   \"name\": \"conda-env-Python3-py\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "tests/notebooks/clean_with_notebook_metadata.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"text = \\\"Hello, world\\\"\\n\",\n    \"text\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"print(text)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python [conda env:Python3] *\",\n   \"language\": \"python\",\n   \"name\": \"conda-env-Python3-py\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\",\n   \"version\": \"3.8.3\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "tests/notebooks/clean_with_outputs.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"'Hello, world'\"\n      ]\n     },\n     \"execution_count\": null,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"text = \\\"Hello, world\\\"\\n\",\n    \"text\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"Hello, world\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"print(text)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python [conda env:Python3] *\",\n   \"language\": \"python\",\n   \"name\": \"conda-env-Python3-py\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "tests/notebooks/clean_with_outputs_with_counts.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"'Hello, world'\"\n      ]\n     },\n     \"execution_count\": 0,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"text = \\\"Hello, world\\\"\\n\",\n    \"text\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"Hello, world\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"print(text)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python [conda env:Python3] *\",\n   \"language\": \"python\",\n   \"name\": \"conda-env-Python3-py\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "tests/notebooks/clean_with_tags_metadata.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {\n    \"tags\": [\n     \"before-import\",\n     \"answer\"\n    ]\n   },\n   \"outputs\": [],\n   \"source\": [\n    \"text = \\\"Hello, world\\\"\\n\",\n    \"text\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"print(text)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python [conda env:Python3] *\",\n   \"language\": \"python\",\n   \"name\": \"conda-env-Python3-py\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "tests/notebooks/clean_with_tags_special_metadata.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {\n    \"special\": \"my special metadata\",\n    \"tags\": [\n     \"before-import\",\n     \"answer\"\n    ]\n   },\n   \"outputs\": [],\n   \"source\": [\n    \"text = \\\"Hello, world\\\"\\n\",\n    \"text\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"print(text)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python [conda env:Python3] *\",\n   \"language\": \"python\",\n   \"name\": \"conda-env-Python3-py\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "tests/notebooks/clean_without_empty_cells.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"text = \\\"Hello, world\\\"\\n\",\n    \"text\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"print(text)\"\n   ]\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python [conda env:Python3] *\",\n   \"language\": \"python\",\n   \"name\": \"conda-env-Python3-py\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "tests/notebooks/clean_without_notebook_metadata.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"text = \\\"Hello, world\\\"\\n\",\n    \"text\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"print(text)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  }\n ],\n \"metadata\": {},\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "tests/notebooks/dirty.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 3,\n   \"metadata\": {\n    \"nbclean\": \"test\",\n    \"tags\": [\n     \"before-import\",\n     \"answer\"\n    ],\n    \"special\": \"my special metadata\"\n   },\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"'Hello, world'\"\n      ]\n     },\n     \"execution_count\": 3,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"text = \\\"Hello, world\\\"\\n\",\n    \"text\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 2,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"Hello, world\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"print(text)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python [conda env:Python3] *\",\n   \"language\": \"python\",\n   \"name\": \"conda-env-Python3-py\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\",\n   \"version\": \"3.8.3\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "tests/notebooks/dirty_empty_octave.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"id\": \"10cfba24-bab5-47a0-9ab8-5d1fc01f1f58\",\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Octave\",\n   \"language\": \"octave\",\n   \"name\": \"octave\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 5\n}\n"
  },
  {
    "path": "tests/notebooks/dirty_with_version.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {\n    \"nbclean\": \"test\"\n   },\n   \"outputs\": [],\n   \"source\": [\n    \"text = \\\"Hello, world\\\"\\n\",\n    \"text\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {\n    \"nbclean\": \"test\",\n    \"tags\": [\n     \"example-tag\",\n     \"another-tag\"\n    ],\n    \"special\": \"my special metadata\"\n   },\n   \"outputs\": [],\n   \"source\": [\n    \"print(text)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python [conda env:Python3] *\",\n   \"language\": \"python\",\n   \"name\": \"conda-env-Python3-py\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\",\n   \"version\": \"3.8.3\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "tests/test_check_notebook.py",
    "content": "from __future__ import annotations\n\nfrom typing import TYPE_CHECKING, cast\n\nimport pytest\n\nimport nb_clean\n\nif TYPE_CHECKING:\n    from collections.abc import Collection\n\n    import nbformat\n\n\n@pytest.mark.parametrize(\n    (\"notebook_name\", \"is_clean\"),\n    [\n        (\"clean_notebook\", True),\n        (\"dirty_notebook\", False),\n        (\"dirty_notebook_with_version\", False),\n    ],\n)\ndef test_check_notebook(\n    notebook_name: str, *, is_clean: bool, request: pytest.FixtureRequest\n) -> None:\n    notebook = cast(\"nbformat.NotebookNode\", request.getfixturevalue(notebook_name))\n    assert nb_clean.check_notebook(notebook) is is_clean\n\n\n@pytest.mark.parametrize(\"preserve_notebook_metadata\", [True, False])\ndef test_check_notebook_preserve_notebook_metadata(\n    clean_notebook_with_notebook_metadata: nbformat.NotebookNode,\n    *,\n    preserve_notebook_metadata: bool,\n) -> None:\n    assert (\n        nb_clean.check_notebook(\n            clean_notebook_with_notebook_metadata,\n            preserve_notebook_metadata=preserve_notebook_metadata,\n        )\n        is preserve_notebook_metadata\n    )\n\n\n@pytest.mark.parametrize(\"remove_empty_cells\", [True, False])\ndef test_check_notebook_remove_empty_cells(\n    clean_notebook_with_empty_cells: nbformat.NotebookNode, *, remove_empty_cells: bool\n) -> None:\n    output = nb_clean.check_notebook(\n        clean_notebook_with_empty_cells, remove_empty_cells=remove_empty_cells\n    )\n    assert output is not remove_empty_cells\n\n\n@pytest.mark.parametrize(\n    \"preserve_cell_metadata\",\n    [\n        [],\n        [\"tags\"],\n        [\"other\"],\n        [\"tags\", \"special\"],\n        [\"nbformat\", \"tags\", \"special\"],\n        None,\n    ],\n)\ndef test_check_notebook_preserve_cell_metadata(\n    clean_notebook_with_cell_metadata: nbformat.NotebookNode,\n    preserve_cell_metadata: Collection[str] | None,\n) -> None:\n    expected = (preserve_cell_metadata is not None) and (\n        preserve_cell_metadata == []\n        or {\"tags\", \"special\", \"nbclean\"}.issubset(preserve_cell_metadata)\n    )\n    output = nb_clean.check_notebook(\n        clean_notebook_with_cell_metadata, preserve_cell_metadata=preserve_cell_metadata\n    )\n    assert output is expected\n\n\n@pytest.mark.parametrize(\n    \"preserve_cell_metadata\",\n    [\n        [],\n        [\"tags\"],\n        [\"other\"],\n        [\"tags\", \"special\"],\n        [\"nbformat\", \"tags\", \"special\"],\n        None,\n    ],\n)\ndef test_check_notebook_preserve_cell_metadata_tags(\n    clean_notebook_with_tags_metadata: nbformat.NotebookNode,\n    preserve_cell_metadata: Collection[str] | None,\n) -> None:\n    expected = (preserve_cell_metadata is not None) and (\n        preserve_cell_metadata == [] or {\"tags\"}.issubset(preserve_cell_metadata)\n    )\n    output = nb_clean.check_notebook(\n        clean_notebook_with_tags_metadata, preserve_cell_metadata=preserve_cell_metadata\n    )\n    assert output is expected\n\n\n@pytest.mark.parametrize(\n    \"preserve_cell_metadata\",\n    [\n        [],\n        [\"tags\"],\n        [\"other\"],\n        [\"tags\", \"special\"],\n        [\"nbformat\", \"tags\", \"special\"],\n        None,\n    ],\n)\ndef test_check_notebook_preserve_cell_metadata_tags_special(\n    clean_notebook_with_tags_special_metadata: nbformat.NotebookNode,\n    preserve_cell_metadata: Collection[str] | None,\n) -> None:\n    expected = (preserve_cell_metadata is not None) and (\n        preserve_cell_metadata == []\n        or {\"tags\", \"special\"}.issubset(preserve_cell_metadata)\n    )\n    output = nb_clean.check_notebook(\n        clean_notebook_with_tags_special_metadata,\n        preserve_cell_metadata=preserve_cell_metadata,\n    )\n    assert output is expected\n\n\n@pytest.mark.parametrize(\n    (\"notebook_name\", \"preserve_cell_outputs\", \"is_clean\"),\n    [\n        (\"clean_notebook_with_outputs\", True, True),\n        (\"clean_notebook_with_outputs\", False, False),\n        (\"clean_notebook_with_outputs_with_counts\", True, False),\n    ],\n)\ndef test_check_notebook_preserve_outputs(\n    notebook_name: str,\n    *,\n    preserve_cell_outputs: bool,\n    is_clean: bool,\n    request: pytest.FixtureRequest,\n) -> None:\n    notebook = cast(\"nbformat.NotebookNode\", request.getfixturevalue(notebook_name))\n    output = nb_clean.check_notebook(\n        notebook, preserve_cell_outputs=preserve_cell_outputs\n    )\n    assert output is is_clean\n\n\n@pytest.mark.parametrize(\n    (\"notebook_name\", \"preserve_execution_counts\", \"is_clean\"),\n    [\n        (\"clean_notebook_with_counts\", True, True),\n        (\"clean_notebook_with_counts\", False, False),\n    ],\n)\ndef test_check_notebook_preserve_execution_counts(\n    notebook_name: str,\n    *,\n    preserve_execution_counts: bool,\n    is_clean: bool,\n    request: pytest.FixtureRequest,\n) -> None:\n    notebook = cast(\"nbformat.NotebookNode\", request.getfixturevalue(notebook_name))\n    output = nb_clean.check_notebook(\n        notebook, preserve_execution_counts=preserve_execution_counts\n    )\n    assert output is is_clean\n\n\n@pytest.mark.parametrize(\n    (\"notebook_name\", \"remove_all_notebook_metadata\", \"is_clean\"),\n    [\n        (\"clean_notebook_with_notebook_metadata\", True, False),\n        (\"clean_notebook_with_notebook_metadata\", False, False),\n        (\"clean_notebook_without_notebook_metadata\", True, True),\n        (\"clean_notebook_without_notebook_metadata\", False, True),\n        (\"clean_notebook\", True, False),\n        (\"clean_notebook\", False, True),\n    ],\n)\ndef test_check_notebook_remove_all_notebook_metadata(\n    notebook_name: str,\n    *,\n    remove_all_notebook_metadata: bool,\n    is_clean: bool,\n    request: pytest.FixtureRequest,\n) -> None:\n    # The test with `(\"clean_notebook_with_notebook_metadata\", False, True)`\n    # is False due to `clean_notebook_with_notebook_metadata` containing\n    # `language_info.version` detected when `preserve_notebook_metadata=False`.\n    notebook = cast(\"nbformat.NotebookNode\", request.getfixturevalue(notebook_name))\n    assert (\n        nb_clean.check_notebook(\n            notebook, remove_all_notebook_metadata=remove_all_notebook_metadata\n        )\n        == is_clean\n    )\n\n\ndef test_check_notebook_exclusive_arguments(\n    dirty_notebook: nbformat.NotebookNode,\n) -> None:\n    with pytest.raises(\n        ValueError,\n        match=\"`preserve_notebook_metadata` and `remove_all_notebook_metadata` cannot both be `True`\",\n    ):\n        nb_clean.check_notebook(\n            dirty_notebook,\n            remove_all_notebook_metadata=True,\n            preserve_notebook_metadata=True,\n        )\n"
  },
  {
    "path": "tests/test_clean_notebook.py",
    "content": "from collections.abc import Collection\nfrom typing import cast\n\nimport nbformat\nimport pytest\n\nimport nb_clean\n\n\ndef test_clean_notebook(\n    dirty_notebook: nbformat.NotebookNode, clean_notebook: nbformat.NotebookNode\n) -> None:\n    assert nb_clean.clean_notebook(dirty_notebook) == clean_notebook\n\n\n@pytest.mark.parametrize(\n    (\"preserve_notebook_metadata\", \"expected_output_name\"),\n    [(True, \"clean_notebook_with_notebook_metadata\"), (False, \"clean_notebook\")],\n)\ndef test_clean_notebook_with_notebook_metadata(\n    clean_notebook_with_notebook_metadata: nbformat.NotebookNode,\n    *,\n    preserve_notebook_metadata: bool,\n    expected_output_name: str,\n    request: pytest.FixtureRequest,\n) -> None:\n    expected_output = cast(\n        \"nbformat.NotebookNode\", request.getfixturevalue(expected_output_name)\n    )\n    assert (\n        nb_clean.clean_notebook(\n            clean_notebook_with_notebook_metadata,\n            preserve_notebook_metadata=preserve_notebook_metadata,\n        )\n        == expected_output\n    )\n\n\ndef test_clean_notebook_remove_empty_cells(\n    clean_notebook_with_empty_cells: nbformat.NotebookNode,\n    clean_notebook_without_empty_cells: nbformat.NotebookNode,\n) -> None:\n    assert (\n        nb_clean.clean_notebook(\n            clean_notebook_with_empty_cells, remove_empty_cells=True\n        )\n        == clean_notebook_without_empty_cells\n    )\n\n\n@pytest.mark.parametrize(\n    \"preserve_cell_metadata\",\n    [[], [\"nbclean\", \"tags\", \"special\"], [\"nbclean\", \"tags\", \"special\", \"toomany\"]],\n)\ndef test_clean_notebook_preserve_cell_metadata(\n    dirty_notebook: nbformat.NotebookNode,\n    clean_notebook_with_cell_metadata: nbformat.NotebookNode,\n    preserve_cell_metadata: Collection[str],\n) -> None:\n    assert (\n        nb_clean.clean_notebook(\n            dirty_notebook, preserve_cell_metadata=preserve_cell_metadata\n        )\n        == clean_notebook_with_cell_metadata\n    )\n\n\n@pytest.mark.parametrize(\"preserve_cell_metadata\", [[\"tags\"], [\"tags\", \"toomany\"]])\ndef test_clean_notebook_preserve_cell_metadata_tags(\n    dirty_notebook: nbformat.NotebookNode,\n    clean_notebook_with_tags_metadata: nbformat.NotebookNode,\n    preserve_cell_metadata: Collection[str],\n) -> None:\n    assert (\n        nb_clean.clean_notebook(\n            dirty_notebook, preserve_cell_metadata=preserve_cell_metadata\n        )\n        == clean_notebook_with_tags_metadata\n    )\n\n\n@pytest.mark.parametrize(\n    \"preserve_cell_metadata\", [[\"tags\", \"special\"], [\"tags\", \"special\", \"toomany\"]]\n)\ndef test_clean_notebook_preserve_cell_metadata_tags_special(\n    dirty_notebook: nbformat.NotebookNode,\n    clean_notebook_with_tags_special_metadata: nbformat.NotebookNode,\n    preserve_cell_metadata: Collection[str],\n) -> None:\n    assert (\n        nb_clean.clean_notebook(\n            dirty_notebook, preserve_cell_metadata=preserve_cell_metadata\n        )\n        == clean_notebook_with_tags_special_metadata\n    )\n\n\ndef test_clean_notebook_preserve_outputs(\n    dirty_notebook: nbformat.NotebookNode,\n    clean_notebook_with_outputs: nbformat.NotebookNode,\n) -> None:\n    assert (\n        nb_clean.clean_notebook(dirty_notebook, preserve_cell_outputs=True)\n        == clean_notebook_with_outputs\n    )\n\n\ndef test_clean_notebook_preserve_execution_counts(\n    dirty_notebook: nbformat.NotebookNode,\n    clean_notebook_with_counts: nbformat.NotebookNode,\n) -> None:\n    assert (\n        nb_clean.clean_notebook(dirty_notebook, preserve_execution_counts=True)\n        == clean_notebook_with_counts\n    )\n\n\ndef test_clean_notebook_remove_all_notebook_metadata(\n    dirty_notebook: nbformat.NotebookNode,\n    clean_notebook_without_notebook_metadata: nbformat.NotebookNode,\n) -> None:\n    assert (\n        nb_clean.clean_notebook(dirty_notebook, remove_all_notebook_metadata=True)\n        == clean_notebook_without_notebook_metadata\n    )\n\n\ndef test_clean_notebook_exclusive_arguments(\n    dirty_notebook: nbformat.NotebookNode,\n) -> None:\n    with pytest.raises(\n        ValueError,\n        match=\"`preserve_notebook_metadata` and `remove_all_notebook_metadata` cannot both be `True`\",\n    ):\n        nb_clean.clean_notebook(\n            dirty_notebook,\n            remove_all_notebook_metadata=True,\n            preserve_notebook_metadata=True,\n        )\n"
  },
  {
    "path": "tests/test_cli.py",
    "content": "from __future__ import annotations\n\nimport io\nimport os\nimport sys\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, cast\n\nimport nbformat\nimport pytest\n\nimport nb_clean\nimport nb_clean.cli\n\nif TYPE_CHECKING:\n    from collections.abc import Collection, Iterable\n\n    from pytest import CaptureFixture  # noqa: PT013\n\n\ndef test_expand_directories_with_files() -> None:\n    paths = [Path(\"tests/notebooks/dirty.ipynb\")]\n    assert nb_clean.cli.expand_directories(paths) == paths\n\n\ndef test_expand_directories_recursively() -> None:\n    input_paths = [Path(\"tests\")]\n    expanded_paths = nb_clean.cli.expand_directories(input_paths)\n    assert len(expanded_paths) > len(input_paths)\n    assert all(path.is_file() and path.suffix == \".ipynb\" for path in expanded_paths)\n\n\ndef test_exit_with_error(capsys: CaptureFixture[str]) -> None:\n    with pytest.raises(SystemExit) as exc:\n        nb_clean.cli.exit_with_error(\"error message\", 42)\n    assert exc.value.code == 42\n    assert capsys.readouterr().err == \"nb-clean: error: error message\\n\"\n\n\ndef test_add_filter_dispatch(monkeypatch: pytest.MonkeyPatch) -> None:\n    captured: dict[str, object] = {}\n\n    def fake_add_git_filter(**kwargs: object) -> None:\n        captured.update(kwargs)\n\n    monkeypatch.setattr(nb_clean, \"add_git_filter\", fake_add_git_filter)\n\n    argv = [\"nb-clean\", \"add-filter\", \"-e\", \"-n\"]\n    monkeypatch.setattr(sys, \"argv\", argv)\n    nb_clean.cli.main()\n\n    assert captured == {\n        \"remove_empty_cells\": True,\n        \"remove_all_notebook_metadata\": False,\n        \"preserve_cell_metadata\": None,\n        \"preserve_cell_outputs\": False,\n        \"preserve_execution_counts\": False,\n        \"preserve_notebook_metadata\": True,\n    }\n\n\ndef test_add_filter_remove_all_notebook_metadata_dispatch(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    captured: dict[str, object] = {}\n\n    def fake_add_git_filter(**kwargs: object) -> None:\n        captured.update(kwargs)\n\n    monkeypatch.setattr(nb_clean, \"add_git_filter\", fake_add_git_filter)\n\n    argv = [\"nb-clean\", \"add-filter\", \"-e\", \"-M\"]\n    monkeypatch.setattr(sys, \"argv\", argv)\n    nb_clean.cli.main()\n\n    assert captured == {\n        \"remove_empty_cells\": True,\n        \"remove_all_notebook_metadata\": True,\n        \"preserve_cell_metadata\": None,\n        \"preserve_cell_outputs\": False,\n        \"preserve_execution_counts\": False,\n        \"preserve_notebook_metadata\": False,\n    }\n\n\ndef test_add_filter_failure_dispatch(\n    capsys: CaptureFixture[str], monkeypatch: pytest.MonkeyPatch\n) -> None:\n    def fake_add_git_filter(**_kwargs: object) -> None:\n        raise nb_clean.GitProcessError(message=\"error message\", return_code=42)\n\n    monkeypatch.setattr(nb_clean, \"add_git_filter\", fake_add_git_filter)\n    monkeypatch.setattr(sys, \"argv\", [\"nb-clean\", \"add-filter\", \"-e\", \"-M\"])\n\n    with pytest.raises(SystemExit) as exc:\n        nb_clean.cli.main()\n    assert exc.value.code == 42\n    assert capsys.readouterr().err == \"nb-clean: error: error message\\n\"\n\n\ndef test_remove_filter_dispatch(monkeypatch: pytest.MonkeyPatch) -> None:\n    called = {\"value\": False}\n\n    def fake_remove_git_filter() -> None:\n        called[\"value\"] = True\n\n    monkeypatch.setattr(nb_clean, \"remove_git_filter\", fake_remove_git_filter)\n    monkeypatch.setattr(sys, \"argv\", [\"nb-clean\", \"remove-filter\"])\n    nb_clean.cli.main()\n    assert called[\"value\"]\n\n\ndef test_remove_filter_failure_dispatch(\n    capsys: CaptureFixture[str], monkeypatch: pytest.MonkeyPatch\n) -> None:\n    def fake_remove_git_filter() -> None:\n        raise nb_clean.GitProcessError(message=\"error message\", return_code=42)\n\n    monkeypatch.setattr(nb_clean, \"remove_git_filter\", fake_remove_git_filter)\n    monkeypatch.setattr(sys, \"argv\", [\"nb-clean\", \"remove-filter\"])\n\n    with pytest.raises(SystemExit) as exc:\n        nb_clean.cli.main()\n    assert exc.value.code == 42\n    assert capsys.readouterr().err == \"nb-clean: error: error message\\n\"\n\n\n@pytest.mark.parametrize(\n    (\"name\", \"expect_exit\"), [(\"clean.ipynb\", False), (\"dirty.ipynb\", True)]\n)\ndef test_check_file(\n    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, name: str, *, expect_exit: bool\n) -> None:\n    src = Path(\"tests/notebooks\") / name\n    dst = tmp_path / name\n    dst.write_bytes(src.read_bytes())\n\n    monkeypatch.setattr(sys, \"argv\", [\"nb-clean\", \"check\", os.fspath(dst)])\n\n    if expect_exit:\n        with pytest.raises(SystemExit) as exc:\n            nb_clean.cli.main()\n        assert exc.value.code == 1\n    else:\n        nb_clean.cli.main()\n\n\n@pytest.mark.parametrize(\n    (\"notebook_name\", \"expect_exit\"),\n    [(\"clean_notebook\", False), (\"dirty_notebook\", True)],\n)\ndef test_check_stdin(\n    monkeypatch: pytest.MonkeyPatch,\n    notebook_name: str,\n    *,\n    expect_exit: bool,\n    request: pytest.FixtureRequest,\n) -> None:\n    notebook = cast(\"nbformat.NotebookNode\", request.getfixturevalue(notebook_name))\n    monkeypatch.setattr(sys, \"argv\", [\"nb-clean\", \"check\"])\n    content = cast(\"str\", nbformat.writes(notebook))\n    monkeypatch.setattr(sys, \"stdin\", io.StringIO(content))\n    if expect_exit:\n        with pytest.raises(SystemExit) as exc:\n            nb_clean.cli.main()\n        assert exc.value.code == 1\n    else:\n        nb_clean.cli.main()\n\n\ndef test_clean_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:\n    src_dirty = Path(\"tests/notebooks/dirty.ipynb\")\n    dst_dirty = tmp_path / \"dirty.ipynb\"\n    dst_dirty.write_bytes(src_dirty.read_bytes())\n\n    monkeypatch.setattr(sys, \"argv\", [\"nb-clean\", \"clean\", str(dst_dirty)])\n    nb_clean.cli.main()\n\n    cleaned = cast(\n        \"nbformat.NotebookNode\",\n        nbformat.read(dst_dirty, as_version=nbformat.NO_CONVERT),\n    )\n    expected = cast(\n        \"nbformat.NotebookNode\",\n        nbformat.read(\n            Path(\"tests/notebooks/clean.ipynb\"), as_version=nbformat.NO_CONVERT\n        ),\n    )\n    assert cleaned == expected\n\n\ndef test_clean_stdin(\n    capsys: CaptureFixture[str], monkeypatch: pytest.MonkeyPatch\n) -> None:\n    dirty = cast(\n        \"nbformat.NotebookNode\",\n        nbformat.read(\n            Path(\"tests/notebooks/dirty.ipynb\"), as_version=nbformat.NO_CONVERT\n        ),\n    )\n    expected = cast(\n        \"nbformat.NotebookNode\",\n        nbformat.read(\n            Path(\"tests/notebooks/clean.ipynb\"), as_version=nbformat.NO_CONVERT\n        ),\n    )\n\n    monkeypatch.setattr(sys, \"argv\", [\"nb-clean\", \"clean\"])\n    dirty_content = cast(\"str\", nbformat.writes(dirty))\n    monkeypatch.setattr(sys, \"stdin\", io.StringIO(dirty_content))\n\n    nb_clean.cli.main()\n\n    out = capsys.readouterr().out\n    expected_text = cast(\"str\", nbformat.writes(expected))\n    assert out.strip() == expected_text.strip()\n\n\n@pytest.mark.parametrize(\n    (\n        \"argv\",\n        \"inputs\",\n        \"remove_empty_cells\",\n        \"remove_all_notebook_metadata\",\n        \"preserve_cell_metadata\",\n        \"preserve_cell_outputs\",\n        \"preserve_execution_counts\",\n        \"preserve_notebook_metadata\",\n    ),\n    [\n        (\"add-filter -e\", [], True, False, None, False, False, False),\n        (\n            \"check -m -o a.ipynb b.ipynb\",\n            [\"a.ipynb\", \"b.ipynb\"],\n            False,\n            False,\n            [],\n            True,\n            False,\n            False,\n        ),\n        (\n            \"check -m tags -o a.ipynb b.ipynb\",\n            [\"a.ipynb\", \"b.ipynb\"],\n            False,\n            False,\n            [\"tags\"],\n            True,\n            False,\n            False,\n        ),\n        (\n            \"check -m tags special -o a.ipynb b.ipynb\",\n            [\"a.ipynb\", \"b.ipynb\"],\n            False,\n            False,\n            [\"tags\", \"special\"],\n            True,\n            False,\n            False,\n        ),\n        (\"clean -e -o a.ipynb\", [\"a.ipynb\"], True, False, None, True, False, False),\n        (\"clean -e -c -o a.ipynb\", [\"a.ipynb\"], True, False, None, True, True, False),\n    ],\n)\ndef test_parse_args(\n    argv: str,\n    inputs: Iterable[str],\n    *,\n    remove_empty_cells: bool,\n    remove_all_notebook_metadata: bool,\n    preserve_cell_metadata: Collection[str] | None,\n    preserve_cell_outputs: bool,\n    preserve_execution_counts: bool,\n    preserve_notebook_metadata: bool,\n) -> None:\n    args = nb_clean.cli.parse_args(argv.split())\n    if inputs:\n        assert args.inputs == [Path(path) for path in inputs]\n    assert args.remove_empty_cells is remove_empty_cells\n    assert args.remove_all_notebook_metadata is remove_all_notebook_metadata\n    assert args.preserve_cell_metadata == preserve_cell_metadata\n    assert args.preserve_cell_outputs is preserve_cell_outputs\n    assert args.preserve_execution_counts is preserve_execution_counts\n    assert args.preserve_notebook_metadata is preserve_notebook_metadata\n"
  },
  {
    "path": "tests/test_git_integration.py",
    "content": "from __future__ import annotations\n\nimport subprocess\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING\nfrom unittest.mock import Mock\n\nimport pytest\n\nimport nb_clean\n\nif TYPE_CHECKING:\n    from collections.abc import Collection\n\n    from pytest_mock import MockerFixture\n\n\ndef test_git(mocker: MockerFixture) -> None:\n    mock_process = Mock()\n    mock_process.stdout = b\" output string \"\n    mock_run = mocker.patch(\"nb_clean.subprocess.run\", return_value=mock_process)\n    output = nb_clean.git(\"command\", \"--flag\")\n    mock_run.assert_called_once_with(\n        [\"git\", \"command\", \"--flag\"], capture_output=True, check=True\n    )\n    assert output == \"output string\"\n\n\ndef test_git_failure(mocker: MockerFixture) -> None:\n    mocker.patch(\n        \"nb_clean.subprocess.run\",\n        side_effect=subprocess.CalledProcessError(\n            returncode=42, cmd=\"command\", stderr=b\"standard error\"\n        ),\n    )\n    with pytest.raises(nb_clean.GitProcessError) as exc:\n        nb_clean.git(\"command\", \"--flag\")\n    assert exc.value.message == \"standard error\"\n    assert exc.value.return_code == 42\n\n\ndef test_git_attributes_path(mocker: MockerFixture) -> None:\n    mocker.patch(\"nb_clean.git\", return_value=\"dir/.git\")\n    assert nb_clean.git_attributes_path() == Path(\"dir\", \".git\", \"info\", \"attributes\")\n\n\n@pytest.mark.parametrize(\n    (\n        \"remove_empty_cells\",\n        \"remove_all_notebook_metadata\",\n        \"preserve_cell_metadata\",\n        \"preserve_cell_outputs\",\n        \"preserve_execution_counts\",\n        \"preserve_notebook_metadata\",\n        \"filter_command\",\n    ),\n    [\n        (False, False, None, False, False, False, \"nb-clean clean\"),\n        (True, False, None, False, False, False, \"nb-clean clean --remove-empty-cells\"),\n        (\n            False,\n            False,\n            [],\n            False,\n            False,\n            False,\n            \"nb-clean clean --preserve-cell-metadata\",\n        ),\n        (\n            False,\n            False,\n            [\"tags\"],\n            False,\n            False,\n            False,\n            \"nb-clean clean --preserve-cell-metadata tags\",\n        ),\n        (\n            False,\n            False,\n            [\"tags\", \"special\"],\n            False,\n            False,\n            False,\n            \"nb-clean clean --preserve-cell-metadata tags special\",\n        ),\n        (\n            False,\n            False,\n            None,\n            True,\n            False,\n            False,\n            \"nb-clean clean --preserve-cell-outputs\",\n        ),\n        (\n            True,\n            False,\n            [],\n            True,\n            False,\n            False,\n            \"nb-clean clean --remove-empty-cells --preserve-cell-metadata --preserve-cell-outputs\",\n        ),\n        (\n            False,\n            False,\n            None,\n            False,\n            True,\n            True,\n            \"nb-clean clean --preserve-execution-counts --preserve-notebook-metadata\",\n        ),\n        (\n            False,\n            True,\n            None,\n            False,\n            False,\n            False,\n            \"nb-clean clean --remove-all-notebook-metadata\",\n        ),\n    ],\n)\ndef test_add_git_filter(\n    mocker: MockerFixture,\n    tmp_path: Path,\n    *,\n    remove_empty_cells: bool,\n    remove_all_notebook_metadata: bool,\n    preserve_cell_metadata: Collection[str] | None,\n    preserve_cell_outputs: bool,\n    preserve_execution_counts: bool,\n    preserve_notebook_metadata: bool,\n    filter_command: str,\n) -> None:\n    mock_git = mocker.patch(\"nb_clean.git\")\n    mock_git_attributes_path = mocker.patch(\n        \"nb_clean.git_attributes_path\", return_value=tmp_path / \"attributes\"\n    )\n    nb_clean.add_git_filter(\n        remove_empty_cells=remove_empty_cells,\n        remove_all_notebook_metadata=remove_all_notebook_metadata,\n        preserve_cell_metadata=preserve_cell_metadata,\n        preserve_cell_outputs=preserve_cell_outputs,\n        preserve_execution_counts=preserve_execution_counts,\n        preserve_notebook_metadata=preserve_notebook_metadata,\n    )\n    mock_git.assert_called_once_with(\"config\", \"filter.nb-clean.clean\", filter_command)\n    mock_git_attributes_path.assert_called_once()\n    assert nb_clean.GIT_ATTRIBUTES_LINE in (tmp_path / \"attributes\").read_text()\n\n\ndef test_add_git_filter_exclusive_arguments() -> None:\n    with pytest.raises(\n        ValueError,\n        match=\"`preserve_notebook_metadata` and `remove_all_notebook_metadata` cannot both be `True`\",\n    ):\n        nb_clean.add_git_filter(\n            remove_all_notebook_metadata=True, preserve_notebook_metadata=True\n        )\n\n\ndef test_add_git_filter_idempotent(mocker: MockerFixture, tmp_path: Path) -> None:\n    mocker.patch(\"nb_clean.git\")\n    (tmp_path / \"attributes\").write_text(nb_clean.GIT_ATTRIBUTES_LINE)\n    mock_git_attributes_path = mocker.patch(\n        \"nb_clean.git_attributes_path\", return_value=tmp_path / \"attributes\"\n    )\n    nb_clean.add_git_filter()\n    mock_git_attributes_path.assert_called_once()\n    assert (tmp_path / \"attributes\").read_text() == nb_clean.GIT_ATTRIBUTES_LINE\n\n\n@pytest.mark.parametrize(\"filter_exists\", [True, False])\ndef test_remove_git_filter(\n    mocker: MockerFixture, tmp_path: Path, *, filter_exists: bool\n) -> None:\n    mock_git = mocker.patch(\"nb_clean.git\")\n    mock_git_attributes_path = mocker.patch(\n        \"nb_clean.git_attributes_path\", return_value=tmp_path / \"attributes\"\n    )\n    (tmp_path / \"attributes\").touch()\n    if filter_exists:\n        (tmp_path / \"attributes\").write_text(nb_clean.GIT_ATTRIBUTES_LINE)\n    nb_clean.remove_git_filter()\n    mock_git_attributes_path.assert_called_once()\n    mock_git.assert_called_once_with(\"config\", \"--remove-section\", \"filter.nb-clean\")\n    if filter_exists:\n        assert nb_clean.GIT_ATTRIBUTES_LINE not in (tmp_path / \"attributes\").read_text()\n"
  }
]