Skip to content

Commit

Permalink
Add support for custom python cell magics (#2744)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mgmarino committed Jan 21, 2022
1 parent e66e0f8 commit 4ea75cd
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 7 deletions.
2 changes: 2 additions & 0 deletions CHANGES.md
Expand Up @@ -30,6 +30,8 @@
- Fix handling of standalone `match()` or `case()` when there is a trailing newline or a
comment inside of the parentheses. (#2760)
- 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
`--preview` (#2789)

Expand Down
22 changes: 19 additions & 3 deletions src/black/__init__.py
Expand Up @@ -24,6 +24,7 @@
MutableMapping,
Optional,
Pattern,
Sequence,
Set,
Sized,
Tuple,
Expand Down Expand Up @@ -225,6 +226,16 @@ def validate_regex(
"(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(
"-S",
"--skip-string-normalization",
Expand Down Expand Up @@ -401,6 +412,7 @@ def main(
fast: bool,
pyi: bool,
ipynb: bool,
python_cell_magics: Sequence[str],
skip_string_normalization: bool,
skip_magic_trailing_comma: bool,
experimental_string_processing: bool,
Expand Down Expand Up @@ -476,6 +488,7 @@ def main(
magic_trailing_comma=not skip_magic_trailing_comma,
experimental_string_processing=experimental_string_processing,
preview=preview,
python_cell_magics=set(python_cell_magics),
)

if code is not None:
Expand Down Expand Up @@ -981,7 +994,7 @@ def format_file_contents(src_contents: str, *, fast: bool, mode: Mode) -> FileCo
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,
or non-Python cell magics, which might cause tokenizer_rt to break because of
indentations.
Expand All @@ -1000,7 +1013,10 @@ def validate_cell(src: str) -> None:
"""
if any(transformed_magic in src for transformed_magic in TRANSFORMED_MAGICS):
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


Expand All @@ -1020,7 +1036,7 @@ def format_cell(src: str, *, fast: bool, mode: Mode) -> str:
could potentially be automagics or multi-line magics, which
are currently not supported.
"""
validate_cell(src)
validate_cell(src, mode)
src_without_trailing_semicolon, has_trailing_semicolon = remove_trailing_semicolon(
src
)
Expand Down
3 changes: 3 additions & 0 deletions src/black/mode.py
Expand Up @@ -4,6 +4,7 @@
chosen by the user.
"""

from hashlib import md5
import sys

from dataclasses import dataclass, field
Expand Down Expand Up @@ -142,6 +143,7 @@ class Mode:
is_ipynb: bool = False
magic_trailing_comma: bool = True
experimental_string_processing: bool = False
python_cell_magics: Set[str] = field(default_factory=set)
preview: bool = False

def __post_init__(self) -> None:
Expand Down Expand Up @@ -180,5 +182,6 @@ def get_cache_key(self) -> str:
str(int(self.magic_trailing_comma)),
str(int(self.experimental_string_processing)),
str(int(self.preview)),
md5((",".join(sorted(self.python_cell_magics))).encode()).hexdigest(),
]
return ".".join(parts)
1 change: 1 addition & 0 deletions tests/test.toml
Expand Up @@ -7,6 +7,7 @@ line-length = 79
target-version = ["py36", "py37", "py38"]
exclude='\.pyi?$'
include='\.py?$'
python-cell-magics = ["custom1", "custom2"]

[v1.0.0-syntax]
# This shouldn't break Black.
Expand Down
1 change: 1 addition & 0 deletions tests/test_black.py
Expand Up @@ -1322,6 +1322,7 @@ def test_parse_pyproject_toml(self) -> None:
self.assertEqual(config["color"], True)
self.assertEqual(config["line_length"], 79)
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["include"], r"\.py?$")

Expand Down
66 changes: 62 additions & 4 deletions tests/test_ipynb.py
@@ -1,5 +1,8 @@
from dataclasses import replace
import pathlib
import re
from contextlib import ExitStack as does_not_raise
from typing import ContextManager

from click.testing import CliRunner
from black.handle_ipynb_magics import jupyter_dependencies_are_installed
Expand Down Expand Up @@ -63,9 +66,19 @@ def test_trailing_semicolon_noop() -> None:
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"
result = format_cell(src, fast=True, mode=JUPYTER_MODE)
result = format_cell(src, fast=True, mode=mode)
expected = "%%time\nfoo = bar"
assert result == expected

Expand All @@ -76,6 +89,16 @@ def test_cell_magic_noop() -> None:
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(
"src, expected",
(
Expand All @@ -96,8 +119,8 @@ def test_cell_magic_noop() -> None:
pytest.param("env = %env", "env = %env", id="Assignment to magic"),
),
)
def test_magic(src: str, expected: str) -> None:
result = format_cell(src, fast=True, mode=JUPYTER_MODE)
def test_magic(src: str, expected: str, mode: Mode) -> None:
result = format_cell(src, fast=True, mode=mode)
assert result == expected


Expand Down Expand Up @@ -139,6 +162,41 @@ def test_cell_magic_with_magic() -> None:
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:
src = "%%time\n%%time\n2+2"
result = format_cell(src, fast=True, mode=JUPYTER_MODE)
Expand Down

0 comments on commit 4ea75cd

Please sign in to comment.