All patches and comments are welcome. Please squash your changes to logical
commits before using git-format-patch and git-send-email to
patches@git.madduck.net.
If you'd read over the Git project's submission guidelines and adhered to them,
I'd be especially grateful.
1 """Functions to process IPython magics with."""
3 from functools import lru_cache
6 from typing import Dict, List, Tuple, Optional
12 if sys.version_info >= (3, 10):
13 from typing import TypeGuard
15 from typing_extensions import TypeGuard
17 from black.report import NothingChanged
18 from black.output import out
21 TRANSFORMED_MAGICS = frozenset(
23 "get_ipython().run_cell_magic",
24 "get_ipython().system",
25 "get_ipython().getoutput",
26 "get_ipython().run_line_magic",
29 TOKENS_TO_IGNORE = frozenset(
40 NON_PYTHON_CELL_MAGICS = frozenset(
58 @dataclasses.dataclass(frozen=True)
65 def jupyter_dependencies_are_installed(*, verbose: bool, quiet: bool) -> bool:
67 import IPython # noqa:F401
68 import tokenize_rt # noqa:F401
69 except ModuleNotFoundError:
70 if verbose or not quiet:
72 "Skipping .ipynb files as Jupyter dependencies are not installed.\n"
73 "You can fix this by running ``pip install black[jupyter]``"
81 def remove_trailing_semicolon(src: str) -> Tuple[str, bool]:
82 """Remove trailing semicolon from Jupyter notebook cell.
86 fig, ax = plt.subplots()
87 ax.plot(x_data, y_data); # plot data
91 fig, ax = plt.subplots()
92 ax.plot(x_data, y_data) # plot data
94 Mirrors the logic in `quiet` from `IPython.core.displayhook`, but uses
95 ``tokenize_rt`` so that round-tripping works fine.
97 from tokenize_rt import (
103 tokens = src_to_tokens(src)
104 trailing_semicolon = False
105 for idx, token in reversed_enumerate(tokens):
106 if token.name in TOKENS_TO_IGNORE:
108 if token.name == "OP" and token.src == ";":
110 trailing_semicolon = True
112 if not trailing_semicolon:
114 return tokens_to_src(tokens), True
117 def put_trailing_semicolon_back(src: str, has_trailing_semicolon: bool) -> str:
118 """Put trailing semicolon back if cell originally had it.
120 Mirrors the logic in `quiet` from `IPython.core.displayhook`, but uses
121 ``tokenize_rt`` so that round-tripping works fine.
123 if not has_trailing_semicolon:
125 from tokenize_rt import src_to_tokens, tokens_to_src, reversed_enumerate
127 tokens = src_to_tokens(src)
128 for idx, token in reversed_enumerate(tokens):
129 if token.name in TOKENS_TO_IGNORE:
131 tokens[idx] = token._replace(src=token.src + ";")
133 else: # pragma: nocover
134 raise AssertionError(
135 "INTERNAL ERROR: Was not able to reinstate trailing semicolon. "
136 "Please report a bug on https://github.com/psf/black/issues. "
138 return str(tokens_to_src(tokens))
141 def mask_cell(src: str) -> Tuple[str, List[Replacement]]:
142 """Mask IPython magics so content becomes parseable Python code.
154 The replacements are returned, along with the transformed code.
156 replacements: List[Replacement] = []
160 # Might have IPython magics, will process below.
163 # Syntax is fine, nothing to mask, early return.
164 return src, replacements
166 from IPython.core.inputtransformer2 import TransformerManager
168 transformer_manager = TransformerManager()
169 transformed = transformer_manager.transform_cell(src)
170 transformed, cell_magic_replacements = replace_cell_magics(transformed)
171 replacements += cell_magic_replacements
172 transformed = transformer_manager.transform_cell(transformed)
173 transformed, magic_replacements = replace_magics(transformed)
174 if len(transformed.splitlines()) != len(src.splitlines()):
175 # Multi-line magic, not supported.
177 replacements += magic_replacements
178 return transformed, replacements
181 def get_token(src: str, magic: str) -> str:
182 """Return randomly generated token to mask IPython magic with.
184 For example, if 'magic' was `%matplotlib inline`, then a possible
185 token to mask it with would be `"43fdd17f7e5ddc83"`. The token
186 will be the same length as the magic, and we make sure that it was
187 not already present anywhere else in the cell.
190 nbytes = max(len(magic) // 2 - 1, 1)
191 token = secrets.token_hex(nbytes)
193 while token in src: # pragma: nocover
194 token = secrets.token_hex(nbytes)
197 raise AssertionError(
198 "INTERNAL ERROR: Black was not able to replace IPython magic. "
199 "Please report a bug on https://github.com/psf/black/issues. "
200 f"The magic might be helpful: {magic}"
202 if len(token) + 2 < len(magic):
207 def replace_cell_magics(src: str) -> Tuple[str, List[Replacement]]:
208 """Replace cell magic with token.
210 Note that 'src' will already have been processed by IPython's
211 TransformerManager().transform_cell.
215 get_ipython().run_cell_magic('t', '-n1', 'ls =!ls\\n')
222 The replacement, along with the transformed code, is returned.
224 replacements: List[Replacement] = []
226 tree = ast.parse(src)
228 cell_magic_finder = CellMagicFinder()
229 cell_magic_finder.visit(tree)
230 if cell_magic_finder.cell_magic is None:
231 return src, replacements
232 if cell_magic_finder.cell_magic.header.split()[0] in NON_PYTHON_CELL_MAGICS:
234 mask = get_token(src, cell_magic_finder.cell_magic.header)
235 replacements.append(Replacement(mask=mask, src=cell_magic_finder.cell_magic.header))
236 return f"{mask}\n{cell_magic_finder.cell_magic.body}", replacements
239 def replace_magics(src: str) -> Tuple[str, List[Replacement]]:
240 """Replace magics within body of cell.
242 Note that 'src' will already have been processed by IPython's
243 TransformerManager().transform_cell.
247 get_ipython().run_line_magic('matplotlib', 'inline')
255 The replacement, along with the transformed code, are returned.
258 magic_finder = MagicFinder()
259 magic_finder.visit(ast.parse(src))
261 for i, line in enumerate(src.splitlines(), start=1):
262 if i in magic_finder.magics:
263 offsets_and_magics = magic_finder.magics[i]
264 if len(offsets_and_magics) != 1: # pragma: nocover
265 raise AssertionError(
266 f"Expecting one magic per line, got: {offsets_and_magics}\n"
267 "Please report a bug on https://github.com/psf/black/issues."
269 col_offset, magic = (
270 offsets_and_magics[0].col_offset,
271 offsets_and_magics[0].magic,
273 mask = get_token(src, magic)
274 replacements.append(Replacement(mask=mask, src=magic))
275 line = line[:col_offset] + mask
276 new_srcs.append(line)
277 return "\n".join(new_srcs), replacements
280 def unmask_cell(src: str, replacements: List[Replacement]) -> str:
281 """Remove replacements from cell.
293 for replacement in replacements:
294 src = src.replace(replacement.mask, replacement.src)
298 def _is_ipython_magic(node: ast.expr) -> TypeGuard[ast.Attribute]:
299 """Check if attribute is IPython magic.
301 Note that the source of the abstract syntax tree
302 will already have been processed by IPython's
303 TransformerManager().transform_cell.
306 isinstance(node, ast.Attribute)
307 and isinstance(node.value, ast.Call)
308 and isinstance(node.value.func, ast.Name)
309 and node.value.func.id == "get_ipython"
313 @dataclasses.dataclass(frozen=True)
319 @dataclasses.dataclass
320 class CellMagicFinder(ast.NodeVisitor):
323 Note that the source of the abstract syntax tree
324 will already have been processed by IPython's
325 TransformerManager().transform_cell.
331 would have been transformed to
333 get_ipython().run_cell_magic('time', '', 'foo()\\n')
335 and we look for instances of the latter.
338 cell_magic: Optional[CellMagic] = None
340 def visit_Expr(self, node: ast.Expr) -> None:
341 """Find cell magic, extract header and body."""
343 isinstance(node.value, ast.Call)
344 and _is_ipython_magic(node.value.func)
345 and node.value.func.attr == "run_cell_magic"
348 for arg in node.value.args:
349 assert isinstance(arg, ast.Str)
351 header = f"%%{args[0]}"
353 header += f" {args[1]}"
354 self.cell_magic = CellMagic(header=header, body=args[2])
355 self.generic_visit(node)
358 @dataclasses.dataclass(frozen=True)
359 class OffsetAndMagic:
364 @dataclasses.dataclass
365 class MagicFinder(ast.NodeVisitor):
366 """Visit cell to look for get_ipython calls.
368 Note that the source of the abstract syntax tree
369 will already have been processed by IPython's
370 TransformerManager().transform_cell.
376 would have been transformed to
378 get_ipython().run_line_magic('matplotlib', 'inline')
380 and we look for instances of the latter (and likewise for other
384 magics: Dict[int, List[OffsetAndMagic]] = dataclasses.field(
385 default_factory=lambda: collections.defaultdict(list)
388 def visit_Assign(self, node: ast.Assign) -> None:
389 """Look for system assign magics.
393 black_version = !black --version
395 would have been transformed to
397 black_version = get_ipython().getoutput('black --version')
399 and we look for instances of the latter.
402 isinstance(node.value, ast.Call)
403 and _is_ipython_magic(node.value.func)
404 and node.value.func.attr == "getoutput"
407 for arg in node.value.args:
408 assert isinstance(arg, ast.Str)
412 self.magics[node.value.lineno].append(
413 OffsetAndMagic(node.value.col_offset, src)
415 self.generic_visit(node)
417 def visit_Expr(self, node: ast.Expr) -> None:
418 """Look for magics in body of cell.
427 would (respectively) get transformed to
429 get_ipython().system('ls')
430 get_ipython().getoutput('ls')
431 get_ipython().run_line_magic('pinfo', 'ls')
432 get_ipython().run_line_magic('pinfo2', 'ls')
434 and we look for instances of any of the latter.
436 if isinstance(node.value, ast.Call) and _is_ipython_magic(node.value.func):
438 for arg in node.value.args:
439 assert isinstance(arg, ast.Str)
442 if node.value.func.attr == "run_line_magic":
443 if args[0] == "pinfo":
445 elif args[0] == "pinfo2":
450 assert src is not None
452 elif node.value.func.attr == "system":
454 elif node.value.func.attr == "getoutput":
457 raise NothingChanged # unsupported magic.
458 self.magics[node.value.lineno].append(
459 OffsetAndMagic(node.value.col_offset, src)
461 self.generic_visit(node)