Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for custom python cell magics #2744

Merged
merged 16 commits into from Jan 21, 2022
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
21 changes: 18 additions & 3 deletions src/black/__init__.py
Expand Up @@ -225,6 +225,16 @@ def validate_regex(
"(useful when piping source on standard input)."
),
)
@click.option(
"--python-cell-magics",
multiple=True,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit jarring to write --python-cell-magics custom1 --python-cell-magics custom2. Should we use a comma-separated list instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, though this is how click supports multiple options out of the box. This is also how target-version takes multiple arguments. I expect that we would need to introduce a custom callback to support multiple options, which might then also require custom logic when reading from the pyproject.toml file.

My expectation is that this will rather mostly be used in a configuration file and so perhaps the clunkiness on the command line is acceptable.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fair!

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 +411,7 @@ def main(
fast: bool,
pyi: bool,
ipynb: bool,
python_cell_magics: List[str],
JelleZijlstra marked this conversation as resolved.
Show resolved Hide resolved
skip_string_normalization: bool,
skip_magic_trailing_comma: bool,
experimental_string_processing: bool,
Expand Down Expand Up @@ -476,6 +487,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 +993,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 +1012,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 +1035,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