├── LICENSE ├── README.md ├── demo.gif ├── jupyter-black.js ├── jupyter-black.yaml └── kernel_exec_on_cell.js /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, driller 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jupyter Black [Black formatter for Jupyter Notebook] 2 | 3 | This extension reformats/prettifies code in a notebook's code cell by [black](https://black.readthedocs.io/en/stable/). 4 | 5 | ![demo](demo.gif) 6 | 7 | **pre-requisites:** of course, you must have some of the corresponding packages installed: 8 | 9 | ```bash 10 | pip install black [--user] 11 | ``` 12 | 13 | Then the extension provides 14 | 15 | - a toolbar button 16 | - a keyboard shortcut for reformatting the current code-cell (default: Ctrl-B) 17 | - a keyboard shortcut for reformatting whole code-cells (default: Ctrl-Shift-B) 18 | 19 | Syntax shall be correct. The extension will also point basic syntax errors. 20 | 21 | ## Installation 22 | 23 | If you use [jupyter-contrib-nbextensions](https://github.com/ipython-contrib/jupyter_contrib_nbextensions), proceed as usual. 24 | 25 | Otherwise, you can still install/try the extension from personal repo, using 26 | 27 | ```bash 28 | jupyter nbextension install https://github.com/drillan/jupyter-black/archive/master.zip --user 29 | jupyter nbextension enable jupyter-black-master/jupyter-black 30 | ``` 31 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drillan/jupyter-black/dd01285bf7bca65a939309727a3a4b95743c9439/demo.gif -------------------------------------------------------------------------------- /jupyter-black.js: -------------------------------------------------------------------------------- 1 | define(['./kernel_exec_on_cell'], function(kernel_exec_on_cell) { 2 | 'use strict'; 3 | 4 | var mod_name = 'jupyter-black'; 5 | 6 | // gives default settings 7 | var cfg = { 8 | add_toolbar_button: true, 9 | hotkeys: { 10 | process_selected: 'Ctrl-B', 11 | process_all: 'Ctrl-Shift-B', 12 | }, 13 | register_hotkey: true, 14 | show_alerts_for_errors: true, 15 | button_label: 'Black', 16 | button_icon: 'fa-legal', 17 | kbd_shortcut_text: 'Black', 18 | }; 19 | 20 | cfg.kernel_config_map = { // map of parameters for supported kernels 21 | "python": { 22 | "library": ["import json", 23 | "def black_reformat(cell_text):", 24 | " import black", 25 | " import re", 26 | " cell_text = re.sub('^%', '#%#', cell_text, flags=re.M)", 27 | " reformated_text = black.format_str(cell_text, mode=black.FileMode())", 28 | " return re.sub('^#%#', '%', reformated_text, flags=re.M)"].join("\n"), 29 | "prefix": "print(json.dumps(black_reformat(u", 30 | "postfix": ")))" 31 | }, 32 | "javascript": { 33 | "library": "jsbeautify = require(" + "'js-beautify')", 34 | // we do this + trick to prevent require.js attempting to load js-beautify when processing the AMI-style load for this module 35 | "prefix": "console.log(JSON.stringify(jsbeautify.js_beautify(", 36 | "postfix": ")));" 37 | } 38 | }; 39 | 40 | 41 | var prettifier = new kernel_exec_on_cell.define_plugin(mod_name, cfg); 42 | prettifier.load_ipython_extension = prettifier.initialize_plugin; 43 | return prettifier; 44 | }); -------------------------------------------------------------------------------- /jupyter-black.yaml: -------------------------------------------------------------------------------- 1 | Type: IPython Notebook Extension 2 | Name: Jupyter Black 3 | Description: Use Black to reformat/prettify Python code cells 4 | Link: README.md 5 | Main: jupyter-black.js 6 | Compatibility: Jupyter 4.x, 5.x 7 | Parameters: 8 | - name: jupyter-black.add_toolbar_button 9 | description: Add a toolbar button to prettify the selected cell(s) 10 | input_type: checkbox 11 | default: true 12 | 13 | - name: jupyter-black.button_icon 14 | description: | 15 | Toolbar button icon: a font-awesome class defining the icon used for the 16 | toolbar button and actions. 17 | See https://fontawesome.com/icons for available icons. 18 | input_type: text 19 | default: 'fa-legal' 20 | 21 | - name: jupyter-black.button_label 22 | description: Toolbar button label text 23 | input_type: text 24 | default: 'Black' 25 | 26 | - name: jupyter-black.register_hotkey 27 | description: | 28 | Register hotkeys to prettify the selected code cell(s), or all code cells 29 | in the notebook 30 | input_type: checkbox 31 | default: true 32 | 33 | - name: jupyter-black.hotkeys.process_selected 34 | description: Hotkey to use to prettify the selected cell(s) 35 | input_type: hotkey 36 | default: 'Ctrl-B' 37 | 38 | - name: jupyter-black.hotkeys.process_all 39 | description: Hotkey to use to prettify the whole notebook 40 | input_type: hotkey 41 | default: 'Ctrl-Shift-B' 42 | 43 | - name: jupyter-black.show_alerts_for_not_supported_kernel 44 | description: Show alerts if the kernel is not supported 45 | input_type: checkbox 46 | default: false 47 | 48 | - name: jupyter-black.show_alerts_for_errors 49 | description: Show alerts for errors in the kernel prettifying calls 50 | input_type: checkbox 51 | default: true 52 | 53 | - name: jupyter-black.kernel_config_map_json 54 | description: | 55 | json defining library calls required to load the kernel-specific 56 | prettifying modules, and the prefix & postfix for the json-format string 57 | required to make the prettifying call. 58 | input_type: textarea 59 | default: | 60 | { 61 | "python": { 62 | "library": "import json\ndef black_reformat(cell_text):\n import black\n import re\n cell_text = re.sub('^%', '#%#', cell_text, flags=re.M)\n reformated_text = black.format_str(cell_text, mode=black.FileMode())\n return re.sub('^#%#', '%', reformated_text, flags=re.M)", 63 | "prefix": "print(json.dumps(black_reformat(u", 64 | "postfix": ")))" 65 | }, 66 | "javascript": { 67 | "library": "jsbeautify = require('js-beautify')", 68 | "prefix": "console.log(JSON.stringify(jsbeautify.js_beautify(", 69 | "postfix": ")));" 70 | } 71 | } -------------------------------------------------------------------------------- /kernel_exec_on_cell.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter-Contrib Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | // Copyright (c) 2019, driller 4 | 5 | define([ 6 | 'jquery', 7 | 'base/js/namespace', 8 | 'base/js/events', 9 | 'notebook/js/codecell', 10 | ], function( 11 | $, 12 | Jupyter, 13 | events, 14 | codecell 15 | ) { 16 | 'use strict'; 17 | 18 | var CodeCell = codecell.CodeCell; 19 | 20 | // this wrapper function allows config & hotkeys to be per-plugin 21 | function KernelExecOnCells(mod_name, cfg) { 22 | 23 | this.mod_name = mod_name; 24 | this.mod_log_prefix = '[' + this.mod_name + ']'; 25 | this.mod_edit_shortcuts = {}; 26 | this.mod_cmd_shortcuts = {}; 27 | this.default_kernel_config = { 28 | library: '', 29 | prefix: '', 30 | postfix: '', 31 | replacements_json_to_kernel: [], 32 | trim_formatted_text: true 33 | }; 34 | // gives default settings 35 | var default_cfg = { 36 | add_toolbar_button: true, 37 | hotkeys: { 38 | process_selected: 'Ctrl-L', 39 | process_all: 'Ctrl-Shift-L', 40 | }, 41 | register_hotkey: true, 42 | show_alerts_for_errors: true, 43 | show_alerts_for_not_supported_kernel: false, 44 | button_icon: 'fa-legal', 45 | button_label: 'Black', 46 | kbd_shortcut_text: mod_name, 47 | kernel_config_map: {}, 48 | actions: null, // to be filled by register_actions 49 | }; 50 | // extend a new object, to avoid interference with other nbextensions 51 | // derived from the same base class 52 | this.cfg = $.extend(true, {}, default_cfg, cfg); 53 | // set default json string, will later be updated from config 54 | // before it is parsed into an object 55 | this.cfg.kernel_config_map_json = JSON.stringify(this.cfg.kernel_config_map); 56 | 57 | } // end per-plugin wrapper define_plugin_functions 58 | 59 | // Prototypes 60 | // ---------- 61 | 62 | /** 63 | * return a Promise which will resolve/reject based on the kernel message 64 | * type. 65 | * The returned promise will be 66 | * - resolved if the message was not an error 67 | * - rejected using the message's error text if msg.msg_type is "error" 68 | */ 69 | KernelExecOnCells.prototype.convert_error_msg_to_broken_promise = function(msg) { 70 | var that = this; 71 | return new Promise(function(resolve, reject) { 72 | if (msg.msg_type == 'error') { 73 | return reject(that.mod_log_prefix + '\n Error: ' + msg.content.ename + '\n' + msg.content.evalue); 74 | } 75 | return resolve(msg); 76 | }); 77 | }; 78 | 79 | KernelExecOnCells.prototype.convert_loading_library_error_msg_to_broken_promise = function(msg) { 80 | var that = this; 81 | return new Promise(function(resolve, reject) { 82 | if (msg.msg_type == 'error') { 83 | return reject(that.mod_log_prefix + '\n Error loading library for ' + 84 | Jupyter.notebook.metadata.kernelspec.language + ':\n' + 85 | msg.content.ename + msg.content.evalue + 86 | '\n\nCheck that the appropriate library/module is correctly installed (read ' + 87 | that.mod_name + '\'s documentation for details)'); 88 | } 89 | return resolve(msg); 90 | }); 91 | }; 92 | 93 | KernelExecOnCells.prototype.get_kernel_config = function() { 94 | var kernelLanguage = Jupyter.notebook.metadata.kernelspec.language.toLowerCase(); 95 | var kernel_config = this.cfg.kernel_config_map[kernelLanguage]; 96 | // true => deep 97 | return $.extend(true, {}, this.default_kernel_config, kernel_config); 98 | }; 99 | 100 | KernelExecOnCells.prototype.transform_json_string_to_kernel_string = function(str, kernel_config) { 101 | for (var ii = 0; ii < kernel_config.replacements_json_to_kernel.length; ii++) { 102 | var from = kernel_config.replacements_json_to_kernel[ii][0]; 103 | var to = kernel_config.replacements_json_to_kernel[ii][1]; 104 | str = str.replace(from, to); 105 | } 106 | return str; 107 | }; 108 | 109 | /** 110 | * construct functions as callbacks for the autoformat cell promise. This 111 | * is necessary because javascript lacks loop scoping, so if we don't use 112 | * this IIFE pattern, cell_index & cell are passed by reference, and every 113 | * callback ends up using the same value 114 | */ 115 | KernelExecOnCells.prototype.construct_cell_callbacks = function(cell_index, cell) { 116 | var that = this; 117 | var on_success = function(formatted_text) { 118 | cell.set_text(formatted_text); 119 | }; 120 | var on_failure = function(reason) { 121 | console.warn( 122 | that.mod_log_prefix, 123 | 'error processing cell', cell_index + ':\n', 124 | reason 125 | ); 126 | if (that.cfg.show_alerts_for_errors) { 127 | alert(reason); 128 | } 129 | }; 130 | return [on_success, on_failure]; 131 | }; 132 | 133 | KernelExecOnCells.prototype.autoformat_cells = function(indices) { 134 | 135 | if (indices === undefined) { 136 | indices = Jupyter.notebook.get_selected_cells_indices(); 137 | } 138 | var kernel_config = this.get_kernel_config(); 139 | for (var ii = 0; ii < indices.length; ii++) { 140 | var cell_index = indices[ii]; 141 | var cell = Jupyter.notebook.get_cell(cell_index); 142 | if (!(cell instanceof CodeCell)) { 143 | continue; 144 | } 145 | // IIFE because otherwise cell_index & cell are passed by reference 146 | var callbacks = this.construct_cell_callbacks(cell_index, cell); 147 | this.autoformat_text(cell.get_text(), kernel_config).then(callbacks[0], callbacks[1]); 148 | } 149 | }; 150 | 151 | KernelExecOnCells.prototype.autoformat_text = function(text, kernel_config) { 152 | var that = this; 153 | return new Promise(function(resolve, reject) { 154 | kernel_config = kernel_config || that.get_kernel_config(); 155 | var kernel_str = that.transform_json_string_to_kernel_string( 156 | JSON.stringify(text), kernel_config); 157 | Jupyter.notebook.kernel.execute( 158 | kernel_config.prefix + kernel_str + kernel_config.postfix, { 159 | iopub: { 160 | output: function(msg) { 161 | return resolve(that.convert_error_msg_to_broken_promise(msg).then( 162 | function on_success(msg) { 163 | // print goes to stream text => msg.content.text 164 | // but for some kernels (eg nodejs) can be called as result of exec 165 | if (msg.content.text !== undefined) { 166 | var formatted_text; 167 | try { 168 | formatted_text = String(JSON.parse(msg.content.text)); 169 | } 170 | catch (err) { 171 | return Promise.reject(err); 172 | } 173 | if (kernel_config.trim_formatted_text) { 174 | formatted_text = formatted_text.trim(); 175 | } 176 | return formatted_text; 177 | } 178 | } 179 | )); 180 | } 181 | } 182 | }, { silent: false } 183 | ); 184 | }); 185 | }; 186 | 187 | 188 | KernelExecOnCells.prototype.add_toolbar_button = function() { 189 | if ($('#' + this.mod_name + '_button').length < 1) { 190 | var button_group_id = this.mod_name + '_button'; 191 | var that = this; 192 | Jupyter.toolbar.add_buttons_group([{ 193 | label: ' ', //space otherwise add_buttons fails -- This label is inserted as a button description AND bubble help 194 | icon: this.cfg.button_icon, 195 | callback: function(evt) { 196 | that.autoformat_cells( 197 | evt.shiftKey ? Jupyter.notebook.get_cells().map(function (cell, idx) { return idx; }) : undefined 198 | ); 199 | }, 200 | }], button_group_id); 201 | 202 | // Correct add_buttons_group default 203 | // Change title --> inserts bubble help 204 | // redefine icon to remove spurious space 205 | var w = $('#'+ button_group_id +' > .btn')[0]; 206 | w.title = this.cfg.kbd_shortcut_text + ' selected cell(s) (add shift for all cells)' 207 | w.innerHTML = ' ' + this.cfg.button_label + '' 208 | } 209 | } 210 | 211 | 212 | 213 | KernelExecOnCells.prototype.add_keyboard_shortcuts = function() { 214 | var new_shortcuts = {}; 215 | new_shortcuts[this.cfg.hotkeys.process_selected] = this.cfg.actions.process_selected.name; 216 | new_shortcuts[this.cfg.hotkeys.process_all] = this.cfg.actions.process_all.name; 217 | Jupyter.keyboard_manager.edit_shortcuts.add_shortcuts(new_shortcuts); 218 | Jupyter.keyboard_manager.command_shortcuts.add_shortcuts(new_shortcuts); 219 | }; 220 | 221 | KernelExecOnCells.prototype.register_actions = function() { 222 | /** 223 | * it's important that the actions created by registering keyboard 224 | * shortcuts get different names, as otherwise a default action is 225 | * created, whose name is a string representation of the handler 226 | * function. 227 | * Since this library uses the same handler function for all plugins, 228 | * just with different contexts (different values of cfg), their 229 | * string representations are the same, and the last one to be 230 | * registered overwrites all previous versions. 231 | * This is essentially an issue with notebook, but it encourages us to 232 | * use actions, which is where notebook is going anyway. 233 | */ 234 | var actions = this.cfg.actions = {}; 235 | var that = this; 236 | actions.process_selected = { 237 | help: that.cfg.kbd_shortcut_text + ' selected cell(s)', 238 | help_index: 'yf', 239 | icon: that.cfg.button_icon, 240 | handler: function(evt) { that.autoformat_cells(); }, 241 | }; 242 | actions.process_all = { 243 | help: that.cfg.kbd_shortcut_text + " the whole notebook", 244 | help_index: 'yf', 245 | icon: that.cfg.button_icon, 246 | handler: function(evt) { 247 | that.autoformat_cells(Jupyter.notebook.get_cells().map(function (cell, idx) { return idx; })); 248 | }, 249 | }; 250 | 251 | actions.process_selected.name = Jupyter.keyboard_manager.actions.register( 252 | actions.process_selected, 'process_selected_cells', that.mod_name); 253 | actions.process_all.name = Jupyter.keyboard_manager.actions.register( 254 | actions.process_all, 'process_all_cells', that.mod_name); 255 | }; 256 | 257 | KernelExecOnCells.prototype.setup_for_new_kernel = function() { 258 | var that = this; 259 | var kernelLanguage = Jupyter.notebook.metadata.kernelspec.language.toLowerCase(); 260 | var kernel_config = this.cfg.kernel_config_map[kernelLanguage]; 261 | if (kernel_config === undefined) { 262 | $('#' + this.mod_name + '_button').remove(); 263 | var err = this.mod_log_prefix + " Sorry, can't use kernel language " + kernelLanguage + ".\n" + 264 | "Configurations are currently only defined for the following languages:\n" + 265 | Object.keys(this.cfg.kernel_config_map).join(', ') + "\n" + 266 | "See readme for more details." 267 | if (this.cfg.show_alerts_for_not_supported_kernel) { 268 | alert(err); 269 | } 270 | else { 271 | console.error(err); 272 | } 273 | // also remove keyboard shortcuts 274 | if (this.cfg.register_hotkey) { 275 | try { 276 | Jupyter.keyboard_manager.edit_shortcuts.remove_shortcut(this.cfg.hotkeys.process_selected); 277 | Jupyter.keyboard_manager.edit_shortcuts.remove_shortcut(this.cfg.hotkeys.process_all); 278 | Jupyter.keyboard_manager.command_shortcuts.remove_shortcut(this.cfg.hotkeys.process_all); 279 | } catch (err) {} 280 | } 281 | 282 | } else { // kernel language is supported 283 | if (this.cfg.add_toolbar_button) { 284 | this.add_toolbar_button(); 285 | } 286 | if (this.cfg.register_hotkey) { 287 | this.add_keyboard_shortcuts(); 288 | } 289 | Jupyter.notebook.kernel.execute( 290 | kernel_config.library, { 291 | iopub: { 292 | output: function(msg) { 293 | return that.convert_loading_library_error_msg_to_broken_promise(msg) 294 | .catch( 295 | function on_failure(err) { 296 | if (that.cfg.show_alerts_for_errors) { 297 | alert(err); 298 | } 299 | else { 300 | console.error(err); 301 | } 302 | } 303 | 304 | ); 305 | } 306 | } 307 | }, { silent: false } 308 | ); 309 | 310 | } 311 | }; 312 | 313 | KernelExecOnCells.prototype.initialize_plugin = function() { 314 | var that = this; 315 | // first, load config 316 | Jupyter.notebook.config.loaded 317 | // now update default config with that loaded from server 318 | .then(function on_success() { 319 | $.extend(true, that.cfg, Jupyter.notebook.config.data[that.mod_name]); 320 | }, function on_error(err) { 321 | console.warn(that.mod_log_prefix, 'error loading config:', err); 322 | }) 323 | // next parse json config values 324 | .then(function on_success() { 325 | var parsed_kernel_cfg = JSON.parse(that.cfg.kernel_config_map_json); 326 | $.extend(that.cfg.kernel_config_map, parsed_kernel_cfg); 327 | }) 328 | // if we failed to parse the json values in the config 329 | // using catch pattern, we attempt to continue anyway using defaults 330 | .catch(function on_error(err) { 331 | console.warn( 332 | that.mod_log_prefix, 'error parsing config variable', 333 | that.mod_name + '.kernel_config_map_json to a json object:', 334 | err 335 | ); 336 | }) 337 | // now do things which required the config to be loaded 338 | .then(function on_success() { 339 | that.register_actions(); // register actions 340 | // kernel may already have been loaded before we get here, in which 341 | // case we've missed the kernel_ready.Kernel event, so try ctx 342 | if (typeof Jupyter.notebook.kernel !== "undefined" && Jupyter.notebook.kernel !== null) { 343 | that.setup_for_new_kernel(); 344 | } 345 | 346 | // on kernel_ready.Kernel, a new kernel has been started 347 | events.on("kernel_ready.Kernel", function(evt, data) { 348 | console.log(that.mod_log_prefix, 'restarting for new kernel_ready.Kernel event'); 349 | that.setup_for_new_kernel(); 350 | }); 351 | }).catch(function on_error(err) { 352 | console.error(that.mod_log_prefix, 'error loading:', err); 353 | }); 354 | }; 355 | 356 | return {define_plugin: KernelExecOnCells}; 357 | }); 358 | --------------------------------------------------------------------------------