| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
…isplaced declarations When one name is lazily declared with a different provider per kind, load_lazy() always preferred the line placeholder, so %%name? loaded the wrong provider, left a stale cell placeholder, and failed. On retry the stale placeholder was dropped, which made the outcome depend on registration order: line-then-cell worked on second attempt, cell-then-line never worked. Pass the requested kind from find() into load_lazy() so each kind loads its own provider on first lookup. Also, when a newer declaration displaces an older one for a kind but never delivers it, restore the previous declaration for one retry instead of dropping the name, so e.g. a line-only override of time keeps the builtin cell time working. Fixes ipython#15383
There was a problem hiding this comment.
Thanks @bunnysayzz for working on this. Left few comments.
Sorry, something went wrong.
| # A newer declaration displaces an older one for this kind; | ||
| # remember the old spec so it can be restored if the new | ||
| # declaration never delivers the magic. | ||
| self._lazy_fallbacks[(kind, name)] = existing.spec |
There was a problem hiding this comment.
One thing though: _lazy_fallbacks only remembers one level, so it breaks as soon as a name gets declared twice:
@magics_class
class MyMagics(Magics):
@line_magic
def time(self, line):
"""My own %time."""
@magics_class
class MyAnotherMagics(Magics):
@line_magic
def time(self, line):
"""MyAnother own %time."""
sys.modules["provider"] = provider = types.ModuleType("provider")
provider.MyMagics, provider.MyAnotherMagics = MyMagics, MyAnotherMagics
ip.magics_manager.register_lazy("time", "provider:MyMagics")
ip.magics_manager.register_lazy("time", "provider:MyAnotherMagics")
%time? # MyAnother own %time. ✅
%%time? # nothing — built-in %%time is gone ❌The second register_lazy overwrites _lazy_fallbacks[("cell", "time")] with provider:MyMagics, so the built-in's spec is lost. find then restores MyMagics, which is also line-only, doesn't deliver a cell magic either, and hits the del, so %%time is destroyed for the session. Same symptom as the original case C in the issue, just one declaration later.
Sorry, something went wrong.
| # wholesale, so prefer the spec the placeholder carries. | ||
| fn = self.magics["line"].get(magic_name) or self.magics["cell"].get(magic_name) | ||
| if magic_kind is not None: | ||
| fn = self.magics[magic_kind].get(magic_name) |
There was a problem hiding this comment.
Good catch here!
Sorry, something went wrong.
| # Declared but not delivered. If an older declaration for | ||
| # this kind was displaced, restore it and give it one | ||
| # chance rather than dropping the name entirely. | ||
| fallback = self._lazy_fallbacks.pop((magic_kind, magic_name), None) | ||
| if fallback is not None and fallback != fn.spec: | ||
| self.magics[magic_kind][magic_name] = LazyMagic( | ||
| self, fallback, magic_kind, magic_name | ||
| ) | ||
| self.load_lazy(magic_name, magic_kind) | ||
| fn = self.magics[magic_kind].get(magic_name) | ||
| if isinstance(fn, LazyMagic): | ||
| # Still nothing delivered; drop the stale placeholder. | ||
| del self.magics[magic_kind][magic_name] | ||
| fn = None |
There was a problem hiding this comment.
Well, storing the displaced entry on the LazyMagic that displaced it instead of in a side table gives you arbitrary depth for free, and it can't get out of sync with the magics table.
Consider this:
# LazyMagic.__init__
self.shadowed = shadowed
# register_lazy
self.magics[kind][name] = LazyMagic(self, fully_qualified_name, kind, name, shadowed=existing)
# find — instead of the single retry + del
while isinstance(fn, LazyMagic):
self.load_lazy(magic_name, magic_kind)
if table.get(magic_name) is not fn:
fn = table.get(magic_name)
continue
fn = fn.shadowed
if fn is None:
del table[magic_name]
else:
table[magic_name] = fn
Sorry, something went wrong.
|
Thanks, both points taken. Reworked per your sketch: the displaced entry now rides on the placeholder itself (shadowed), and find swaps each undispatched placeholder into the table and retries, so the chain unwinds to whatever declaration actually delivers, at any depth. Side table is gone. Added test_lazy_magic_falls_back_through_two_declarations (two newest declarations deliver nothing, oldest resolves) — I verified it fails on the old single-level code and passes now. Full test_magic.py + test_magic_table.py still 150 passed, and the reformatted lines match the formatter. |
Sorry, something went wrong.
|
Good catch, same root cause one layer over. register_lazy overwrites lazy_magics[name] per call, and load_all then fed that single spec through kind-blind load_lazy, so only the line half's class ever imported. Now load_all_lazy_magics walks the placeholders per kind first (extensions still skipped, same as before) and keeps the old loop as fallback. Added test_load_all_lazy_magics_loads_both_kind_halves; verified it plus the other four new tests fail on clean main and pass here. Suite still 151 green. |
Sorry, something went wrong.
| line_spec = f"{mod}:LineHalf" | ||
| cell_spec = f"{mod}:CellHalf" | ||
| try: | ||
| mm.register_lazy("dual", line_spec, "line") # type: ignore[arg-type] |
There was a problem hiding this comment.
| mm.register_lazy("dual", line_spec, "line") # type: ignore[arg-type] | |
| mm.register_lazy("dual", line_spec, "line") |
Sorry, something went wrong.
| else (("cell", cell_spec), ("line", line_spec)) | ||
| ) | ||
| try: | ||
| mm.register_lazy("dual", first[1], first[0]) # type: ignore[arg-type] |
There was a problem hiding this comment.
| mm.register_lazy("dual", first[1], first[0]) # type: ignore[arg-type] | |
| mm.register_lazy("dual", first[1], first[0]) |
Sorry, something went wrong.
| ) | ||
| try: | ||
| mm.register_lazy("dual", first[1], first[0]) # type: ignore[arg-type] | ||
| mm.register_lazy("dual", second[1], second[0]) # type: ignore[arg-type] |
There was a problem hiding this comment.
| mm.register_lazy("dual", second[1], second[0]) # type: ignore[arg-type] | |
| mm.register_lazy("dual", second[1], second[0]) |
Sorry, something went wrong.
| cell_spec = f"{mod}:CellHalf" | ||
| try: | ||
| mm.register_lazy("dual", line_spec, "line") # type: ignore[arg-type] | ||
| mm.register_lazy("dual", cell_spec, "cell") # type: ignore[arg-type] |
There was a problem hiding this comment.
| mm.register_lazy("dual", cell_spec, "cell") # type: ignore[arg-type] | |
| mm.register_lazy("dual", cell_spec, "cell") |
Sorry, something went wrong.
| try: | ||
| mm.register_lazy("dual", line_spec, "line") # type: ignore[arg-type] | ||
| mm.register_lazy("dual", cell_spec, "cell") # type: ignore[arg-type] | ||
| mm.load_all_lazy_magics() |
There was a problem hiding this comment.
Calling load_all_lazy_magics() on the shared session shell may break later tests.
Lets build a standalone MagicsManager(shell=...) for this test instead of using the session ip. There's a manager fixture in test_magic_table.py
Sorry, something went wrong.
| # Load per kind from the placeholders themselves: one name may be | ||
| # declared with a different provider per kind, and ``lazy_magics`` | ||
| # only remembers the last spec per name. Extensions (specs without a | ||
| # ":") are still skipped: importing one can run arbitrary code. | ||
| for kind in magic_kinds: | ||
| for magic_name in list(self.magics[kind]): | ||
| fn = self.magics[kind].get(magic_name) | ||
| if isinstance(fn, LazyMagic) and ":" in fn.spec: | ||
| self.load_lazy(magic_name, kind) |
There was a problem hiding this comment.
One suggestion: the two loops here are deriving "everything still declared lazily" from the two stores inline, and _MagicsRegistry.__missing__ needs the same thing. Worth pulling it into a helper?
def _lazy_declarations(self) -> list[tuple[str, _MagicKind | None, str]]:
"""Every live lazy declaration, as ``(name, kind, spec)``.
The placeholders in :attr:`magics` are the source of truth, because
:attr:`lazy_magics` is keyed by name alone and so keeps only the last
spec for a name declared once per kind. A name declared only through
the trait has no placeholder, and so no known kind.
"""
seen: set[tuple[str, str]] = set()
declarations: list[tuple[str, _MagicKind | None, str]] = []
for kind in magic_kinds:
for name, fn in list(self.magics[kind].items()):
if isinstance(fn, LazyMagic) and (name, fn.spec) not in seen:
seen.add((name, fn.spec))
declarations.append((name, kind, fn.spec))
for name, spec in list(self.lazy_magics.items()):
if (name, spec) not in seen:
seen.add((name, spec))
declarations.append((name, None, spec))
return declarationsThen this method is just a filter:
def load_all_lazy_magics(self) -> None:
for magic_name, magic_kind, spec in self._lazy_declarations():
if ":" in spec:
self.load_lazy(magic_name, magic_kind)__missing__ with the helper:
def __missing__(self, key: s
for magic_name, magic_kind, spec in self._manager._lazy_declarations():
if spec.endswith(":"
self._manager.load_lazy(magic_name, magic_kind)
# A matching spelared once per kind
# has one spec per kind, so keep going until `key` shows up.
if key in self:
break
if key not in self:
raise KeyError(key)
return self[key]
Sorry, something went wrong.
|
Round 2 done, thanks for the detailed review:
All green locally: test_magic.py + test_magic_table.py, 153 passed, 3 skipped. I also re-verified the load_all test still fails if the helper is neutered back to trait-only iteration, so it still guards the original bug. |
Sorry, something went wrong.
| # The table entry this declaration displaced, if any. A name may be | ||
| # declared lazily any number of times; the chain is walked if a | ||
| # newer declaration never delivers the magic. | ||
| self.shadowed = shadowed |
There was a problem hiding this comment.
You likely want to create docstring for __init__ and start documenting the parameter instead of comments.
Sorry, something went wrong.
There was a problem hiding this comment.
And that seem like overkill, if someone called register_magic with the wrong kind, and it does not "deliver" (A lot of that vocabulary seem a lot like opus 5) then I think it is fair to error or do the wrong things than to try to walk to the next declaration (unless I missunderstand)
Sorry, something went wrong.
There was a problem hiding this comment.
I think this solution is for a particular issue that @krassowski described in the issue thread.
@magics_class
class MyMagics(Magics):
@line_magic
def time(self, line):
"""My own %time."""
sys.modules["provider"] = provider = types.ModuleType("provider")
provider.MyMagics = MyMagics
ip = InteractiveShell()
ip.magics_manager.register_lazy("time", "provider:MyMagics")
ip.run_cell("time?") # my own %time
ip.run_cell("%%time?") # expected: IPython's %%time docs
ip.run_cell("%%time\npass") # expected: a wall time reportif someone called register_magic with the wrong kind, and it does not "deliver"
Yes a direct solution would be to just use:
ip.magics_manager.register_lazy("time", "provider:MyMagics", "line")but by default it is "line_cell" and overrides both %time and %%time without checking if it provides both implementation (and I don't think we can validate since the magic is registered lazily and the provider module has not been imported yet), which is causing the above failure.
I think it is fair to error or do the wrong things than to try to walk to the next declaration
I also agree with this, since it is a mistake on the side of the magic registration too. Maybe we could look into improving the error message. I'll see.
Sorry, something went wrong.
|
Thanks for looking, both points taken seriously: Docstring over inline comment: agreed, I'll move the shadowed explanation into an __init__ docstring documenting the parameter. (Also noted on the vocabulary, I'll use plainer words.) Is the walk overkill? Let me make sure the case it covers is worth it before I rip it out. The concrete scenario is the time test: IPython itself declares time lazily for both kinds. A user then registers their own line-only time, which displaces both placeholders. When something looks up %%time, the new provider loads fine but delivers no cell magic. Without the walk, the cell lookup ends with the name resolving to nothing, so %%time breaks entirely as a side effect of overriding %time. The walk restores the previously displaced declaration in that case, so %%time keeps working. If you'd rather that cell lookup raise (or just resolve to whatever the new provider gave) instead of falling back, say the word and I'll simplify it down to that. Your call. |
Sorry, something went wrong.
|
I applied the documentation-only part of your first note in commit fda3b63f1: the LazyMagic.__init__ docstring now documents shadowed, and the old explanatory inline comment is gone. I left the fallback behavior unchanged while waiting for your answer on whether the displaced-entry walk should be removed. The magic test suites remain green: 153 passed, 3 skipped. |
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
Fixes #15383.
When one magic name is lazily declared with a different provider per kind, load_lazy() always preferred the line-table placeholder, so %%name? loaded the wrong provider and left a stale cell placeholder behind. On retry the stale placeholder was dropped, which made the outcome depend on registration order: line-then-cell worked on the second attempt, cell-then-line never worked at all.
This passes the requested kind from find() into load_lazy(), so each kind loads its own provider on the first lookup. It also remembers a displaced lazy declaration per (kind, name): if the newer declaration never delivers the magic, the previous one is restored for one retry instead of the name going unresolvable. That last part covers the time case in the issue, where a line-only override kept working for %time/time? while %%time? and %%time fell back to IPython's own cell magic again.
Tests added in tests/test_magic.py (test_lazy_magic_help_finds_both_kinds_first_try, parametrized over both registration orders, and test_lazy_magic_falls_back_to_previous_declaration). All three fail without the fix and pass with it. Full test_magic.py + test_magic_table.py green (149 passed, 5 skipped); the 5 failures in test_interactiveshell.py/test_prefilter.py are pre-existing environment failures, identical on clean main.
@Darshan808 would appreciate your review when you have a moment.