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 importlib.util import find_spec
10 from typing import Dict, List, Optional, Tuple
12 if sys.version_info >= (3, 10):
13 from typing import TypeGuard
15 from typing_extensions import TypeGuard
17 from black.output import out
18 from black.report import NothingChanged
20 TRANSFORMED_MAGICS = frozenset(
22 "get_ipython().run_cell_magic",
23 "get_ipython().system",
24 "get_ipython().getoutput",
25 "get_ipython().run_line_magic",
28 TOKENS_TO_IGNORE = frozenset(
39 PYTHON_CELL_MAGICS = frozenset(
50 TOKEN_HEX = secrets.token_hex
53 @dataclasses.dataclass(frozen=True)
60 def jupyter_dependencies_are_installed(*, warn: bool) -> bool:
62 find_spec("tokenize_rt") is not None and find_spec("IPython") is not None
64 if not installed and warn:
66 "Skipping .ipynb files as Jupyter dependencies are not installed.\n"
67 'You can fix this by running ``pip install "black[jupyter]"``'
73 def remove_trailing_semicolon(src: str) -> Tuple[str, bool]:
74 """Remove trailing semicolon from Jupyter notebook cell.
78 fig, ax = plt.subplots()
79 ax.plot(x_data, y_data); # plot data
83 fig, ax = plt.subplots()
84 ax.plot(x_data, y_data) # plot data
86 Mirrors the logic in `quiet` from `IPython.core.displayhook`, but uses
87 ``tokenize_rt`` so that round-tripping works fine.
89 from tokenize_rt import reversed_enumerate, src_to_tokens, tokens_to_src
91 tokens = src_to_tokens(src)
92 trailing_semicolon = False
93 for idx, token in reversed_enumerate(tokens):
94 if token.name in TOKENS_TO_IGNORE:
96 if token.name == "OP" and token.src == ";":
98 trailing_semicolon = True
100 if not trailing_semicolon:
102 return tokens_to_src(tokens), True
105 def put_trailing_semicolon_back(src: str, has_trailing_semicolon: bool) -> str:
106 """Put trailing semicolon back if cell originally had it.
108 Mirrors the logic in `quiet` from `IPython.core.displayhook`, but uses
109 ``tokenize_rt`` so that round-tripping works fine.
111 if not has_trailing_semicolon:
113 from tokenize_rt import reversed_enumerate, src_to_tokens, tokens_to_src
115 tokens = src_to_tokens(src)
116 for idx, token in reversed_enumerate(tokens):
117 if token.name in TOKENS_TO_IGNORE:
119 tokens[idx] = token._replace(src=token.src + ";")
121 else: # pragma: nocover
122 raise AssertionError(
123 "INTERNAL ERROR: Was not able to reinstate trailing semicolon. "
124 "Please report a bug on https://github.com/psf/black/issues. "
126 return str(tokens_to_src(tokens))
129 def mask_cell(src: str) -> Tuple[str, List[Replacement]]:
130 """Mask IPython magics so content becomes parseable Python code.
142 The replacements are returned, along with the transformed code.
144 replacements: List[Replacement] = []
148 # Might have IPython magics, will process below.
151 # Syntax is fine, nothing to mask, early return.
152 return src, replacements
154 from IPython.core.inputtransformer2 import TransformerManager
156 transformer_manager = TransformerManager()
157 transformed = transformer_manager.transform_cell(src)
158 transformed, cell_magic_replacements = replace_cell_magics(transformed)
159 replacements += cell_magic_replacements
160 transformed = transformer_manager.transform_cell(transformed)
161 transformed, magic_replacements = replace_magics(transformed)
162 if len(transformed.splitlines()) != len(src.splitlines()):
163 # Multi-line magic, not supported.
165 replacements += magic_replacements
166 return transformed, replacements
169 def get_token(src: str, magic: str) -> str:
170 """Return randomly generated token to mask IPython magic with.
172 For example, if 'magic' was `%matplotlib inline`, then a possible
173 token to mask it with would be `"43fdd17f7e5ddc83"`. The token
174 will be the same length as the magic, and we make sure that it was
175 not already present anywhere else in the cell.
178 nbytes = max(len(magic) // 2 - 1, 1)
179 token = TOKEN_HEX(nbytes)
182 token = TOKEN_HEX(nbytes)
185 raise AssertionError(
186 "INTERNAL ERROR: Black was not able to replace IPython magic. "
187 "Please report a bug on https://github.com/psf/black/issues. "
188 f"The magic might be helpful: {magic}"
190 if len(token) + 2 < len(magic):
195 def replace_cell_magics(src: str) -> Tuple[str, List[Replacement]]:
196 """Replace cell magic with token.
198 Note that 'src' will already have been processed by IPython's
199 TransformerManager().transform_cell.
203 get_ipython().run_cell_magic('t', '-n1', 'ls =!ls\\n')
210 The replacement, along with the transformed code, is returned.
212 replacements: List[Replacement] = []
214 tree = ast.parse(src)
216 cell_magic_finder = CellMagicFinder()
217 cell_magic_finder.visit(tree)
218 if cell_magic_finder.cell_magic is None:
219 return src, replacements
220 header = cell_magic_finder.cell_magic.header
221 mask = get_token(src, header)
222 replacements.append(Replacement(mask=mask, src=header))
223 return f"{mask}\n{cell_magic_finder.cell_magic.body}", replacements
226 def replace_magics(src: str) -> Tuple[str, List[Replacement]]:
227 """Replace magics within body of cell.
229 Note that 'src' will already have been processed by IPython's
230 TransformerManager().transform_cell.
234 get_ipython().run_line_magic('matplotlib', 'inline')
242 The replacement, along with the transformed code, are returned.
245 magic_finder = MagicFinder()
246 magic_finder.visit(ast.parse(src))
248 for i, line in enumerate(src.splitlines(), start=1):
249 if i in magic_finder.magics:
250 offsets_and_magics = magic_finder.magics[i]
251 if len(offsets_and_magics) != 1: # pragma: nocover
252 raise AssertionError(
253 f"Expecting one magic per line, got: {offsets_and_magics}\n"
254 "Please report a bug on https://github.com/psf/black/issues."
256 col_offset, magic = (
257 offsets_and_magics[0].col_offset,
258 offsets_and_magics[0].magic,
260 mask = get_token(src, magic)
261 replacements.append(Replacement(mask=mask, src=magic))
262 line = line[:col_offset] + mask
263 new_srcs.append(line)
264 return "\n".join(new_srcs), replacements
267 def unmask_cell(src: str, replacements: List[Replacement]) -> str:
268 """Remove replacements from cell.
280 for replacement in replacements:
281 src = src.replace(replacement.mask, replacement.src)
285 def _is_ipython_magic(node: ast.expr) -> TypeGuard[ast.Attribute]:
286 """Check if attribute is IPython magic.
288 Note that the source of the abstract syntax tree
289 will already have been processed by IPython's
290 TransformerManager().transform_cell.
293 isinstance(node, ast.Attribute)
294 and isinstance(node.value, ast.Call)
295 and isinstance(node.value.func, ast.Name)
296 and node.value.func.id == "get_ipython"
300 def _get_str_args(args: List[ast.expr]) -> List[str]:
303 assert isinstance(arg, ast.Str)
304 str_args.append(arg.s)
308 @dataclasses.dataclass(frozen=True)
311 params: Optional[str]
315 def header(self) -> str:
317 return f"%%{self.name} {self.params}"
318 return f"%%{self.name}"
321 # ast.NodeVisitor + dataclass = breakage under mypyc.
322 class CellMagicFinder(ast.NodeVisitor):
325 Note that the source of the abstract syntax tree
326 will already have been processed by IPython's
327 TransformerManager().transform_cell.
334 would have been transformed to
336 get_ipython().run_cell_magic('time', '', 'foo()\\n')
338 and we look for instances of the latter.
341 def __init__(self, cell_magic: Optional[CellMagic] = None) -> None:
342 self.cell_magic = cell_magic
344 def visit_Expr(self, node: ast.Expr) -> None:
345 """Find cell magic, extract header and body."""
347 isinstance(node.value, ast.Call)
348 and _is_ipython_magic(node.value.func)
349 and node.value.func.attr == "run_cell_magic"
351 args = _get_str_args(node.value.args)
352 self.cell_magic = CellMagic(name=args[0], params=args[1], body=args[2])
353 self.generic_visit(node)
356 @dataclasses.dataclass(frozen=True)
357 class OffsetAndMagic:
362 # Unsurprisingly, subclassing ast.NodeVisitor means we can't use dataclasses here
363 # as mypyc will generate broken code.
364 class MagicFinder(ast.NodeVisitor):
365 """Visit cell to look for get_ipython calls.
367 Note that the source of the abstract syntax tree
368 will already have been processed by IPython's
369 TransformerManager().transform_cell.
375 would have been transformed to
377 get_ipython().run_line_magic('matplotlib', 'inline')
379 and we look for instances of the latter (and likewise for other
383 def __init__(self) -> None:
384 self.magics: Dict[int, List[OffsetAndMagic]] = collections.defaultdict(list)
386 def visit_Assign(self, node: ast.Assign) -> None:
387 """Look for system assign magics.
391 black_version = !black --version
394 would have been (respectively) transformed to
396 black_version = get_ipython().getoutput('black --version')
397 env = get_ipython().run_line_magic('env', 'var')
399 and we look for instances of any of the latter.
401 if isinstance(node.value, ast.Call) and _is_ipython_magic(node.value.func):
402 args = _get_str_args(node.value.args)
403 if node.value.func.attr == "getoutput":
405 elif node.value.func.attr == "run_line_magic":
410 raise AssertionError(
411 f"Unexpected IPython magic {node.value.func.attr!r} found. "
412 "Please report a bug on https://github.com/psf/black/issues."
414 self.magics[node.value.lineno].append(
415 OffsetAndMagic(node.value.col_offset, src)
417 self.generic_visit(node)
419 def visit_Expr(self, node: ast.Expr) -> None:
420 """Look for magics in body of cell.
429 would (respectively) get transformed to
431 get_ipython().system('ls')
432 get_ipython().getoutput('ls')
433 get_ipython().run_line_magic('pinfo', 'ls')
434 get_ipython().run_line_magic('pinfo2', 'ls')
436 and we look for instances of any of the latter.
438 if isinstance(node.value, ast.Call) and _is_ipython_magic(node.value.func):
439 args = _get_str_args(node.value.args)
440 if node.value.func.attr == "run_line_magic":
441 if args[0] == "pinfo":
443 elif args[0] == "pinfo2":
449 elif node.value.func.attr == "system":
451 elif node.value.func.attr == "getoutput":
454 raise NothingChanged # unsupported magic.
455 self.magics[node.value.lineno].append(
456 OffsetAndMagic(node.value.col_offset, src)
458 self.generic_visit(node)