Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 4937fe6

Browse files
Fix some shenanigans with the cache file and IPython (psf#5038)
1 parent 2e641d1 commit 4937fe6

5 files changed

Lines changed: 54 additions & 10 deletions

File tree

CHANGES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313

1414
<!-- Changes that affect Black's stable style -->
1515

16+
- Prevent Jupyter notebook magic masking collisions from corrupting cells by using
17+
exact-length placeholders for short magics and aborting if a placeholder can no longer
18+
be unmasked safely (#5038)
19+
1620
### Preview style
1721

1822
<!-- Changes that affect Black's preview style -->
@@ -21,6 +25,9 @@
2125

2226
<!-- Changes to how Black can be configured -->
2327

28+
- Always hash cache filename components derived from `--python-cell-magics` so custom
29+
magic names cannot affect cache paths (#5038)
30+
2431
### Packaging
2532

2633
<!-- Changes to how Black is packaged, such as dependency requirements -->

src/black/handle_ipynb_magics.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import dataclasses
66
import re
77
import secrets
8+
import string
9+
from collections.abc import Collection
810
from functools import lru_cache
911
from importlib.util import find_spec
1012
from typing import TypeGuard
@@ -188,6 +190,13 @@ def mask_cell(src: str) -> tuple[str, list[Replacement]]:
188190
def create_token(n_chars: int) -> str:
189191
"""Create a randomly generated token that is n_chars characters long."""
190192
assert n_chars > 0
193+
if n_chars == 1:
194+
return secrets.choice(string.ascii_letters)
195+
if n_chars < 4:
196+
return "_" + "".join(
197+
secrets.choice(string.ascii_letters + string.digits + "_")
198+
for _ in range(n_chars - 1)
199+
)
191200
n_bytes = max(n_chars // 2 - 1, 1)
192201
token = secrets.token_hex(n_bytes)
193202
if len(token) + 3 > n_chars:
@@ -197,7 +206,7 @@ def create_token(n_chars: int) -> str:
197206
return f'b"{token}"'
198207

199208

200-
def get_token(src: str, magic: str) -> str:
209+
def get_token(src: str, magic: str, existing_tokens: Collection[str] = ()) -> str:
201210
"""Return randomly generated token to mask IPython magic with.
202211
203212
For example, if 'magic' was `%matplotlib inline`, then a possible
@@ -209,7 +218,7 @@ def get_token(src: str, magic: str) -> str:
209218
n_chars = len(magic)
210219
token = create_token(n_chars)
211220
counter = 0
212-
while token in src:
221+
while token in src or token in existing_tokens:
213222
token = create_token(n_chars)
214223
counter += 1
215224
if counter > 100:
@@ -271,6 +280,7 @@ def replace_magics(src: str) -> tuple[str, list[Replacement]]:
271280
The replacement, along with the transformed code, are returned.
272281
"""
273282
replacements = []
283+
existing_tokens: set[str] = set()
274284
magic_finder = MagicFinder()
275285
magic_finder.visit(ast.parse(src))
276286
new_srcs = []
@@ -286,8 +296,9 @@ def replace_magics(src: str) -> tuple[str, list[Replacement]]:
286296
offsets_and_magics[0].col_offset,
287297
offsets_and_magics[0].magic,
288298
)
289-
mask = get_token(src, magic)
299+
mask = get_token(src, magic, existing_tokens)
290300
replacements.append(Replacement(mask=mask, src=magic))
301+
existing_tokens.add(mask)
291302
line = line[:col_offset] + mask
292303
new_srcs.append(line)
293304
return "\n".join(new_srcs), replacements
@@ -307,7 +318,9 @@ def unmask_cell(src: str, replacements: list[Replacement]) -> str:
307318
foo = bar
308319
"""
309320
for replacement in replacements:
310-
src = src.replace(replacement.mask, replacement.src)
321+
if src.count(replacement.mask) != 1:
322+
raise NothingChanged
323+
src = src.replace(replacement.mask, replacement.src, 1)
311324
return src
312325

313326

src/black/mode.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,9 @@ def get_cache_key(self) -> str:
288288
+ "@"
289289
+ ",".join(sorted(self.python_cell_magics))
290290
)
291-
if len(features_and_magics) > _MAX_CACHE_KEY_PART_LENGTH:
292-
features_and_magics = sha256(features_and_magics.encode()).hexdigest()[
293-
:_MAX_CACHE_KEY_PART_LENGTH
294-
]
291+
features_and_magics = sha256(features_and_magics.encode()).hexdigest()[
292+
:_MAX_CACHE_KEY_PART_LENGTH
293+
]
295294
parts = [
296295
version_str,
297296
str(self.line_length),

tests/test_black.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2166,6 +2166,15 @@ def test_cache_file_length(self) -> None:
21662166
# doesn't get too crazy.
21672167
assert len(cache_file.name) <= 96
21682168

2169+
def test_cache_file_path_ignores_python_cell_magic_separators(self) -> None:
2170+
mode = replace(DEFAULT_MODE, python_cell_magics={"../../../tmp/pwned"})
2171+
with cache_dir() as workspace:
2172+
cache_file = get_cache_file(mode)
2173+
assert cache_file.parent == workspace
2174+
assert "/" not in cache_file.name
2175+
assert ".." not in cache_file.name
2176+
assert "../../../tmp/pwned" not in mode.get_cache_key()
2177+
21692178
def test_cache_broken_file(self) -> None:
21702179
mode = DEFAULT_MODE
21712180
with cache_dir() as workspace:

tests/test_ipynb.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
from dataclasses import replace
77

88
import pytest
9-
from _pytest.monkeypatch import MonkeyPatch
109
from click.testing import CliRunner
10+
from pytest import MonkeyPatch
1111

1212
from black import (
1313
Mode,
@@ -17,7 +17,12 @@
1717
format_file_in_place,
1818
main,
1919
)
20-
from black.handle_ipynb_magics import jupyter_dependencies_are_installed
20+
from black.handle_ipynb_magics import (
21+
Replacement,
22+
create_token,
23+
jupyter_dependencies_are_installed,
24+
unmask_cell,
25+
)
2126
from tests.util import DATA_DIR, get_case_path, read_jupyter_notebook
2227

2328
with contextlib.suppress(ModuleNotFoundError):
@@ -39,6 +44,17 @@ def test_noop() -> None:
3944
format_cell(src, fast=True, mode=JUPYTER_MODE)
4045

4146

47+
@pytest.mark.parametrize("n_chars", [1, 2, 3, 4, 5, 17])
48+
def test_create_token_uses_requested_length(n_chars: int) -> None:
49+
assert len(create_token(n_chars)) == n_chars
50+
51+
52+
def test_unmask_cell_raises_when_token_is_not_unique() -> None:
53+
replacement = Replacement(mask='b"dead"', src="%time")
54+
with pytest.raises(NothingChanged):
55+
unmask_cell(f"{replacement.mask}\nvalue = {replacement.mask}", [replacement])
56+
57+
4258
@pytest.mark.parametrize("fast", [True, False])
4359
def test_trailing_semicolon(fast: bool) -> None:
4460
src = 'foo = "a" ;'

0 commit comments

Comments
 (0)