├── MANIFEST.in ├── setup.cfg ├── nb_black.py ├── LICENSE ├── README.md ├── setup.py ├── .gitignore └── lab_black.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_files = LICENSE 3 | -------------------------------------------------------------------------------- /nb_black.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, division, print_function, unicode_literals 3 | 4 | from lab_black import BlackFormatter, black_formatter, unload_ipython_extension 5 | 6 | 7 | def load_ipython_extension(ip): 8 | global black_formatter 9 | if black_formatter is None: 10 | black_formatter = BlackFormatter(ip, is_lab=False) 11 | ip.events.register("post_run_cell", black_formatter.format_cell) 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Khoa Duong 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nb_black 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/nb_black.svg)](https://pypi.org/project/nb-black/) 4 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/nb_black.svg)](https://pypi.org/project/nb-black/) 5 | 6 | A simple extension for Jupyter Notebook and Jupyter Lab to beautify Python code automatically using **[Black](https://github.com/psf/black)**. 7 | 8 | Please note that since the **Black** package only supports Python 3.6+, so **[YAPF](https://github.com/google/yapf)** package will 9 | be used for the lower versions. If you edit the code while running the cell, the formatting is 10 | not submitted to the Jupyter notebook and instead silently suppressed, so you have to stick with 11 | the edited, but unformatted code. 12 | 13 | ## Installation 14 | 15 | You can install this package using [pip](http://www.pip-installer.org): 16 | 17 | ``` 18 | $ [sudo] pip install nb_black 19 | ``` 20 | 21 | ## Usage 22 | 23 | For Jupyter Notebook: 24 | 25 | ``` 26 | %load_ext nb_black 27 | ``` 28 | 29 | For Jupyter Lab: 30 | 31 | ``` 32 | %load_ext lab_black 33 | ``` 34 | 35 | Just put this code into the first cell in your Notebook, and that's all. :) 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | 5 | from setuptools import setup 6 | 7 | 8 | def readme(file_name): 9 | if os.path.isfile(file_name): 10 | with open(file_name, "r") as f: 11 | return f.read() 12 | 13 | 14 | setup( 15 | name="nb_black", 16 | version="1.0.7", 17 | description="A simple extension for Jupyter Notebook and Jupyter Lab to beautify Python code automatically using Black.", 18 | long_description=readme(file_name="README.md"), 19 | long_description_content_type="text/markdown", 20 | keywords="black-formatter black-beautifier black jupyterlab-extension jupyter-notebook-extension", 21 | url="https://github.com/dnanhkhoa/nb_black", 22 | author="Khoa Duong", 23 | author_email="dnanhkhoa@live.com", 24 | license="MIT", 25 | py_modules=["nb_black", "lab_black"], 26 | zip_safe=False, 27 | install_requires=[ 28 | "yapf >= 0.28; python_version < '3.6'", 29 | "black >= 19.3; python_version >= '3.6'", 30 | "ipython", 31 | ], 32 | classifiers=[ 33 | "Development Status :: 5 - Production/Stable", 34 | "Intended Audience :: Developers", 35 | "License :: OSI Approved :: MIT License", 36 | "Programming Language :: Python", 37 | "Programming Language :: Python :: 2", 38 | "Programming Language :: Python :: 3", 39 | "Topic :: Software Development :: Libraries :: Python Modules", 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python,pycharm+all 3 | 4 | ### PyCharm+all ### 5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 7 | 8 | # User-specific stuff 9 | .idea/**/workspace.xml 10 | .idea/**/tasks.xml 11 | .idea/**/usage.statistics.xml 12 | .idea/**/dictionaries 13 | .idea/**/shelf 14 | 15 | # Sensitive or high-churn files 16 | .idea/**/dataSources/ 17 | .idea/**/dataSources.ids 18 | .idea/**/dataSources.local.xml 19 | .idea/**/sqlDataSources.xml 20 | .idea/**/dynamic.xml 21 | .idea/**/uiDesigner.xml 22 | .idea/**/dbnavigator.xml 23 | 24 | # Gradle 25 | .idea/**/gradle.xml 26 | .idea/**/libraries 27 | 28 | # Gradle and Maven with auto-import 29 | # When using Gradle or Maven with auto-import, you should exclude module files, 30 | # since they will be recreated, and may cause churn. Uncomment if using 31 | # auto-import. 32 | # .idea/modules.xml 33 | # .idea/*.iml 34 | # .idea/modules 35 | 36 | # CMake 37 | cmake-build-*/ 38 | 39 | # Mongo Explorer plugin 40 | .idea/**/mongoSettings.xml 41 | 42 | # File-based project format 43 | *.iws 44 | 45 | # IntelliJ 46 | out/ 47 | 48 | # mpeltonen/sbt-idea plugin 49 | .idea_modules/ 50 | 51 | # JIRA plugin 52 | atlassian-ide-plugin.xml 53 | 54 | # Cursive Clojure plugin 55 | .idea/replstate.xml 56 | 57 | # Crashlytics plugin (for Android Studio and IntelliJ) 58 | com_crashlytics_export_strings.xml 59 | crashlytics.properties 60 | crashlytics-build.properties 61 | fabric.properties 62 | 63 | # Editor-based Rest Client 64 | .idea/httpRequests 65 | 66 | ### PyCharm+all Patch ### 67 | # Ignores the whole .idea folder and all .iml files 68 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 69 | 70 | .idea/ 71 | 72 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 73 | 74 | *.iml 75 | modules.xml 76 | .idea/misc.xml 77 | *.ipr 78 | 79 | ### Python ### 80 | # Byte-compiled / optimized / DLL files 81 | __pycache__/ 82 | *.py[cod] 83 | *$py.class 84 | 85 | # C extensions 86 | *.so 87 | 88 | # Distribution / packaging 89 | .Python 90 | build/ 91 | develop-eggs/ 92 | dist/ 93 | downloads/ 94 | eggs/ 95 | .eggs/ 96 | lib/ 97 | lib64/ 98 | parts/ 99 | sdist/ 100 | var/ 101 | wheels/ 102 | *.egg-info/ 103 | .installed.cfg 104 | *.egg 105 | MANIFEST 106 | 107 | # PyInstaller 108 | # Usually these files are written by a python script from a template 109 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 110 | *.manifest 111 | *.spec 112 | 113 | # Installer logs 114 | pip-log.txt 115 | pip-delete-this-directory.txt 116 | 117 | # Unit test / coverage reports 118 | htmlcov/ 119 | .tox/ 120 | .coverage 121 | .coverage.* 122 | .cache 123 | nosetests.xml 124 | coverage.xml 125 | *.cover 126 | .hypothesis/ 127 | .pytest_cache/ 128 | 129 | # Translations 130 | *.mo 131 | *.pot 132 | 133 | # Django stuff: 134 | *.log 135 | local_settings.py 136 | db.sqlite3 137 | 138 | # Flask stuff: 139 | instance/ 140 | .webassets-cache 141 | 142 | # Scrapy stuff: 143 | .scrapy 144 | 145 | # Sphinx documentation 146 | docs/_build/ 147 | 148 | # PyBuilder 149 | target/ 150 | 151 | # Jupyter Notebook 152 | .ipynb_checkpoints 153 | 154 | # pyenv 155 | .python-version 156 | 157 | # celery beat schedule file 158 | celerybeat-schedule 159 | 160 | # SageMath parsed files 161 | *.sage.py 162 | 163 | # Environments 164 | .env 165 | .venv 166 | env/ 167 | venv/ 168 | ENV/ 169 | env.bak/ 170 | venv.bak/ 171 | 172 | # Spyder project settings 173 | .spyderproject 174 | .spyproject 175 | 176 | # Rope project settings 177 | .ropeproject 178 | 179 | # mkdocs documentation 180 | /site 181 | 182 | # mypy 183 | .mypy_cache/ 184 | 185 | ### Python Patch ### 186 | .venv/ 187 | 188 | ### Python.VirtualEnv Stack ### 189 | # Virtualenv 190 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 191 | [Bb]in 192 | [Ii]nclude 193 | [Ll]ib 194 | [Ll]ib64 195 | [Ll]ocal 196 | [Ss]cripts 197 | pyvenv.cfg 198 | pip-selfcheck.json 199 | 200 | 201 | # End of https://www.gitignore.io/api/python,pycharm+all 202 | -------------------------------------------------------------------------------- /lab_black.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, division, print_function, unicode_literals 3 | 4 | import json 5 | import logging 6 | import re 7 | import sys 8 | 9 | from IPython.core.inputtransformer2 import ( 10 | ESCAPE_DOUBLES, 11 | EscapedCommand, 12 | HelpEnd, 13 | MagicAssign, 14 | SystemAssign, 15 | TransformerManager, 16 | _help_end_re, 17 | assemble_continued_line, 18 | find_end_of_continued_line, 19 | tr, 20 | ) 21 | from IPython.display import Javascript, display 22 | 23 | __BF_SIGNATURE__ = "__BF_HIDDEN_VARIABLE_{}__" 24 | 25 | if sys.version_info >= (3, 6, 0): 26 | from black import format_str, FileMode 27 | 28 | def _format_code(code): 29 | return format_str(src_contents=code, mode=FileMode()) 30 | 31 | 32 | else: 33 | from yapf.yapflib.yapf_api import FormatCode 34 | 35 | def _format_code(code): 36 | return FormatCode(code, style_config="facebook")[0] 37 | 38 | 39 | def _transform_magic_commands(cell, hidden_variables): 40 | def __cell_magic(lines): 41 | # https://github.com/ipython/ipython/blob/1879ed27bb0ec3be5fee499ac177ad14a9ef7cfd/IPython/core/inputtransformer2.py#L91 42 | if not lines or not lines[0].startswith("%%"): 43 | return lines 44 | if re.match(r"%%\w+\?", lines[0]): 45 | # This case will be handled by help_end 46 | return lines 47 | magic_name, _, first_line = lines[0][2:-1].partition(" ") 48 | body = "".join(lines[1:]) 49 | hidden_variables.append("".join(lines)) 50 | return [__BF_SIGNATURE__.format(len(hidden_variables) - 1)] 51 | 52 | class __MagicAssign(MagicAssign): 53 | def transform(self, lines): 54 | # https://github.com/ipython/ipython/blob/1879ed27bb0ec3be5fee499ac177ad14a9ef7cfd/IPython/core/inputtransformer2.py#L223 55 | """Transform a magic assignment found by the ``find()`` classmethod. 56 | """ 57 | start_line, start_col = self.start_line, self.start_col 58 | lhs = lines[start_line][:start_col] 59 | end_line = find_end_of_continued_line(lines, start_line) 60 | rhs = assemble_continued_line(lines, (start_line, start_col), end_line) 61 | assert rhs.startswith("%"), rhs 62 | magic_name, _, args = rhs[1:].partition(" ") 63 | 64 | lines_before = lines[:start_line] 65 | hidden_variables.append(rhs) 66 | call = __BF_SIGNATURE__.format(len(hidden_variables) - 1) 67 | new_line = lhs + call + "\n" 68 | lines_after = lines[end_line + 1 :] 69 | 70 | return lines_before + [new_line] + lines_after 71 | 72 | class __SystemAssign(SystemAssign): 73 | def transform(self, lines): 74 | # https://github.com/ipython/ipython/blob/1879ed27bb0ec3be5fee499ac177ad14a9ef7cfd/IPython/core/inputtransformer2.py#L262 75 | """Transform a system assignment found by the ``find()`` classmethod. 76 | """ 77 | start_line, start_col = self.start_line, self.start_col 78 | 79 | lhs = lines[start_line][:start_col] 80 | end_line = find_end_of_continued_line(lines, start_line) 81 | rhs = assemble_continued_line(lines, (start_line, start_col), end_line) 82 | assert rhs.startswith("!"), rhs 83 | cmd = rhs[1:] 84 | 85 | lines_before = lines[:start_line] 86 | hidden_variables.append(rhs) 87 | call = __BF_SIGNATURE__.format(len(hidden_variables) - 1) 88 | new_line = lhs + call + "\n" 89 | lines_after = lines[end_line + 1 :] 90 | 91 | return lines_before + [new_line] + lines_after 92 | 93 | class __EscapedCommand(EscapedCommand): 94 | def transform(self, lines): 95 | # https://github.com/ipython/ipython/blob/1879ed27bb0ec3be5fee499ac177ad14a9ef7cfd/IPython/core/inputtransformer2.py#L382 96 | """Transform an escaped line found by the ``find()`` classmethod. 97 | """ 98 | start_line, start_col = self.start_line, self.start_col 99 | 100 | indent = lines[start_line][:start_col] 101 | end_line = find_end_of_continued_line(lines, start_line) 102 | line = assemble_continued_line(lines, (start_line, start_col), end_line) 103 | 104 | if len(line) > 1 and line[:2] in ESCAPE_DOUBLES: 105 | escape, content = line[:2], line[2:] 106 | else: 107 | escape, content = line[:1], line[1:] 108 | 109 | if escape in tr: 110 | hidden_variables.append(line) 111 | call = __BF_SIGNATURE__.format(len(hidden_variables) - 1) 112 | else: 113 | call = "" 114 | 115 | lines_before = lines[:start_line] 116 | new_line = indent + call + "\n" 117 | lines_after = lines[end_line + 1 :] 118 | 119 | return lines_before + [new_line] + lines_after 120 | 121 | class __HelpEnd(HelpEnd): 122 | def transform(self, lines): 123 | # https://github.com/ipython/ipython/blob/1879ed27bb0ec3be5fee499ac177ad14a9ef7cfd/IPython/core/inputtransformer2.py#L439 124 | """Transform a help command found by the ``find()`` classmethod. 125 | """ 126 | piece = "".join(lines[self.start_line : self.q_line + 1]) 127 | indent, content = piece[: self.start_col], piece[self.start_col :] 128 | lines_before = lines[: self.start_line] 129 | lines_after = lines[self.q_line + 1 :] 130 | 131 | m = _help_end_re.search(content) 132 | if not m: 133 | raise SyntaxError(content) 134 | assert m is not None, content 135 | target = m.group(1) 136 | esc = m.group(3) 137 | 138 | # If we're mid-command, put it back on the next prompt for the user. 139 | next_input = None 140 | if ( 141 | (not lines_before) 142 | and (not lines_after) 143 | and content.strip() != m.group(0) 144 | ): 145 | next_input = content.rstrip("?\n") 146 | 147 | hidden_variables.append(content) 148 | call = __BF_SIGNATURE__.format(len(hidden_variables) - 1) 149 | new_line = indent + call + "\n" 150 | 151 | return lines_before + [new_line] + lines_after 152 | 153 | transformer_manager = TransformerManager() 154 | transformer_manager.line_transforms = [__cell_magic] 155 | transformer_manager.token_transformers = [ 156 | __MagicAssign, 157 | __SystemAssign, 158 | __EscapedCommand, 159 | __HelpEnd, 160 | ] 161 | return transformer_manager.transform_cell(cell) 162 | 163 | 164 | def _recover_magic_commands(cell, hidden_variables): 165 | for hidden_variable_idx, hidden_variable in enumerate(hidden_variables): 166 | cell = cell.replace( 167 | __BF_SIGNATURE__.format(hidden_variable_idx), hidden_variable 168 | ) 169 | return cell 170 | 171 | 172 | class BlackFormatter(object): 173 | def __init__(self, ip, is_lab): 174 | self.shell = ip 175 | self.is_lab = is_lab 176 | 177 | def __set_cell(self, unformatted_cell, cell, cell_id=None): 178 | if self.is_lab: 179 | self.shell.set_next_input(cell, replace=True) 180 | else: 181 | js_code = """ 182 | setTimeout(function() { 183 | var nbb_cell_id = %d; 184 | var nbb_unformatted_code = %s; 185 | var nbb_formatted_code = %s; 186 | var nbb_cells = Jupyter.notebook.get_cells(); 187 | for (var i = 0; i < nbb_cells.length; ++i) { 188 | if (nbb_cells[i].input_prompt_number == nbb_cell_id) { 189 | if (nbb_cells[i].get_text() == nbb_unformatted_code) { 190 | nbb_cells[i].set_text(nbb_formatted_code); 191 | } 192 | break; 193 | } 194 | } 195 | }, 500); 196 | """ 197 | js_code = js_code % ( 198 | cell_id, 199 | json.dumps(unformatted_cell), 200 | json.dumps(cell), 201 | ) 202 | display(Javascript(js_code)) 203 | 204 | def format_cell(self, *args, **kwargs): 205 | try: 206 | cell_id = len(self.shell.user_ns["In"]) - 1 207 | if cell_id > 0: 208 | unformatted_cell = self.shell.user_ns["_i" + str(cell_id)] 209 | 210 | if re.search(r"^\s*%load(py)? ", unformatted_cell, flags=re.M): 211 | return 212 | 213 | hidden_variables = [] 214 | 215 | # Transform magic commands into special variables 216 | cell = _transform_magic_commands(unformatted_cell, hidden_variables) 217 | 218 | formatted_code = _format_code(cell) 219 | 220 | # Recover magic commands 221 | formatted_code = _recover_magic_commands( 222 | formatted_code, hidden_variables 223 | ) 224 | 225 | self.__set_cell(unformatted_cell, formatted_code.strip(), cell_id) 226 | except (ValueError, TypeError, AssertionError) as err: 227 | logging.exception(err) 228 | 229 | 230 | black_formatter = None 231 | 232 | 233 | def load_ipython_extension(ip): 234 | global black_formatter 235 | if black_formatter is None: 236 | black_formatter = BlackFormatter(ip, is_lab=True) 237 | ip.events.register("post_run_cell", black_formatter.format_cell) 238 | 239 | 240 | def unload_ipython_extension(ip): 241 | global black_formatter 242 | if black_formatter: 243 | ip.events.unregister("post_run_cell", black_formatter.format_cell) 244 | black_formatter = None 245 | --------------------------------------------------------------------------------