├── tests ├── data │ ├── config │ │ ├── pyproject.toml │ │ ├── input.ipynb │ │ └── expected.ipynb │ ├── input.ipynb │ └── expected.ipynb └── test_format.py ├── setup.cfg ├── .gitignore ├── pyproject.toml ├── .pre-commit-hooks.yaml ├── tox.ini ├── .pre-commit-config.yaml ├── LICENSE ├── setup.py ├── README.md ├── .github └── workflows │ └── tests.yml └── black_nbconvert.py /tests/data/config/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 50 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | python-tag = py36 3 | 4 | [options] 5 | setup_requires = setuptools_scm 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _version.py 2 | dist 3 | *.egg-info 4 | build 5 | .eggs 6 | .DS_Store 7 | __pycache__ 8 | .tox 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=30.3.0", "wheel", "setuptools_scm"] 3 | 4 | [tool.isort] 5 | line_length = 88 6 | multi_line_output = 3 7 | include_trailing_comma = true 8 | force_grid_wrap = 0 9 | use_parentheses = true 10 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: black_nbconvert 2 | name: black_nbconvert 3 | description: 'Apply black to ipynb files' 4 | entry: black_nbconvert 5 | language: python 6 | language_version: python3 7 | require_serial: true 8 | files: '\.ipynb$' 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{38,39}{,-beta},lint 3 | 4 | [gh-actions] 5 | python = 6 | 3.8: py38 7 | 3.9: py39 8 | 9 | [testenv] 10 | deps = 11 | pytest 12 | beta: black==21.12b0 13 | commands = 14 | pip freeze 15 | python -m pytest -v {posargs} 16 | 17 | [testenv:lint] 18 | skip_install = true 19 | deps = pre-commit 20 | commands = 21 | pre-commit run --all-files 22 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.6.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | exclude_types: [json, binary] 8 | - repo: https://github.com/PyCQA/isort 9 | rev: "5.13.2" 10 | hooks: 11 | - id: isort 12 | additional_dependencies: [toml] 13 | - repo: https://github.com/psf/black 14 | rev: "24.4.0" 15 | hooks: 16 | - id: black 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Dan Foreman-Mackey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/data/input.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "b9ccdeae-2b07-4ab1-8232-f1d05463961b", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "def this_isnt_valid(\n", 11 | " an_arg, another_arg,\n", 12 | " kwarg=None, another_kwarg=0\n", 13 | " ):\n", 14 | " return \"this is a really long string that shouldn't be valid\" + \"in black, how about now? is this long enough?\"" 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": null, 20 | "id": "7d7e2e06-495b-49c6-be92-dc58e386ca2f", 21 | "metadata": {}, 22 | "outputs": [], 23 | "source": [] 24 | } 25 | ], 26 | "metadata": { 27 | "kernelspec": { 28 | "display_name": "Python 3", 29 | "language": "python", 30 | "name": "python3" 31 | }, 32 | "language_info": { 33 | "codemirror_mode": { 34 | "name": "ipython", 35 | "version": 3 36 | }, 37 | "file_extension": ".py", 38 | "mimetype": "text/x-python", 39 | "name": "python", 40 | "nbconvert_exporter": "python", 41 | "pygments_lexer": "ipython3", 42 | "version": "3.9.4" 43 | } 44 | }, 45 | "nbformat": 4, 46 | "nbformat_minor": 5 47 | } 48 | -------------------------------------------------------------------------------- /tests/data/config/input.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "b9ccdeae-2b07-4ab1-8232-f1d05463961b", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "def this_isnt_valid(\n", 11 | " an_arg, another_arg,\n", 12 | " kwarg=None, another_kwarg=0\n", 13 | " ):\n", 14 | " return \"this is a really long string that shouldn't be valid\" + \"in black, how about now? is this long enough?\"" 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": null, 20 | "id": "7d7e2e06-495b-49c6-be92-dc58e386ca2f", 21 | "metadata": {}, 22 | "outputs": [], 23 | "source": [] 24 | } 25 | ], 26 | "metadata": { 27 | "kernelspec": { 28 | "display_name": "Python 3", 29 | "language": "python", 30 | "name": "python3" 31 | }, 32 | "language_info": { 33 | "codemirror_mode": { 34 | "name": "ipython", 35 | "version": 3 36 | }, 37 | "file_extension": ".py", 38 | "mimetype": "text/x-python", 39 | "name": "python", 40 | "nbconvert_exporter": "python", 41 | "pygments_lexer": "ipython3", 42 | "version": "3.9.4" 43 | } 44 | }, 45 | "nbformat": 4, 46 | "nbformat_minor": 5 47 | } 48 | -------------------------------------------------------------------------------- /tests/data/expected.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "b9ccdeae-2b07-4ab1-8232-f1d05463961b", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "def this_isnt_valid(an_arg, another_arg, kwarg=None, another_kwarg=0):\n", 11 | " return (\n", 12 | " \"this is a really long string that shouldn't be valid\"\n", 13 | " + \"in black, how about now? is this long enough?\"\n", 14 | " )" 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": null, 20 | "id": "7d7e2e06-495b-49c6-be92-dc58e386ca2f", 21 | "metadata": {}, 22 | "outputs": [], 23 | "source": [] 24 | } 25 | ], 26 | "metadata": { 27 | "kernelspec": { 28 | "display_name": "Python 3", 29 | "language": "python", 30 | "name": "python3" 31 | }, 32 | "language_info": { 33 | "codemirror_mode": { 34 | "name": "ipython", 35 | "version": 3 36 | }, 37 | "file_extension": ".py", 38 | "mimetype": "text/x-python", 39 | "name": "python", 40 | "nbconvert_exporter": "python", 41 | "pygments_lexer": "ipython3", 42 | "version": "3.9.4" 43 | } 44 | }, 45 | "nbformat": 4, 46 | "nbformat_minor": 5 47 | } 48 | -------------------------------------------------------------------------------- /tests/data/config/expected.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "b9ccdeae-2b07-4ab1-8232-f1d05463961b", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "def this_isnt_valid(\n", 11 | " an_arg,\n", 12 | " another_arg,\n", 13 | " kwarg=None,\n", 14 | " another_kwarg=0,\n", 15 | "):\n", 16 | " return (\n", 17 | " \"this is a really long string that shouldn't be valid\"\n", 18 | " + \"in black, how about now? is this long enough?\"\n", 19 | " )" 20 | ] 21 | }, 22 | { 23 | "cell_type": "code", 24 | "execution_count": null, 25 | "id": "7d7e2e06-495b-49c6-be92-dc58e386ca2f", 26 | "metadata": {}, 27 | "outputs": [], 28 | "source": [] 29 | } 30 | ], 31 | "metadata": { 32 | "kernelspec": { 33 | "display_name": "Python 3", 34 | "language": "python", 35 | "name": "python3" 36 | }, 37 | "language_info": { 38 | "codemirror_mode": { 39 | "name": "ipython", 40 | "version": 3 41 | }, 42 | "file_extension": ".py", 43 | "mimetype": "text/x-python", 44 | "name": "python", 45 | "nbconvert_exporter": "python", 46 | "pygments_lexer": "ipython3", 47 | "version": "3.9.4" 48 | } 49 | }, 50 | "nbformat": 4, 51 | "nbformat_minor": 5 52 | } 53 | -------------------------------------------------------------------------------- /tests/test_format.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | import shutil 5 | import subprocess as sp 6 | from pathlib import Path 7 | 8 | 9 | def get_temp_path(subdir=""): 10 | root = Path(__file__).parent / "data" 11 | source = root / subdir / "input.ipynb" 12 | expected = root / subdir / "expected.ipynb" 13 | target = root / subdir / ".temp.ipynb" 14 | shutil.copy(source, target) 15 | return source, target, expected 16 | 17 | 18 | def compare_notebooks(a, b): 19 | with open(a, "r") as f: 20 | str_a = json.dumps(json.load(f), sort_keys=True) 21 | with open(b, "r") as f: 22 | str_b = json.dumps(json.load(f), sort_keys=True) 23 | return str_a == str_b 24 | 25 | 26 | def test_check(): 27 | source, target, _ = get_temp_path() 28 | code = sp.call(f"python -m black_nbconvert --check {target}", shell=True) 29 | assert code != 0 30 | assert compare_notebooks(source, target) 31 | target.unlink() 32 | 33 | 34 | def test_basic(): 35 | _, target, expected = get_temp_path() 36 | code = sp.call(f"python -m black_nbconvert {target}", shell=True) 37 | assert code == 0 38 | assert compare_notebooks(expected, target) 39 | target.unlink() 40 | 41 | 42 | def test_config(): 43 | _, target, expected = get_temp_path("config") 44 | code = sp.call(f"python -m black_nbconvert {target}", shell=True) 45 | assert code == 0 46 | assert compare_notebooks(expected, target) 47 | target.unlink() 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from setuptools import setup 4 | 5 | assert sys.version_info >= (3, 6, 0), "black_nbconvert requires Python 3.6+" 6 | from pathlib import Path # noqa E402 7 | 8 | CURRENT_DIR = Path(__file__).parent 9 | 10 | 11 | def get_long_description() -> str: 12 | readme_md = CURRENT_DIR / "README.md" 13 | with open(readme_md, encoding="utf8") as ld_file: 14 | return ld_file.read() 15 | 16 | 17 | setup( 18 | name="black_nbconvert", 19 | use_scm_version=True, 20 | description="Apply black to ipynb files", 21 | long_description=get_long_description(), 22 | long_description_content_type="text/markdown", 23 | author="Dan Foreman-Mackey", 24 | author_email="foreman.mackey@gmail.com", 25 | url="https://github.com/dfm/black_nbconvert", 26 | license="MIT", 27 | py_modules=["black_nbconvert"], 28 | python_requires=">=3.6", 29 | zip_safe=False, 30 | install_requires=["black", "nbconvert"], 31 | classifiers=[ 32 | "Development Status :: 4 - Beta", 33 | "Environment :: Console", 34 | "Intended Audience :: Developers", 35 | "License :: OSI Approved :: MIT License", 36 | "Operating System :: OS Independent", 37 | "Programming Language :: Python", 38 | "Programming Language :: Python :: 3.6", 39 | "Programming Language :: Python :: 3.7", 40 | "Programming Language :: Python :: 3 :: Only", 41 | "Topic :: Software Development :: Libraries :: Python Modules", 42 | "Topic :: Software Development :: Quality Assurance", 43 | ], 44 | entry_points={"console_scripts": ["black_nbconvert=black_nbconvert:main"]}, 45 | ) 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # black natively supports Jupyter notebooks 2 | 3 | **This project is no longer necessary!** 4 | 5 | To replace your `black_nbconvert` `pre-commit` configuration, use: 6 | 7 | ```yaml 8 | - repo: https://github.com/psf/black 9 | rev: "22.1.0" 10 | hooks: 11 | - id: black-jupyter 12 | ``` 13 | 14 | If you're still using this project, the original README is below: 15 | 16 | # black + nbconvert 17 | 18 | Tired of having to *think* about formatting in Jupyter notebooks? 19 | Look no further! 20 | This script will correctly format your Jupyter notebooks for you using [black](https://black.readthedocs.io). 21 | 22 | **Warning: This project will overwrite your notebooks in place. 23 | It shouldn't change anything except the format, but use at your own risk!** 24 | 25 | ## Installation & Usage 26 | 27 | To install: 28 | 29 | ```bash 30 | pip install black_nbconvert 31 | ``` 32 | 33 | To check a notebook: 34 | 35 | ```bash 36 | black_nbconvert --check /path/to/a/notebook.ipynb 37 | ``` 38 | 39 | To fix the formatting in a notebook (in place): 40 | 41 | ```bash 42 | black_nbconvert /path/to/a/notebook.ipynb 43 | ``` 44 | 45 | If you pass a directory instead of a notebook file, notebooks will be found recursively below that directory. 46 | For example: 47 | 48 | ```bash 49 | black_nbconvert . 50 | ``` 51 | 52 | will fix the formatting for all notebooks in the current directory and (recursively) below. 53 | 54 | *Configuration:* Configuration for `black` in a `pyproject.toml` file above the target files will be respected. 55 | 56 | ## Version control integration 57 | 58 | Use [pre-commit](https://pre-commit.com/). 59 | Once you [have it installed](https://pre-commit.com/#install), add this to the `.pre-commit-config.yaml` in your repository: 60 | 61 | ```yaml 62 | repos: 63 | - repo: https://github.com/dfm/black_nbconvert 64 | rev: v0.3.0 65 | hooks: 66 | - id: black_nbconvert 67 | ``` 68 | 69 | Then run `pre-commit install` and you're ready to go. 70 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - "*" 9 | pull_request: 10 | 11 | jobs: 12 | tests: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | python-version: ["3.8", "3.9"] 17 | os: ["ubuntu-latest"] 18 | include: 19 | - python-version: "3.8" 20 | os: "macos-latest" 21 | - python-version: "3.8" 22 | os: "windows-latest" 23 | 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v2 27 | with: 28 | fetch-depth: 0 29 | - name: Setup Python 30 | uses: actions/setup-python@v2 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install -U pip 36 | python -m pip install -U tox tox-gh-actions 37 | - name: Run tests 38 | run: python -m tox 39 | 40 | lint: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v2 44 | with: 45 | fetch-depth: 0 46 | - name: Setup Python 47 | uses: actions/setup-python@v2 48 | with: 49 | python-version: "3.9" 50 | - name: Install dependencies 51 | run: | 52 | python -m pip install -U pip 53 | python -m pip install tox 54 | - name: Lint the code 55 | run: python -m tox -e lint 56 | 57 | build: 58 | runs-on: ubuntu-latest 59 | steps: 60 | - uses: actions/checkout@v2 61 | with: 62 | fetch-depth: 0 63 | - uses: actions/setup-python@v2 64 | name: Install Python 65 | with: 66 | python-version: "3.9" 67 | - name: Build sdist and wheel 68 | run: | 69 | python -m pip install -U pip 70 | python -m pip install -U build 71 | python -m build . 72 | - uses: actions/upload-artifact@v2 73 | with: 74 | path: dist/* 75 | 76 | upload_pypi: 77 | needs: [tests, lint, build] 78 | runs-on: ubuntu-latest 79 | if: startsWith(github.ref, 'refs/tags/') 80 | steps: 81 | - uses: actions/download-artifact@v2 82 | with: 83 | name: artifact 84 | path: dist 85 | 86 | - uses: pypa/gh-action-pypi-publish@v1.4.2 87 | with: 88 | user: __token__ 89 | password: ${{ secrets.pypi_password }} 90 | -------------------------------------------------------------------------------- /black_nbconvert.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | __all__ = ["__version__", "BlackPreprocessor"] 5 | 6 | import os 7 | import re 8 | import sys 9 | from pathlib import Path 10 | 11 | try: 12 | import tomli 13 | except ImportError: 14 | tomli = None 15 | import toml 16 | 17 | import nbformat 18 | from black import ( 19 | DEFAULT_LINE_LENGTH, 20 | FileMode, 21 | InvalidInput, 22 | find_pyproject_toml, 23 | format_str, 24 | parse_pyproject_toml, 25 | ) 26 | from nbconvert.preprocessors import Preprocessor 27 | from pkg_resources import DistributionNotFound, get_distribution 28 | 29 | try: 30 | __version__ = get_distribution("black_nbconvert").version 31 | except DistributionNotFound: 32 | __version__ = None 33 | 34 | 35 | def load_toml(path): 36 | if tomli is None: 37 | return toml.load(str(path)) 38 | with open(path, encoding="utf8") as f: 39 | return tomli.load(f) 40 | 41 | 42 | class BlackPreprocessor(Preprocessor): 43 | def __init__(self, *args, **kwargs): 44 | self.mode = FileMode(*args, **kwargs) 45 | self.magic = "# BLACKNBCONVERT%BLACKNBCONVERT" 46 | self.forward = re.compile("^%", flags=re.M) 47 | self.reverse = re.compile("^{0}".format(self.magic), flags=re.M) 48 | super(BlackPreprocessor, self).__init__() 49 | 50 | def preprocess(self, *args, **kwargs): 51 | self.count = 0 52 | return super(BlackPreprocessor, self).preprocess(*args, **kwargs) 53 | 54 | def preprocess_cell(self, cell, resources, index): 55 | if cell.get("cell_type", None) == "code": 56 | src = cell.get("source", "") 57 | if len(src.strip()): 58 | src_fmt = self.forward.sub(self.magic, src) 59 | try: 60 | cell["source"] = self.reverse.sub( 61 | "%", format_str(src_fmt, mode=self.mode).strip() 62 | ) 63 | except InvalidInput: 64 | pass 65 | if src.strip()[-1] == ";": 66 | cell["source"] += ";" 67 | if src != cell["source"]: 68 | self.count += 1 69 | 70 | return cell, resources 71 | 72 | 73 | def format_one(proc, filename, check=False): 74 | with open(filename) as f: 75 | notebook = nbformat.read(f, as_version=4) 76 | proc.preprocess( 77 | notebook, 78 | {"metadata": {"path": os.path.dirname(os.path.abspath(filename))}}, 79 | ) 80 | if not check: 81 | with open(filename, mode="wt") as f: 82 | nbformat.write(notebook, f) 83 | return proc.count > 0 84 | 85 | 86 | def format_some(filenames, **config): 87 | check = config.get("check", False) 88 | 89 | toml = find_pyproject_toml(filenames) 90 | if toml is not None: 91 | new_config = parse_pyproject_toml(toml) 92 | config = dict(new_config, **config) 93 | 94 | proc = BlackPreprocessor( 95 | line_length=config.get( 96 | "line-length", config.get("line_length", DEFAULT_LINE_LENGTH) 97 | ), 98 | target_versions=set(config.get("target_version", [])), 99 | string_normalization=not config.get("skip_string_normalization", False), 100 | ) 101 | 102 | count = 0 103 | for filename in filenames: 104 | changed = format_one(proc, filename, check=check) 105 | if changed: 106 | count += 1 107 | if check: 108 | print("Invalid format: {0}".format(filename)) 109 | else: 110 | print("Formatted: {0}".format(filename)) 111 | 112 | return count 113 | 114 | 115 | def main(): 116 | import argparse 117 | import re 118 | 119 | parser = argparse.ArgumentParser(description="Format Jupyter notebooks using black") 120 | parser.add_argument("filenames", nargs="+", help="files or directories to format") 121 | parser.add_argument( 122 | "--check", 123 | action="store_true", 124 | help="just check the formatting, don't overwrite", 125 | ) 126 | args = parser.parse_args() 127 | 128 | check = args.check 129 | 130 | exclude_re = re.compile(r"/(\.ipynb_checkpoints)/") 131 | filenames = [] 132 | for fn in args.filenames: 133 | path = Path(os.path.abspath(fn)) 134 | if path.is_dir(): 135 | filenames += list( 136 | str(fn) 137 | for fn in path.glob("**/*.ipynb") 138 | if not exclude_re.search(str(fn)) 139 | ) 140 | else: 141 | filenames.append(str(path)) 142 | 143 | count = format_some(tuple(filenames), check=check) 144 | if count > 0: 145 | if check: 146 | print("{0} notebook(s) would be formatted".format(count)) 147 | else: 148 | print("Formatted {0} notebook(s)".format(count)) 149 | if check: 150 | return count 151 | return 0 152 | 153 | 154 | if __name__ == "__main__": 155 | sys.exit(main()) 156 | --------------------------------------------------------------------------------