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."""
8 from functools import lru_cache
9 from typing import Dict, List, Optional, Tuple
11 if sys.version_info >= (3, 10):
12 from typing import TypeGuard
14 from typing_extensions import TypeGuard
16 from black.output import out
17 from black.report import NothingChanged
19 TRANSFORMED_MAGICS = frozenset(
21 "get_ipython().run_cell_magic",
22 "get_ipython().system",
23 "get_ipython().getoutput",
24 "get_ipython().run_line_magic",
27 TOKENS_TO_IGNORE = frozenset(
38 PYTHON_CELL_MAGICS = frozenset(
49 TOKEN_HEX = secrets.token_hex
52 @dataclasses.dataclass(frozen=True)
59 def jupyter_dependencies_are_installed(*, verbose: bool, quiet: bool) -> bool:
62 # tokenize_rt is less commonly installed than IPython
63 # and IPython is expensive to import
64 import tokenize_rt # noqa:F401
65 import IPython # noqa:F401
68 except ModuleNotFoundError:
69 if verbose or not quiet:
71 "Skipping .ipynb files as Jupyter dependencies are not installed.\n"
72 'You can fix this by running ``pip install "black[jupyter]"``'
80 def remove_trailing_semicolon(src: str) -> Tuple[str, bool]:
81 """Remove trailing semicolon from Jupyter notebook cell.
85 fig, ax = plt.subplots()
86 ax.plot(x_data, y_data); # plot data
90 fig, ax = plt.subplots()
91 ax.plot(x_data, y_data) # plot data
93 Mirrors the logic in `quiet` from `IPython.core.displayhook`, but uses
94 ``tokenize_rt`` so that round-tripping works fine.
96 from tokenize_rt import reversed_enumerate, src_to_tokens, tokens_to_src
98 tokens = src_to_tokens(src)
99 trailing_semicolon = False
100 for idx, token in reversed_enumerate(tokens):
101 if token.name in TOKENS_TO_IGNORE:
103 if token.name == "OP" and token.src == ";":
105 trailing_semicolon = True
107 if not trailing_semicolon:
109 return tokens_to_src(tokens), True
112 def put_trailing_semicolon_back(src: str, has_trailing_semicolon: bool) -> str:
113 """Put trailing semicolon back if cell originally had it.
115 Mirrors the logic in `quiet` from `IPython.core.displayhook`, but uses
116 ``tokenize_rt`` so that round-tripping works fine.
118 if not has_trailing_semicolon:
120 from tokenize_rt import reversed_enumerate, src_to_tokens, tokens_to_src
122 tokens = src_to_tokens(src)
123 for idx, token in reversed_enumerate(tokens):
124 if token.name in TOKENS_TO_IGNORE:
126 tokens[idx] = token._replace(src=token.src + ";")
128 else: # pragma: nocover
129 raise AssertionError(
130 "INTERNAL ERROR: Was not able to reinstate trailing semicolon. "
131 "Please report a bug on https://github.com/psf/black/issues. "
133 return str(tokens_to_src(tokens))
136 def mask_cell(src: str) -> Tuple[str, List[Replacement]]:
137 """Mask IPython magics so content becomes parseable Python code.
149 The replacements are returned, along with the transformed code.
151 replacements: List[Replacement] = []
155 # Might have IPython magics, will process below.
158 # Syntax is fine, nothing to mask, early return.
159 return src, replacements
161 from IPython.core.inputtransformer2 import TransformerManager
163 transformer_manager = TransformerManager()
164 transformed = transformer_manager.transform_cell(src)
165 transformed, cell_magic_replacements = replace_cell_magics(transformed)
166 replacements += cell_magic_replacements
167 transformed = transformer_manager.transform_cell(transformed)
168 transformed, magic_replacements = replace_magics(transformed)
169 if len(transformed.splitlines()) != len(src.splitlines()):
170 # Multi-line magic, not supported.
172 replacements += magic_replacements
173 return transformed, replacements
176 def get_token(src: str, magic: str) -> str:
177 """Return randomly generated token to mask IPython magic with.
179 For example, if 'magic' was `%matplotlib inline`, then a possible
180 token to mask it with would be `"43fdd17f7e5ddc83"`. The token
181 will be the same length as the magic, and we make sure that it was
182 not already present anywhere else in the cell.
185 nbytes = max(len(magic) // 2 - 1, 1)
186 token = TOKEN_HEX(nbytes)
189 token = TOKEN_HEX(nbytes)
192 raise AssertionError(
193 "INTERNAL ERROR: Black was not able to replace IPython magic. "
194 "Please report a bug on https://github.com/psf/black/issues. "
195 f"The magic might be helpful: {magic}"
197 if len(token) + 2 < len(magic):
202 def replace_cell_magics(src: str) -> Tuple[str, List[Replacement]]:
203 """Replace cell magic with token.
205 Note that 'src' will already have been processed by IPython's
206 TransformerManager().transform_cell.
210 get_ipython().run_cell_magic('t', '-n1', 'ls =!ls\\n')
217 The replacement, along with the transformed code, is returned.
219 replacements: List[Replacement] = []
221 tree = ast.parse(src)
223 cell_magic_finder = CellMagicFinder()
224 cell_magic_finder.visit(tree)
225 if cell_magic_finder.cell_magic is None:
226 return src, replacements
227 header = cell_magic_finder.cell_magic.header
228 mask = get_token(src, header)
229 replacements.append(Replacement(mask=mask, src=header))
230 return f"{mask}\n{cell_magic_finder.cell_magic.body}", replacements
233 def replace_magics(src: str) -> Tuple[str, List[Replacement]]:
234 """Replace magics within body of cell.
236 Note that 'src' will already have been processed by IPython's
237 TransformerManager().transform_cell.
241 get_ipython().run_line_magic('matplotlib', 'inline')
249 The replacement, along with the transformed code, are returned.
252 magic_finder = MagicFinder()
253 magic_finder.visit(ast.parse(src))
255 for i, line in enumerate(src.splitlines(), start=1):
256 if i in magic_finder.magics:
257 offsets_and_magics = magic_finder.magics[i]
258 if len(offsets_and_magics) != 1: # pragma: nocover
259 raise AssertionError(
260 f"Expecting one magic per line, got: {offsets_and_magics}\n"
261 "Please report a bug on https://github.com/psf/black/issues."
263 col_offset, magic = (
264 offsets_and_magics[0].col_offset,
265 offsets_and_magics[0].magic,
267 mask = get_token(src, magic)
268 replacements.append(Replacement(mask=mask, src=magic))
269 line = line[:col_offset] + mask
270 new_srcs.append(line)
271 return "\n".join(new_srcs), replacements
274 def unmask_cell(src: str, replacements: List[Replacement]) -> str:
275 """Remove replacements from cell.
287 for replacement in replacements:
288 src = src.replace(replacement.mask, replacement.src)
292 def _is_ipython_magic(node: ast.expr) -> TypeGuard[ast.Attribute]:
293 """Check if attribute is IPython magic.
295 Note that the source of the abstract syntax tree
296 will already have been processed by IPython's
297 TransformerManager().transform_cell.
300 isinstance(node, ast.Attribute)
301 and isinstance(node.value, ast.Call)
302 and isinstance(node.value.func, ast.Name)
303 and node.value.func.id == "get_ipython"
307 def _get_str_args(args: List[ast.expr]) -> List[str]:
310 assert isinstance(arg, ast.Str)
311 str_args.append(arg.s)
315 @dataclasses.dataclass(frozen=True)
318 params: Optional[str]
322 def header(self) -> str:
324 return f"%%{self.name} {self.params}"
325 return f"%%{self.name}"
328 # ast.NodeVisitor + dataclass = breakage under mypyc.
329 class CellMagicFinder(ast.NodeVisitor):
332 Note that the source of the abstract syntax tree
333 will already have been processed by IPython's
334 TransformerManager().transform_cell.
341 would have been transformed to
343 get_ipython().run_cell_magic('time', '', 'foo()\\n')
345 and we look for instances of the latter.
348 def __init__(self, cell_magic: Optional[CellMagic] = None) -> None:
349 self.cell_magic = cell_magic
351 def visit_Expr(self, node: ast.Expr) -> None:
352 """Find cell magic, extract header and body."""
354 isinstance(node.value, ast.Call)
355 and _is_ipython_magic(node.value.func)
356 and node.value.func.attr == "run_cell_magic"
358 args = _get_str_args(node.value.args)
359 self.cell_magic = CellMagic(name=args[0], params=args[1], body=args[2])
360 self.generic_visit(node)
363 @dataclasses.dataclass(frozen=True)
364 class OffsetAndMagic:
369 # Unsurprisingly, subclassing ast.NodeVisitor means we can't use dataclasses here
370 # as mypyc will generate broken code.
371 class MagicFinder(ast.NodeVisitor):
372 """Visit cell to look for get_ipython calls.
374 Note that the source of the abstract syntax tree
375 will already have been processed by IPython's
376 TransformerManager().transform_cell.
382 would have been transformed to
384 get_ipython().run_line_magic('matplotlib', 'inline')
386 and we look for instances of the latter (and likewise for other
390 def __init__(self) -> None:
391 self.magics: Dict[int, List[OffsetAndMagic]] = collections.defaultdict(list)
393 def visit_Assign(self, node: ast.Assign) -> None:
394 """Look for system assign magics.
398 black_version = !black --version
401 would have been (respectively) transformed to
403 black_version = get_ipython().getoutput('black --version')
404 env = get_ipython().run_line_magic('env', 'var')
406 and we look for instances of any of the latter.
408 if isinstance(node.value, ast.Call) and _is_ipython_magic(node.value.func):
409 args = _get_str_args(node.value.args)
410 if node.value.func.attr == "getoutput":
412 elif node.value.func.attr == "run_line_magic":
417 raise AssertionError(
418 f"Unexpected IPython magic {node.value.func.attr!r} found. "
419 "Please report a bug on https://github.com/psf/black/issues."
421 self.magics[node.value.lineno].append(
422 OffsetAndMagic(node.value.col_offset, src)
424 self.generic_visit(node)
426 def visit_Expr(self, node: ast.Expr) -> None:
427 """Look for magics in body of cell.
436 would (respectively) get transformed to
438 get_ipython().system('ls')
439 get_ipython().getoutput('ls')
440 get_ipython().run_line_magic('pinfo', 'ls')
441 get_ipython().run_line_magic('pinfo2', 'ls')
443 and we look for instances of any of the latter.
445 if isinstance(node.value, ast.Call) and _is_ipython_magic(node.value.func):
446 args = _get_str_args(node.value.args)
447 if node.value.func.attr == "run_line_magic":
448 if args[0] == "pinfo":
450 elif args[0] == "pinfo2":
456 elif node.value.func.attr == "system":
458 elif node.value.func.attr == "getoutput":
461 raise NothingChanged # unsupported magic.
462 self.magics[node.value.lineno].append(
463 OffsetAndMagic(node.value.col_offset, src)
465 self.generic_visit(node)