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."""
2 from functools import lru_cache
5 from typing import Dict
8 from typing import List, Tuple
11 from typing import Optional
12 from typing_extensions import TypeGuard
13 from black.report import NothingChanged
14 from black.output import out
17 TRANSFORMED_MAGICS = frozenset(
19 "get_ipython().run_cell_magic",
20 "get_ipython().system",
21 "get_ipython().getoutput",
22 "get_ipython().run_line_magic",
25 TOKENS_TO_IGNORE = frozenset(
36 NON_PYTHON_CELL_MAGICS = frozenset(
54 @dataclasses.dataclass(frozen=True)
61 def jupyter_dependencies_are_installed(*, verbose: bool, quiet: bool) -> bool:
63 import IPython # noqa:F401
64 import tokenize_rt # noqa:F401
65 except ModuleNotFoundError:
66 if verbose or not quiet:
68 "Skipping .ipynb files as Jupyter dependencies are not installed.\n"
69 "You can fix this by running ``pip install black[jupyter]``"
77 def remove_trailing_semicolon(src: str) -> Tuple[str, bool]:
78 """Remove trailing semicolon from Jupyter notebook cell.
82 fig, ax = plt.subplots()
83 ax.plot(x_data, y_data); # plot data
87 fig, ax = plt.subplots()
88 ax.plot(x_data, y_data) # plot data
90 Mirrors the logic in `quiet` from `IPython.core.displayhook`, but uses
91 ``tokenize_rt`` so that round-tripping works fine.
93 from tokenize_rt import (
99 tokens = src_to_tokens(src)
100 trailing_semicolon = False
101 for idx, token in reversed_enumerate(tokens):
102 if token.name in TOKENS_TO_IGNORE:
104 if token.name == "OP" and token.src == ";":
106 trailing_semicolon = True
108 if not trailing_semicolon:
110 return tokens_to_src(tokens), True
113 def put_trailing_semicolon_back(src: str, has_trailing_semicolon: bool) -> str:
114 """Put trailing semicolon back if cell originally had it.
116 Mirrors the logic in `quiet` from `IPython.core.displayhook`, but uses
117 ``tokenize_rt`` so that round-tripping works fine.
119 if not has_trailing_semicolon:
121 from tokenize_rt import src_to_tokens, tokens_to_src, reversed_enumerate
123 tokens = src_to_tokens(src)
124 for idx, token in reversed_enumerate(tokens):
125 if token.name in TOKENS_TO_IGNORE:
127 tokens[idx] = token._replace(src=token.src + ";")
129 else: # pragma: nocover
130 raise AssertionError(
131 "INTERNAL ERROR: Was not able to reinstate trailing semicolon. "
132 "Please report a bug on https://github.com/psf/black/issues. "
134 return str(tokens_to_src(tokens))
137 def mask_cell(src: str) -> Tuple[str, List[Replacement]]:
138 """Mask IPython magics so content becomes parseable Python code.
150 The replacements are returned, along with the transformed code.
152 replacements: List[Replacement] = []
156 # Might have IPython magics, will process below.
159 # Syntax is fine, nothing to mask, early return.
160 return src, replacements
162 from IPython.core.inputtransformer2 import TransformerManager
164 transformer_manager = TransformerManager()
165 transformed = transformer_manager.transform_cell(src)
166 transformed, cell_magic_replacements = replace_cell_magics(transformed)
167 replacements += cell_magic_replacements
168 transformed = transformer_manager.transform_cell(transformed)
169 transformed, magic_replacements = replace_magics(transformed)
170 if len(transformed.splitlines()) != len(src.splitlines()):
171 # Multi-line magic, not supported.
173 replacements += magic_replacements
174 return transformed, replacements
177 def get_token(src: str, magic: str) -> str:
178 """Return randomly generated token to mask IPython magic with.
180 For example, if 'magic' was `%matplotlib inline`, then a possible
181 token to mask it with would be `"43fdd17f7e5ddc83"`. The token
182 will be the same length as the magic, and we make sure that it was
183 not already present anywhere else in the cell.
186 nbytes = max(len(magic) // 2 - 1, 1)
187 token = secrets.token_hex(nbytes)
189 while token in src: # pragma: nocover
190 token = secrets.token_hex(nbytes)
193 raise AssertionError(
194 "INTERNAL ERROR: Black was not able to replace IPython magic. "
195 "Please report a bug on https://github.com/psf/black/issues. "
196 f"The magic might be helpful: {magic}"
198 if len(token) + 2 < len(magic):
203 def replace_cell_magics(src: str) -> Tuple[str, List[Replacement]]:
204 """Replace cell magic with token.
206 Note that 'src' will already have been processed by IPython's
207 TransformerManager().transform_cell.
211 get_ipython().run_cell_magic('t', '-n1', 'ls =!ls\\n')
218 The replacement, along with the transformed code, is returned.
220 replacements: List[Replacement] = []
222 tree = ast.parse(src)
224 cell_magic_finder = CellMagicFinder()
225 cell_magic_finder.visit(tree)
226 if cell_magic_finder.cell_magic is None:
227 return src, replacements
228 if cell_magic_finder.cell_magic.header.split()[0] in NON_PYTHON_CELL_MAGICS:
230 mask = get_token(src, cell_magic_finder.cell_magic.header)
231 replacements.append(Replacement(mask=mask, src=cell_magic_finder.cell_magic.header))
232 return f"{mask}\n{cell_magic_finder.cell_magic.body}", replacements
235 def replace_magics(src: str) -> Tuple[str, List[Replacement]]:
236 """Replace magics within body of cell.
238 Note that 'src' will already have been processed by IPython's
239 TransformerManager().transform_cell.
243 get_ipython().run_line_magic('matplotlib', 'inline')
251 The replacement, along with the transformed code, are returned.
254 magic_finder = MagicFinder()
255 magic_finder.visit(ast.parse(src))
257 for i, line in enumerate(src.splitlines(), start=1):
258 if i in magic_finder.magics:
259 offsets_and_magics = magic_finder.magics[i]
260 if len(offsets_and_magics) != 1: # pragma: nocover
261 raise AssertionError(
262 f"Expecting one magic per line, got: {offsets_and_magics}\n"
263 "Please report a bug on https://github.com/psf/black/issues."
265 col_offset, magic = (
266 offsets_and_magics[0].col_offset,
267 offsets_and_magics[0].magic,
269 mask = get_token(src, magic)
270 replacements.append(Replacement(mask=mask, src=magic))
271 line = line[:col_offset] + mask
272 new_srcs.append(line)
273 return "\n".join(new_srcs), replacements
276 def unmask_cell(src: str, replacements: List[Replacement]) -> str:
277 """Remove replacements from cell.
289 for replacement in replacements:
290 src = src.replace(replacement.mask, replacement.src)
294 def _is_ipython_magic(node: ast.expr) -> TypeGuard[ast.Attribute]:
295 """Check if attribute is IPython magic.
297 Note that the source of the abstract syntax tree
298 will already have been processed by IPython's
299 TransformerManager().transform_cell.
302 isinstance(node, ast.Attribute)
303 and isinstance(node.value, ast.Call)
304 and isinstance(node.value.func, ast.Name)
305 and node.value.func.id == "get_ipython"
309 @dataclasses.dataclass(frozen=True)
315 @dataclasses.dataclass
316 class CellMagicFinder(ast.NodeVisitor):
319 Note that the source of the abstract syntax tree
320 will already have been processed by IPython's
321 TransformerManager().transform_cell.
327 would have been transformed to
329 get_ipython().run_cell_magic('time', '', 'foo()\\n')
331 and we look for instances of the latter.
334 cell_magic: Optional[CellMagic] = None
336 def visit_Expr(self, node: ast.Expr) -> None:
337 """Find cell magic, extract header and body."""
339 isinstance(node.value, ast.Call)
340 and _is_ipython_magic(node.value.func)
341 and node.value.func.attr == "run_cell_magic"
344 for arg in node.value.args:
345 assert isinstance(arg, ast.Str)
347 header = f"%%{args[0]}"
349 header += f" {args[1]}"
350 self.cell_magic = CellMagic(header=header, body=args[2])
351 self.generic_visit(node)
354 @dataclasses.dataclass(frozen=True)
355 class OffsetAndMagic:
360 @dataclasses.dataclass
361 class MagicFinder(ast.NodeVisitor):
362 """Visit cell to look for get_ipython calls.
364 Note that the source of the abstract syntax tree
365 will already have been processed by IPython's
366 TransformerManager().transform_cell.
372 would have been transformed to
374 get_ipython().run_line_magic('matplotlib', 'inline')
376 and we look for instances of the latter (and likewise for other
380 magics: Dict[int, List[OffsetAndMagic]] = dataclasses.field(
381 default_factory=lambda: collections.defaultdict(list)
384 def visit_Assign(self, node: ast.Assign) -> None:
385 """Look for system assign magics.
389 black_version = !black --version
391 would have been transformed to
393 black_version = get_ipython().getoutput('black --version')
395 and we look for instances of the latter.
398 isinstance(node.value, ast.Call)
399 and _is_ipython_magic(node.value.func)
400 and node.value.func.attr == "getoutput"
403 for arg in node.value.args:
404 assert isinstance(arg, ast.Str)
408 self.magics[node.value.lineno].append(
409 OffsetAndMagic(node.value.col_offset, src)
411 self.generic_visit(node)
413 def visit_Expr(self, node: ast.Expr) -> None:
414 """Look for magics in body of cell.
423 would (respectively) get transformed to
425 get_ipython().system('ls')
426 get_ipython().getoutput('ls')
427 get_ipython().run_line_magic('pinfo', 'ls')
428 get_ipython().run_line_magic('pinfo2', 'ls')
430 and we look for instances of any of the latter.
432 if isinstance(node.value, ast.Call) and _is_ipython_magic(node.value.func):
434 for arg in node.value.args:
435 assert isinstance(arg, ast.Str)
438 if node.value.func.attr == "run_line_magic":
439 if args[0] == "pinfo":
441 elif args[0] == "pinfo2":
446 assert src is not None
448 elif node.value.func.attr == "system":
450 elif node.value.func.attr == "getoutput":
453 raise NothingChanged # unsupported magic.
454 self.magics[node.value.lineno].append(
455 OffsetAndMagic(node.value.col_offset, src)
457 self.generic_visit(node)