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(
56 TOKEN_HEX = secrets.token_hex
59 @dataclasses.dataclass(frozen=True)
66 def jupyter_dependencies_are_installed(*, verbose: bool, quiet: bool) -> bool:
68 import IPython # noqa:F401
69 import tokenize_rt # noqa:F401
70 except ModuleNotFoundError:
71 if verbose or not quiet:
73 "Skipping .ipynb files as Jupyter dependencies are not installed.\n"
74 "You can fix this by running ``pip install black[jupyter]``"
82 def remove_trailing_semicolon(src: str) -> Tuple[str, bool]:
83 """Remove trailing semicolon from Jupyter notebook cell.
87 fig, ax = plt.subplots()
88 ax.plot(x_data, y_data); # plot data
92 fig, ax = plt.subplots()
93 ax.plot(x_data, y_data) # plot data
95 Mirrors the logic in `quiet` from `IPython.core.displayhook`, but uses
96 ``tokenize_rt`` so that round-tripping works fine.
98 from tokenize_rt import (
104 tokens = src_to_tokens(src)
105 trailing_semicolon = False
106 for idx, token in reversed_enumerate(tokens):
107 if token.name in TOKENS_TO_IGNORE:
109 if token.name == "OP" and token.src == ";":
111 trailing_semicolon = True
113 if not trailing_semicolon:
115 return tokens_to_src(tokens), True
118 def put_trailing_semicolon_back(src: str, has_trailing_semicolon: bool) -> str:
119 """Put trailing semicolon back if cell originally had it.
121 Mirrors the logic in `quiet` from `IPython.core.displayhook`, but uses
122 ``tokenize_rt`` so that round-tripping works fine.
124 if not has_trailing_semicolon:
126 from tokenize_rt import src_to_tokens, tokens_to_src, reversed_enumerate
128 tokens = src_to_tokens(src)
129 for idx, token in reversed_enumerate(tokens):
130 if token.name in TOKENS_TO_IGNORE:
132 tokens[idx] = token._replace(src=token.src + ";")
134 else: # pragma: nocover
135 raise AssertionError(
136 "INTERNAL ERROR: Was not able to reinstate trailing semicolon. "
137 "Please report a bug on https://github.com/psf/black/issues. "
139 return str(tokens_to_src(tokens))
142 def mask_cell(src: str) -> Tuple[str, List[Replacement]]:
143 """Mask IPython magics so content becomes parseable Python code.
155 The replacements are returned, along with the transformed code.
157 replacements: List[Replacement] = []
161 # Might have IPython magics, will process below.
164 # Syntax is fine, nothing to mask, early return.
165 return src, replacements
167 from IPython.core.inputtransformer2 import TransformerManager
169 transformer_manager = TransformerManager()
170 transformed = transformer_manager.transform_cell(src)
171 transformed, cell_magic_replacements = replace_cell_magics(transformed)
172 replacements += cell_magic_replacements
173 transformed = transformer_manager.transform_cell(transformed)
174 transformed, magic_replacements = replace_magics(transformed)
175 if len(transformed.splitlines()) != len(src.splitlines()):
176 # Multi-line magic, not supported.
178 replacements += magic_replacements
179 return transformed, replacements
182 def get_token(src: str, magic: str) -> str:
183 """Return randomly generated token to mask IPython magic with.
185 For example, if 'magic' was `%matplotlib inline`, then a possible
186 token to mask it with would be `"43fdd17f7e5ddc83"`. The token
187 will be the same length as the magic, and we make sure that it was
188 not already present anywhere else in the cell.
191 nbytes = max(len(magic) // 2 - 1, 1)
192 token = TOKEN_HEX(nbytes)
195 token = TOKEN_HEX(nbytes)
198 raise AssertionError(
199 "INTERNAL ERROR: Black was not able to replace IPython magic. "
200 "Please report a bug on https://github.com/psf/black/issues. "
201 f"The magic might be helpful: {magic}"
203 if len(token) + 2 < len(magic):
208 def replace_cell_magics(src: str) -> Tuple[str, List[Replacement]]:
209 """Replace cell magic with token.
211 Note that 'src' will already have been processed by IPython's
212 TransformerManager().transform_cell.
216 get_ipython().run_cell_magic('t', '-n1', 'ls =!ls\\n')
223 The replacement, along with the transformed code, is returned.
225 replacements: List[Replacement] = []
227 tree = ast.parse(src)
229 cell_magic_finder = CellMagicFinder()
230 cell_magic_finder.visit(tree)
231 if cell_magic_finder.cell_magic is None:
232 return src, replacements
233 if cell_magic_finder.cell_magic.name in NON_PYTHON_CELL_MAGICS:
235 header = cell_magic_finder.cell_magic.header
236 mask = get_token(src, header)
237 replacements.append(Replacement(mask=mask, src=header))
238 return f"{mask}\n{cell_magic_finder.cell_magic.body}", replacements
241 def replace_magics(src: str) -> Tuple[str, List[Replacement]]:
242 """Replace magics within body of cell.
244 Note that 'src' will already have been processed by IPython's
245 TransformerManager().transform_cell.
249 get_ipython().run_line_magic('matplotlib', 'inline')
257 The replacement, along with the transformed code, are returned.
260 magic_finder = MagicFinder()
261 magic_finder.visit(ast.parse(src))
263 for i, line in enumerate(src.splitlines(), start=1):
264 if i in magic_finder.magics:
265 offsets_and_magics = magic_finder.magics[i]
266 if len(offsets_and_magics) != 1: # pragma: nocover
267 raise AssertionError(
268 f"Expecting one magic per line, got: {offsets_and_magics}\n"
269 "Please report a bug on https://github.com/psf/black/issues."
271 col_offset, magic = (
272 offsets_and_magics[0].col_offset,
273 offsets_and_magics[0].magic,
275 mask = get_token(src, magic)
276 replacements.append(Replacement(mask=mask, src=magic))
277 line = line[:col_offset] + mask
278 new_srcs.append(line)
279 return "\n".join(new_srcs), replacements
282 def unmask_cell(src: str, replacements: List[Replacement]) -> str:
283 """Remove replacements from cell.
295 for replacement in replacements:
296 src = src.replace(replacement.mask, replacement.src)
300 def _is_ipython_magic(node: ast.expr) -> TypeGuard[ast.Attribute]:
301 """Check if attribute is IPython magic.
303 Note that the source of the abstract syntax tree
304 will already have been processed by IPython's
305 TransformerManager().transform_cell.
308 isinstance(node, ast.Attribute)
309 and isinstance(node.value, ast.Call)
310 and isinstance(node.value.func, ast.Name)
311 and node.value.func.id == "get_ipython"
315 def _get_str_args(args: List[ast.expr]) -> List[str]:
318 assert isinstance(arg, ast.Str)
319 str_args.append(arg.s)
323 @dataclasses.dataclass(frozen=True)
326 params: Optional[str]
330 def header(self) -> str:
332 return f"%%{self.name} {self.params}"
333 return f"%%{self.name}"
336 @dataclasses.dataclass
337 class CellMagicFinder(ast.NodeVisitor):
340 Note that the source of the abstract syntax tree
341 will already have been processed by IPython's
342 TransformerManager().transform_cell.
348 would have been transformed to
350 get_ipython().run_cell_magic('time', '', 'foo()\\n')
352 and we look for instances of the latter.
355 cell_magic: Optional[CellMagic] = None
357 def visit_Expr(self, node: ast.Expr) -> None:
358 """Find cell magic, extract header and body."""
360 isinstance(node.value, ast.Call)
361 and _is_ipython_magic(node.value.func)
362 and node.value.func.attr == "run_cell_magic"
364 args = _get_str_args(node.value.args)
365 self.cell_magic = CellMagic(name=args[0], params=args[1], body=args[2])
366 self.generic_visit(node)
369 @dataclasses.dataclass(frozen=True)
370 class OffsetAndMagic:
375 @dataclasses.dataclass
376 class MagicFinder(ast.NodeVisitor):
377 """Visit cell to look for get_ipython calls.
379 Note that the source of the abstract syntax tree
380 will already have been processed by IPython's
381 TransformerManager().transform_cell.
387 would have been transformed to
389 get_ipython().run_line_magic('matplotlib', 'inline')
391 and we look for instances of the latter (and likewise for other
395 magics: Dict[int, List[OffsetAndMagic]] = dataclasses.field(
396 default_factory=lambda: collections.defaultdict(list)
399 def visit_Assign(self, node: ast.Assign) -> None:
400 """Look for system assign magics.
404 black_version = !black --version
406 would have been transformed to
408 black_version = get_ipython().getoutput('black --version')
410 and we look for instances of the latter.
413 isinstance(node.value, ast.Call)
414 and _is_ipython_magic(node.value.func)
415 and node.value.func.attr == "getoutput"
417 (arg,) = _get_str_args(node.value.args)
419 self.magics[node.value.lineno].append(
420 OffsetAndMagic(node.value.col_offset, src)
422 self.generic_visit(node)
424 def visit_Expr(self, node: ast.Expr) -> None:
425 """Look for magics in body of cell.
434 would (respectively) get transformed to
436 get_ipython().system('ls')
437 get_ipython().getoutput('ls')
438 get_ipython().run_line_magic('pinfo', 'ls')
439 get_ipython().run_line_magic('pinfo2', 'ls')
441 and we look for instances of any of the latter.
443 if isinstance(node.value, ast.Call) and _is_ipython_magic(node.value.func):
444 args = _get_str_args(node.value.args)
445 if node.value.func.attr == "run_line_magic":
446 if args[0] == "pinfo":
448 elif args[0] == "pinfo2":
453 assert src is not None
455 elif node.value.func.attr == "system":
457 elif node.value.func.attr == "getoutput":
460 raise NothingChanged # unsupported magic.
461 self.magics[node.value.lineno].append(
462 OffsetAndMagic(node.value.col_offset, src)
464 self.generic_visit(node)