Add support for custom python cell magics (#2744)

Fixes #2742.

This PR adds the ability to configure additional python cell magics. This
will allow formatting cells in Jupyter Notebooks that are using custom (python)
magics.
This commit is contained in:
Michael Marino 2022-01-21 01:45:28 +01:00 committed by GitHub
parent e66e0f8ff0
commit 4ea75cd495
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 88 additions and 7 deletions

View File

@ -30,6 +30,8 @@
- Fix handling of standalone `match()` or `case()` when there is a trailing newline or a - Fix handling of standalone `match()` or `case()` when there is a trailing newline or a
comment inside of the parentheses. (#2760) comment inside of the parentheses. (#2760)
- Black now normalizes string prefix order (#2297) - Black now normalizes string prefix order (#2297)
- Add configuration option (`python-cell-magics`) to format cells with custom magics in
Jupyter Notebooks (#2744)
- Deprecate `--experimental-string-processing` and move the functionality under - Deprecate `--experimental-string-processing` and move the functionality under
`--preview` (#2789) `--preview` (#2789)

View File

@ -24,6 +24,7 @@
MutableMapping, MutableMapping,
Optional, Optional,
Pattern, Pattern,
Sequence,
Set, Set,
Sized, Sized,
Tuple, Tuple,
@ -225,6 +226,16 @@ def validate_regex(
"(useful when piping source on standard input)." "(useful when piping source on standard input)."
), ),
) )
@click.option(
"--python-cell-magics",
multiple=True,
help=(
"When processing Jupyter Notebooks, add the given magic to the list"
f" of known python-magics ({', '.join(PYTHON_CELL_MAGICS)})."
" Useful for formatting cells with custom python magics."
),
default=[],
)
@click.option( @click.option(
"-S", "-S",
"--skip-string-normalization", "--skip-string-normalization",
@ -401,6 +412,7 @@ def main(
fast: bool, fast: bool,
pyi: bool, pyi: bool,
ipynb: bool, ipynb: bool,
python_cell_magics: Sequence[str],
skip_string_normalization: bool, skip_string_normalization: bool,
skip_magic_trailing_comma: bool, skip_magic_trailing_comma: bool,
experimental_string_processing: bool, experimental_string_processing: bool,
@ -476,6 +488,7 @@ def main(
magic_trailing_comma=not skip_magic_trailing_comma, magic_trailing_comma=not skip_magic_trailing_comma,
experimental_string_processing=experimental_string_processing, experimental_string_processing=experimental_string_processing,
preview=preview, preview=preview,
python_cell_magics=set(python_cell_magics),
) )
if code is not None: if code is not None:
@ -981,7 +994,7 @@ def format_file_contents(src_contents: str, *, fast: bool, mode: Mode) -> FileCo
return dst_contents return dst_contents
def validate_cell(src: str) -> None: def validate_cell(src: str, mode: Mode) -> None:
"""Check that cell does not already contain TransformerManager transformations, """Check that cell does not already contain TransformerManager transformations,
or non-Python cell magics, which might cause tokenizer_rt to break because of or non-Python cell magics, which might cause tokenizer_rt to break because of
indentations. indentations.
@ -1000,7 +1013,10 @@ def validate_cell(src: str) -> None:
""" """
if any(transformed_magic in src for transformed_magic in TRANSFORMED_MAGICS): if any(transformed_magic in src for transformed_magic in TRANSFORMED_MAGICS):
raise NothingChanged raise NothingChanged
if src[:2] == "%%" and src.split()[0][2:] not in PYTHON_CELL_MAGICS: if (
src[:2] == "%%"
and src.split()[0][2:] not in PYTHON_CELL_MAGICS | mode.python_cell_magics
):
raise NothingChanged raise NothingChanged
@ -1020,7 +1036,7 @@ def format_cell(src: str, *, fast: bool, mode: Mode) -> str:
could potentially be automagics or multi-line magics, which could potentially be automagics or multi-line magics, which
are currently not supported. are currently not supported.
""" """
validate_cell(src) validate_cell(src, mode)
src_without_trailing_semicolon, has_trailing_semicolon = remove_trailing_semicolon( src_without_trailing_semicolon, has_trailing_semicolon = remove_trailing_semicolon(
src src
) )

View File

@ -4,6 +4,7 @@
chosen by the user. chosen by the user.
""" """
from hashlib import md5
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -142,6 +143,7 @@ class Mode:
is_ipynb: bool = False is_ipynb: bool = False
magic_trailing_comma: bool = True magic_trailing_comma: bool = True
experimental_string_processing: bool = False experimental_string_processing: bool = False
python_cell_magics: Set[str] = field(default_factory=set)
preview: bool = False preview: bool = False
def __post_init__(self) -> None: def __post_init__(self) -> None:
@ -180,5 +182,6 @@ def get_cache_key(self) -> str:
str(int(self.magic_trailing_comma)), str(int(self.magic_trailing_comma)),
str(int(self.experimental_string_processing)), str(int(self.experimental_string_processing)),
str(int(self.preview)), str(int(self.preview)),
md5((",".join(sorted(self.python_cell_magics))).encode()).hexdigest(),
] ]
return ".".join(parts) return ".".join(parts)

View File

@ -7,6 +7,7 @@ line-length = 79
target-version = ["py36", "py37", "py38"] target-version = ["py36", "py37", "py38"]
exclude='\.pyi?$' exclude='\.pyi?$'
include='\.py?$' include='\.py?$'
python-cell-magics = ["custom1", "custom2"]
[v1.0.0-syntax] [v1.0.0-syntax]
# This shouldn't break Black. # This shouldn't break Black.

View File

@ -1322,6 +1322,7 @@ def test_parse_pyproject_toml(self) -> None:
self.assertEqual(config["color"], True) self.assertEqual(config["color"], True)
self.assertEqual(config["line_length"], 79) self.assertEqual(config["line_length"], 79)
self.assertEqual(config["target_version"], ["py36", "py37", "py38"]) self.assertEqual(config["target_version"], ["py36", "py37", "py38"])
self.assertEqual(config["python_cell_magics"], ["custom1", "custom2"])
self.assertEqual(config["exclude"], r"\.pyi?$") self.assertEqual(config["exclude"], r"\.pyi?$")
self.assertEqual(config["include"], r"\.py?$") self.assertEqual(config["include"], r"\.py?$")

View File

@ -1,5 +1,8 @@
from dataclasses import replace
import pathlib import pathlib
import re import re
from contextlib import ExitStack as does_not_raise
from typing import ContextManager
from click.testing import CliRunner from click.testing import CliRunner
from black.handle_ipynb_magics import jupyter_dependencies_are_installed from black.handle_ipynb_magics import jupyter_dependencies_are_installed
@ -63,9 +66,19 @@ def test_trailing_semicolon_noop() -> None:
format_cell(src, fast=True, mode=JUPYTER_MODE) format_cell(src, fast=True, mode=JUPYTER_MODE)
def test_cell_magic() -> None: @pytest.mark.parametrize(
"mode",
[
pytest.param(JUPYTER_MODE, id="default mode"),
pytest.param(
replace(JUPYTER_MODE, python_cell_magics={"cust1", "cust1"}),
id="custom cell magics mode",
),
],
)
def test_cell_magic(mode: Mode) -> None:
src = "%%time\nfoo =bar" src = "%%time\nfoo =bar"
result = format_cell(src, fast=True, mode=JUPYTER_MODE) result = format_cell(src, fast=True, mode=mode)
expected = "%%time\nfoo = bar" expected = "%%time\nfoo = bar"
assert result == expected assert result == expected
@ -76,6 +89,16 @@ def test_cell_magic_noop() -> None:
format_cell(src, fast=True, mode=JUPYTER_MODE) format_cell(src, fast=True, mode=JUPYTER_MODE)
@pytest.mark.parametrize(
"mode",
[
pytest.param(JUPYTER_MODE, id="default mode"),
pytest.param(
replace(JUPYTER_MODE, python_cell_magics={"cust1", "cust1"}),
id="custom cell magics mode",
),
],
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"src, expected", "src, expected",
( (
@ -96,8 +119,8 @@ def test_cell_magic_noop() -> None:
pytest.param("env = %env", "env = %env", id="Assignment to magic"), pytest.param("env = %env", "env = %env", id="Assignment to magic"),
), ),
) )
def test_magic(src: str, expected: str) -> None: def test_magic(src: str, expected: str, mode: Mode) -> None:
result = format_cell(src, fast=True, mode=JUPYTER_MODE) result = format_cell(src, fast=True, mode=mode)
assert result == expected assert result == expected
@ -139,6 +162,41 @@ def test_cell_magic_with_magic() -> None:
assert result == expected assert result == expected
@pytest.mark.parametrize(
"mode, expected_output, expectation",
[
pytest.param(
JUPYTER_MODE,
"%%custom_python_magic -n1 -n2\nx=2",
pytest.raises(NothingChanged),
id="No change when cell magic not registered",
),
pytest.param(
replace(JUPYTER_MODE, python_cell_magics={"cust1", "cust1"}),
"%%custom_python_magic -n1 -n2\nx=2",
pytest.raises(NothingChanged),
id="No change when other cell magics registered",
),
pytest.param(
replace(JUPYTER_MODE, python_cell_magics={"custom_python_magic", "cust1"}),
"%%custom_python_magic -n1 -n2\nx = 2",
does_not_raise(),
id="Correctly change when cell magic registered",
),
],
)
def test_cell_magic_with_custom_python_magic(
mode: Mode, expected_output: str, expectation: ContextManager[object]
) -> None:
with expectation:
result = format_cell(
"%%custom_python_magic -n1 -n2\nx=2",
fast=True,
mode=mode,
)
assert result == expected_output
def test_cell_magic_nested() -> None: def test_cell_magic_nested() -> None:
src = "%%time\n%%time\n2+2" src = "%%time\n%%time\n2+2"
result = format_cell(src, fast=True, mode=JUPYTER_MODE) result = format_cell(src, fast=True, mode=JUPYTER_MODE)