Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions IPython/core/magic.py
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,47 @@ def __init__(

self._in_call = False

def _target(self) -> Any:
"""Resolve the aliased magic, keeping the lookup at call time."""
target = self.shell.find_magic(self.magic_name, self.magic_kind) # type: ignore[no-untyped-call]
seen = {id(self)}
while isinstance(target, MagicAlias):
if id(target) in seen:
return None
seen.add(id(target))
target = target.shell.find_magic( # type: ignore[no-untyped-call]
target.magic_name, target.magic_kind
)
return target

@property
def _ipython_magic_no_var_expand(self) -> bool:
"""Propagate the ``no_var_expand`` opt-out flag of the aliased magic."""
target = self._target()
return (
getattr(target, MAGIC_NO_VAR_EXPAND_ATTR, False)
if target is not None
else False
)

@property
def needs_local_scope(self) -> bool:
"""Propagate the ``needs_local_scope`` flag of the aliased magic."""
target = self._target()
return (
getattr(target, "needs_local_scope", False) if target is not None else False
)

@property
def _ipython_magic_output_can_be_silenced(self) -> bool:
"""Propagate the ``output_can_be_silenced`` flag of the aliased magic."""
target = self._target()
return (
getattr(target, MAGIC_OUTPUT_CAN_BE_SILENCED, False)
if target is not None
else False
)

def __call__(self, *args: Any, **kwargs: Any) -> Any:
"""Call the magic alias."""
fn = self.shell.find_magic(self.magic_name, self.magic_kind) # type: ignore[no-untyped-call]
Expand Down
59 changes: 59 additions & 0 deletions tests/test_magic.py
Original file line number Diff line number Diff line change
Expand Up @@ -1708,6 +1708,65 @@ def test_alias_magic():
assert "history_alias" in mm.magics["line"]


def test_alias_magic_no_var_expand():
"""Alias of a ``no_var_expand`` magic must not expand variables (GH #13064)."""
_ip.run_line_magic("alias_magic", "--line time_alias_no_expand time")
_ip.user_ns["a"] = 5
_ip.user_ns["b"] = []
_ip.run_line_magic("time_alias_no_expand", 'b.append("{a}")')
assert _ip.user_ns["b"] == ["{a}"]


def test_alias_magic_cell_no_var_expand():
"""Same as above for the cell-magic path (GH #13064).

The aliased ``%%timeit`` runs the rest of the first line as setup code,
so without flag propagation ``{a}`` is expanded from user_ns beforehand.
"""
_ip.run_line_magic("alias_magic", "--cell time_cell_alias_no_expand timeit")
_ip.user_ns["a"] = 5
_ip.user_ns["b"] = []
_ip.run_cell_magic("time_cell_alias_no_expand", '-n1 -r1 b.append("{a}")', "pass")
assert _ip.user_ns["b"] == ["{a}"]


def test_alias_magic_needs_local_scope():
"""Alias must propagate ``needs_local_scope`` of the target (GH #13064)."""
_ip.run_line_magic("alias_magic", "--line time_alias_locals time")
_ip.user_ns["results"] = []

def f():
x = 42
_ip.run_line_magic("time_alias_locals", "results.append(x)")

f()
assert _ip.user_ns["results"] == [42]


def test_alias_magic_fstring_loop():
"""Exact repro from GH #13064: stale ``i`` from user_ns must not leak
into f-strings run through an alias of a ``no_var_expand`` magic."""
_ip.run_line_magic("alias_magic", "--line t time")
# Stale leftover from a previous loop, as in the issue.
_ip.user_ns["i"] = 2
for i in range(3):
with tt.AssertPrints(f"{i} {i}"):
_ip.run_line_magic("t", 'print(i, f"{i}")')


def test_alias_magic_flag_lookup_on_self_loop():
"""Flag lookup on a self-referencing alias must not recurse forever.

The alias cycle guard lives in ``MagicAlias.__call__``, but the shell
reads magic flags before the call — resolving flags through a cyclic
alias chain must terminate so the original ``UsageError`` is raised.
"""
_ip.run_line_magic("alias_magic", "--line t_loop_cycle time")
_ip.run_line_magic("alias_magic", "--line t_loop_cycle t_loop_cycle")
with pytest.raises(UsageError, match="Infinite recursion"):
_ip.run_line_magic("t_loop_cycle", "pass")


def test_save():
"""Test %save."""
ip = get_ipython()
Expand Down
Loading