Repository: ipython/ipython

Issue:
autoreload 3 has the effect of import *
The autoreload extension is almost magical, huge thanks to whoever's worked on it. There is one behavior that I find unexpected and I think may have been unintended.

We have a file reload.py containing:
```
n = 5
```
Then in ipython we do:
```
%load_ext autoreload
%autoreload 3
from reload import n
```
now we edit and save reload.py to be:
```
n = 5
m = 7
```
Now in the ipython repl, m is also defined, even though we just imported n. In the ipython test suite for this functionality in `test_autoload_newly_added_objects`, it demonstrates it with `from module import *`. But even if we didn't `import *` it will effectively always behave that way, as with `autoreload 3`, it sets `autoload_obj` to True, which causes it to pass shell into superreload, where it does `shell.user_ns[name] = new_obj` for every new variable, even if we didn't `import *`, but explicitly only wanted to import `n` as in this example. I'm not sure if this is intended or a bug, but it seems like it has the potential to dangerously clobber existing names in cases where users didn't `import *`


Discussion:
Hey @sczi thanks for catching this. I put up a PR #14872  with a minor modification to `autoreload 3` so that we appropriately deal with the `from X import Y` and `from X import Y as Z` cases -- I agree that it's unlikely this is intended. 



Patch:
diff --git a/IPython/core/interactiveshell.py b/IPython/core/interactiveshell.py
index 504681c3582..ec85e2d3ad9 100644
--- a/IPython/core/interactiveshell.py
+++ b/IPython/core/interactiveshell.py
@@ -252,13 +252,23 @@ class ExecutionInfo:
     Stores information about what is going to happen.
     """
     raw_cell = None
+    transformed_cell = None
     store_history = False
     silent = False
     shell_futures = True
     cell_id = None
 
-    def __init__(self, raw_cell, store_history, silent, shell_futures, cell_id):
+    def __init__(
+        self,
+        raw_cell,
+        store_history,
+        silent,
+        shell_futures,
+        cell_id,
+        transformed_cell=None,
+    ):
         self.raw_cell = raw_cell
+        self.transformed_cell = transformed_cell
         self.store_history = store_history
         self.silent = silent
         self.shell_futures = shell_futures
@@ -269,12 +279,18 @@ def __repr__(self):
         raw_cell = (
             (self.raw_cell[:50] + "..") if len(self.raw_cell) > 50 else self.raw_cell
         )
+        transformed_cell = (
+            (self.transformed_cell[:50] + "..")
+            if self.transformed_cell and len(self.transformed_cell) > 50
+            else self.transformed_cell
+        )
         return (
-            '<%s object at %x, raw_cell="%s" store_history=%s silent=%s shell_futures=%s cell_id=%s>'
+            '<%s object at %x, raw_cell="%s" transformed_cell="%s" store_history=%s silent=%s shell_futures=%s cell_id=%s>'
             % (
                 name,
                 id(self),
                 raw_cell,
+                transformed_cell,
                 self.store_history,
                 self.silent,
                 self.shell_futures,
@@ -3156,14 +3172,18 @@ def _run_cell(
         except BaseException as e:
             try:
                 info = ExecutionInfo(
-                    raw_cell, store_history, silent, shell_futures, cell_id
+                    raw_cell,
+                    store_history,
+                    silent,
+                    shell_futures,
+                    cell_id,
+                    transformed_cell=transformed_cell,
                 )
                 result = ExecutionResult(info)
                 result.error_in_exec = e
                 self.showtraceback(running_compiled_code=True)
             except:
                 pass
-
         return result
 
     def should_run_async(
@@ -3248,7 +3268,14 @@ async def run_cell_async(
 
         .. versionadded:: 7.0
         """
-        info = ExecutionInfo(raw_cell, store_history, silent, shell_futures, cell_id)
+        info = ExecutionInfo(
+            raw_cell,
+            store_history,
+            silent,
+            shell_futures,
+            cell_id,
+            transformed_cell=transformed_cell,
+        )
         result = ExecutionResult(info)
 
         if (not raw_cell) or raw_cell.isspace():
diff --git a/IPython/extensions/autoreload.py b/IPython/extensions/autoreload.py
index 39ac6d52d49..15b6cdf4842 100644
--- a/IPython/extensions/autoreload.py
+++ b/IPython/extensions/autoreload.py
@@ -73,6 +73,30 @@
 
     Mark module 'foo' to not be autoreloaded.
 
+Import Conflict Resolution
+==========================
+
+In ``%autoreload 3`` mode, the extension tracks ``from X import Y`` style imports
+and intelligently resolves conflicts when the same name is imported multiple ways.
+
+Import tracking occurs after successful code execution, ensuring that only valid
+imports are tracked. This approach handles edge cases such as:
+
+- Importing a name that doesn't initially exist in a module, then adding that name
+  to the module and importing it again
+- Conflicts between aliased imports (``from X import Y as Z``) and direct imports
+  (``from X import Z``)
+
+When conflicts occur:
+
+- If you first do ``from X import Y as Z`` then later ``from X import Z``,
+  the extension will switch to reloading ``Z`` instead of ``Y`` under the name ``Z``.
+
+- Similarly, if you first do ``from X import Z`` then later ``from X import Y as Z``,
+  the extension will switch to reloading ``Y`` as ``Z`` instead of the original ``Z``.
+
+- The most recent successful import always takes precedence in conflict resolution.
+
 Caveats
 =======
 
@@ -127,6 +151,7 @@
 # Imports
 # -----------------------------------------------------------------------------
 
+import ast
 import os
 import sys
 import traceback
@@ -171,6 +196,9 @@ def __init__(self, shell=None):
         # Deduper reloader
         self.deduper_reloader = DeduperReloader()
 
+        # Persistent import tracker for from-imports
+        self.import_from_tracker = ImportFromTracker({}, {})
+
         # Cache module modification times
         self.check(check_all=True, do_reload=False)
 
@@ -193,6 +221,10 @@ def mark_module_reloadable(self, module_name):
             pass
         self.modules[module_name] = True
 
+    def clear_import_tracker(self):
+        """Clear the persistent import tracker state"""
+        self.import_from_tracker = ImportFromTracker({}, {})
+
     def aimport_module(self, module_name):
         """Import a module, and mark it reloadable
 
@@ -237,7 +269,7 @@ def filename_and_mtime(self, module):
 
         return py_filename, pymtime
 
-    def check(self, check_all=False, do_reload=True):
+    def check(self, check_all=False, do_reload=True, execution_info=None):
         """Check whether some modules need to be reloaded."""
 
         if not self.enabled and not check_all:
@@ -248,6 +280,10 @@ def check(self, check_all=False, do_reload=True):
         else:
             modules = list(self.modules.keys())
 
+        # Use the persistent import_from_tracker
+        import_from_tracker = (
+            self.import_from_tracker if self.import_from_tracker.imports_froms else None
+        )
         for modname in modules:
             m = sys.modules.get(modname, None)
 
@@ -275,7 +311,13 @@ def check(self, check_all=False, do_reload=True):
                 self._report(f"Reloading '{modname}'.")
                 try:
                     if self.autoload_obj:
-                        superreload(m, reload, self.old_objects, self.shell)
+                        superreload(
+                            m,
+                            reload,
+                            self.old_objects,
+                            self.shell,
+                            import_from_tracker=import_from_tracker,
+                        )
                     # if not using autoload, check if deduperreload is viable for this module
                     elif self.deduper_reloader.maybe_reload_module(m):
                         pass
@@ -427,6 +469,59 @@ def __call__(self):
 ]
 
 
+class ImportFromTracker:
+    def __init__(self, imports_froms: dict, symbol_map: dict):
+        self.imports_froms = imports_froms
+        # symbol_map maps original_name -> list of resolved_names
+        self.symbol_map = {}
+        if symbol_map:
+            for module_name, mappings in symbol_map.items():
+                self.symbol_map[module_name] = {}
+                for original_name, resolved_names in mappings.items():
+                    if isinstance(resolved_names, list):
+                        self.symbol_map[module_name][original_name] = resolved_names[:]
+                    else:
+                        self.symbol_map[module_name][original_name] = [resolved_names]
+        else:
+            self.symbol_map = symbol_map or {}
+
+    def add_import(
+        self, module_name: str, original_name: str, resolved_name: str
+    ) -> None:
+        """Add an import, handling conflicts with existing imports.
+
+        This method is called after successful code execution, so we know the import is valid.
+        """
+        if module_name not in self.imports_froms:
+            self.imports_froms[module_name] = []
+        if module_name not in self.symbol_map:
+            self.symbol_map[module_name] = {}
+
+        # Check if there's already a different mapping for the same resolved_name from a different original_name
+        # We need to remove any conflicting mappings
+        for orig_name, res_names in list(self.symbol_map[module_name].items()):
+            if resolved_name in res_names and orig_name != original_name:
+                # Remove the conflicting resolved_name from the other original_name's list
+                res_names.remove(resolved_name)
+                if (
+                    not res_names
+                ):  # If the list is now empty, remove the original_name entirely
+                    if orig_name in self.imports_froms[module_name]:
+                        self.imports_froms[module_name].remove(orig_name)
+                    del self.symbol_map[module_name][orig_name]
+
+        # Add the new mapping
+        if original_name not in self.imports_froms[module_name]:
+            self.imports_froms[module_name].append(original_name)
+
+        if original_name not in self.symbol_map[module_name]:
+            self.symbol_map[module_name][original_name] = []
+
+        # Add the resolved_name if it's not already in the list
+        if resolved_name not in self.symbol_map[module_name][original_name]:
+            self.symbol_map[module_name][original_name].append(resolved_name)
+
+
 def append_obj(module, d, name, obj, autoload=False):
     in_module = hasattr(obj, "__module__") and obj.__module__ == module.__name__
     if autoload:
@@ -445,7 +540,9 @@ def append_obj(module, d, name, obj, autoload=False):
     return True
 
 
-def superreload(module, reload=reload, old_objects=None, shell=None):
+def superreload(
+    module, reload=reload, old_objects=None, shell=None, import_from_tracker=None
+):
     """Enhanced version of the builtin reload function.
 
     superreload remembers objects previously in the module, and
@@ -486,18 +583,34 @@ def superreload(module, reload=reload, old_objects=None, shell=None):
         module.__dict__.update(old_dict)
         raise
 
-    # iterate over all objects and update functions & classes
     for name, new_obj in list(module.__dict__.items()):
         key = (module.__name__, name)
         if key not in old_objects:
             # here 'shell' acts both as a flag and as an output var
+            imports_froms = (
+                import_from_tracker.imports_froms if import_from_tracker else None
+            )
+            symbol_map = import_from_tracker.symbol_map if import_from_tracker else None
             if (
                 shell is None
                 or name == "Enum"
                 or not append_obj(module, old_objects, name, new_obj, True)
+                or (
+                    imports_froms
+                    and module.__name__ in imports_froms
+                    and "*" not in imports_froms[module.__name__]
+                    and name not in imports_froms[module.__name__]
+                )
             ):
                 continue
-            shell.user_ns[name] = new_obj
+
+            # Handle symbol mapping - now supporting multiple resolved names per original name
+            if symbol_map and name in symbol_map.get(module.__name__, {}):
+                resolved_names = symbol_map.get(module.__name__, {})[name]
+                for resolved_name in resolved_names:
+                    shell.user_ns[resolved_name] = new_obj
+            else:
+                shell.user_ns[name] = new_obj
 
         new_refs = []
         for old_ref in old_objects[key]:
@@ -727,6 +840,9 @@ def aimport(self, parameter_s="", stream=None):
                     self.shell.push({top_name: top_module})
 
     def pre_run_cell(self, info):
+        # Store the execution info for later use in post_execute_hook
+        self._last_execution_info = info
+
         if self._reloader.enabled:
             try:
                 self._reloader.check()
@@ -734,7 +850,20 @@ def pre_run_cell(self, info):
                 pass
 
     def post_execute_hook(self):
-        """Cache the modification times of any modules imported in this execution"""
+        """Cache the modification times of any modules imported in this execution and track imports"""
+
+        # Track imports from the recently executed code if autoreload 3 is enabled
+        if self._reloader.enabled and self._reloader.autoload_obj:
+            # Use the stored execution info
+            if (
+                hasattr(self, "_last_execution_info")
+                and self._last_execution_info
+                and self._last_execution_info.transformed_cell
+            ):
+                self._track_imports_from_code(
+                    self._last_execution_info.transformed_cell
+                )
+
         newly_loaded_modules = set(sys.modules) - self.loaded_modules
         for modname in newly_loaded_modules:
             _, pymtime = self._reloader.filename_and_mtime(sys.modules[modname])
@@ -743,6 +872,36 @@ def post_execute_hook(self):
 
         self.loaded_modules.update(newly_loaded_modules)
 
+    def _track_imports_from_code(self, code: str) -> None:
+        """Track import statements from executed code"""
+        try:
+            tree = ast.parse(code)
+
+            for node in ast.walk(tree):
+                # Handle "from X import Y" style imports
+                if isinstance(node, ast.ImportFrom):
+                    mod = node.module
+
+                    # Skip relative imports that don't have a module name
+                    if mod is None:
+                        continue
+
+                    for name in node.names:
+                        # name.name is going to be actual name that we want to import from module
+                        # name.asname is Z in the case of from X import Y as Z
+                        # we should update Z in the shell in this situation, so track it too.
+                        original_name = name.name
+                        resolved_name = name.asname if name.asname else name.name
+
+                        # Since the code executed successfully, we know this import is valid
+                        self._reloader.import_from_tracker.add_import(
+                            mod, original_name, resolved_name
+                        )
+        except (SyntaxError, ValueError):
+            # If there's a syntax error, skip import tracking
+            # (though this shouldn't happen since the code already executed successfully)
+            pass
+
 
 def load_ipython_extension(ip):
     """Load the extension in IPython."""


Test patch:
diff --git a/tests/test_zzz_autoreload.py b/tests/test_zzz_autoreload.py
index db0cb1981ef..94979f7aa48 100644
--- a/tests/test_zzz_autoreload.py
+++ b/tests/test_zzz_autoreload.py
@@ -33,6 +33,7 @@
 from IPython.core.events import EventManager, pre_run_cell
 from IPython.testing.decorators import skipif_not_numpy
 from IPython.core.interactiveshell import ExecutionInfo
+from IPython.core import inputtransformer2 as ipt2
 
 if platform.python_implementation() == "PyPy":
     pytest.skip(
@@ -51,10 +52,12 @@ class FakeShell:
     def __init__(self):
         self.ns = {}
         self.user_ns = self.ns
+        self.user_ns["In"] = []
         self.user_ns_hidden = {}
         self.events = EventManager(self, {"pre_run_cell", pre_run_cell})
         self.auto_magics = AutoreloadMagics(shell=self)
         self.events.register("pre_run_cell", self.auto_magics.pre_run_cell)
+        self.input_transformer_manager = ipt2.TransformerManager()
 
     register_magics = set_hook = noop
 
@@ -69,10 +72,12 @@ def showtraceback(
         traceback.print_exc()
 
     def run_code(self, code):
+        transformed_cell = self.input_transformer_manager.transform_cell(code)
         self.events.trigger(
             "pre_run_cell",
             ExecutionInfo(
-                raw_cell="",
+                raw_cell=code,
+                transformed_cell=code,
                 store_history=False,
                 silent=False,
                 shell_futures=False,
@@ -417,6 +422,149 @@ def meth(self):
         self.shell.run_code("t = ExtTest(); assert t.meth() == 'ext'")
         self.shell.run_code("assert ext_int == 2")
 
+    def test_autoload3_import_Y_as_Z(self):
+        self.shell.magic_autoreload("3")
+        mod_code = """
+        def func1(): pass
+        n = 1
+        """
+        mod_name, mod_fn = self.new_module(textwrap.dedent(mod_code))
+        self.shell.run_code(f"from {mod_name} import n as foo")
+        self.shell.run_code("foo")
+        with self.assertRaises(NameError):
+            self.shell.run_code("func1()")
+
+        new_code = """
+        n = 100
+        def func2(): pass
+        def func1(): pass
+        m = 5
+        """
+        self.write_file(mod_fn, textwrap.dedent(new_code))
+
+        self.shell.run_code("foo")
+        with self.assertRaises(NameError):
+            self.shell.run_code("n")
+
+    def test_autoload3_import_Y_as_Z_overloading(self):
+        self.shell.magic_autoreload("3")
+        mod_code = """
+        def func1(): pass
+        n = 1
+        """
+        mod_name, mod_fn = self.new_module(textwrap.dedent(mod_code))
+        self.shell.run_code(f"from {mod_name} import n as foo")
+        self.shell.run_code("foo")
+        with self.assertRaises(NameError):
+            self.shell.run_code("func1()")
+
+        new_code = """
+        n = 100
+        def func2(): pass
+        def func1(): pass
+        foo = 45
+        """
+        self.write_file(mod_fn, textwrap.dedent(new_code))
+        self.shell.run_code(f"assert foo == 100")
+        self.shell.run_code(f"from {mod_name} import foo")
+        self.shell.run_code(f"assert foo == 45")
+
+    def test_autoload3_normalimport(self):
+        self.shell.magic_autoreload("3")
+        mod_code = """
+        def func1(): pass
+        n = 1
+        """
+        mod_name, mod_fn = self.new_module(textwrap.dedent(mod_code))
+        self.shell.run_code(f"import {mod_name}")
+        self.shell.run_code(f"{mod_name}.func1()")
+        self.shell.run_code(f"{mod_name}.n")
+
+        new_code = """
+        n = 100
+        def func2(): pass
+        def func1(): pass
+        m = 5
+        """
+        self.write_file(mod_fn, textwrap.dedent(new_code))
+
+        self.shell.run_code(f"{mod_name}.func1()")
+        self.shell.run_code(f"{mod_name}.n")
+        self.shell.run_code(f"{mod_name}.func2()")
+        self.shell.run_code(f"{mod_name}.m")
+
+        self.shell.run_code(f"from {mod_name} import n")
+        self.shell.run_code(f"{mod_name}.func1()")
+        self.shell.run_code(f"n")
+
+    def test_autoload3_normalimport_2(self):
+        self.shell.magic_autoreload("3")
+        mod_code = """
+        def func1(): pass
+        n = 1
+        """
+        mod_name, mod_fn = self.new_module(textwrap.dedent(mod_code))
+        self.shell.run_code(f"from {mod_name} import n")
+        with self.assertRaises(NameError):
+            self.shell.run_code("func1()")
+        self.shell.run_code("n")
+
+        new_code = """
+        n = 100
+        def func2(): pass
+        def func1(): pass
+        m = 5
+        """
+        self.shell.run_code(f"import {mod_name}")
+        self.write_file(mod_fn, textwrap.dedent(new_code))
+        self.shell.run_code(f"{mod_name}.func1()")
+        self.shell.run_code(f"{mod_name}.n")
+        self.shell.run_code(f"{mod_name}.func2()")
+        self.shell.run_code(f"{mod_name}.m")
+        self.shell.run_code(f"n")
+
+    def test_autoload_3_does_not_add_all(self):
+        # Tests that %autoreload 3 does not effectively run from X import *
+        self.shell.magic_autoreload("3")
+        mod_code = """
+        def func1(): pass
+        n = 1
+        """
+        mod_name, mod_fn = self.new_module(textwrap.dedent(mod_code))
+        self.shell.run_code(f"from {mod_name} import n")
+        self.shell.run_code("n")
+        with self.assertRaises(NameError):
+            self.shell.run_code("func()")
+
+        new_code = """
+        n = 1
+        def func2(): pass
+        def func1(): pass
+        m = 5
+        """
+        self.write_file(mod_fn, textwrap.dedent(new_code))
+        self.shell.run_code("n")
+        with self.assertRaises(NameError):
+            self.shell.run_code("func1()")
+        with self.assertRaises(NameError):
+            self.shell.run_code("func2()")
+        with self.assertRaises(NameError):
+            self.shell.run_code("m")
+
+        self.shell.run_code(f"from {mod_name} import m")
+        self.shell.run_code("n")
+        self.shell.run_code("m")
+        with self.assertRaises(NameError):
+            self.shell.run_code("func1()")
+        with self.assertRaises(NameError):
+            self.shell.run_code("func2()")
+
+        self.shell.run_code(f"from {mod_name} import func1,func2")
+        self.shell.run_code("n")
+        self.shell.run_code("m")
+        self.shell.run_code("func1()")
+        self.shell.run_code("func2()")
+
     def test_verbose_names(self):
         # Asserts correspondense between original mode names and their verbose equivalents.
         @dataclass
@@ -709,3 +857,299 @@ def test_smoketest_aimport(self):
 
     def test_smoketest_autoreload(self):
         self._check_smoketest(use_aimport=False)
+
+    def test_autoreload_with_user_defined_In_variable(self):
+        """
+        Check that autoreload works when the user has defined an In variable.
+        """
+        mod_name, mod_fn = self.new_module(
+            textwrap.dedent(
+                """
+                                def hello():
+                                    return "Hello"
+                            """
+            )
+        )
+        self.shell.magic_autoreload("2")
+        self.shell.run_code(f"import {mod_name}")
+        self.shell.run_code(f"res = {mod_name}.hello()")
+        assert self.shell.user_ns["res"] == "Hello"
+
+        self.shell.user_ns["In"] = "some_value"
+
+        self.write_file(
+            mod_fn,
+            textwrap.dedent(
+                """
+                                def hello():
+                                    return "Changed"
+                            """
+            ),
+        )
+
+        self.shell.run_code(f"res = {mod_name}.hello()")
+        assert self.shell.user_ns["res"] == "Changed"
+
+    def test_import_from_tracker_conflict_resolution(self):
+        """Test that ImportFromTracker properly handles import conflicts"""
+        from IPython.extensions.autoreload import ImportFromTracker
+        from unittest.mock import Mock
+
+        # Create a test module with both 'foo' and 'bar' attributes
+        mod_name, mod_fn = self.new_module(
+            textwrap.dedent(
+                """
+                foo = "original_foo"
+                bar = "original_bar"
+                """
+            )
+        )
+
+        # Mock the module in sys.modules instead of actually importing it
+        mock_module = Mock()
+        mock_module.foo = "original_foo"
+        mock_module.bar = "original_bar"
+        sys.modules[mod_name] = mock_module
+
+        try:
+            # Create a tracker
+            tracker = ImportFromTracker({}, {})
+
+            # Test case 1: "from x import y as z" then "from x import z"
+            # First import: from mod_name import foo as bar
+            tracker.add_import(mod_name, "foo", "bar")
+
+            # Verify initial state
+            assert mod_name in tracker.imports_froms
+            assert "foo" in tracker.imports_froms[mod_name]
+            assert tracker.symbol_map[mod_name]["foo"] == ["bar"]
+
+            # Second import: from mod_name import bar (conflicts with previous "bar")
+            tracker.add_import(mod_name, "bar", "bar")
+
+            # The second import should take precedence since "bar" is a valid import
+            assert "bar" in tracker.imports_froms[mod_name]
+            assert "foo" not in tracker.imports_froms[mod_name]  # Should be removed
+            assert tracker.symbol_map[mod_name]["bar"] == ["bar"]
+            assert "foo" not in tracker.symbol_map[mod_name]  # Should be removed
+        finally:
+            # Clean up sys.modules
+            if mod_name in sys.modules:
+                del sys.modules[mod_name]
+
+    def test_import_from_tracker_reverse_conflict(self):
+        """Test the reverse case: 'from x import z' then 'from x import y as z'"""
+        from IPython.extensions.autoreload import ImportFromTracker
+        from unittest.mock import Mock
+
+        # Create a test module
+        mod_name, mod_fn = self.new_module(
+            textwrap.dedent(
+                """
+                foo = "original_foo"
+                bar = "original_bar"
+                """
+            )
+        )
+
+        # Mock the module in sys.modules instead of actually importing it
+        mock_module = Mock()
+        mock_module.foo = "original_foo"
+        mock_module.bar = "original_bar"
+        sys.modules[mod_name] = mock_module
+
+        try:
+            # Create a tracker
+            tracker = ImportFromTracker({}, {})
+
+            # First import: from mod_name import bar
+            tracker.add_import(mod_name, "bar", "bar")
+
+            # Verify initial state
+            assert "bar" in tracker.imports_froms[mod_name]
+            assert tracker.symbol_map[mod_name]["bar"] == ["bar"]
+
+            # Second import: from mod_name import foo as bar (conflicts with previous "bar")
+            tracker.add_import(mod_name, "foo", "bar")
+
+            # The second import should take precedence since "foo" is a valid import
+            assert "foo" in tracker.imports_froms[mod_name]
+            assert "bar" not in tracker.imports_froms[mod_name]  # Should be removed
+            assert tracker.symbol_map[mod_name]["foo"] == ["bar"]
+            assert "bar" not in tracker.symbol_map[mod_name]  # Should be removed
+        finally:
+            # Clean up sys.modules
+            if mod_name in sys.modules:
+                del sys.modules[mod_name]
+
+    def test_import_from_tracker_invalid_import(self):
+        """Test that ImportFromTracker works correctly with the post-execution approach"""
+        from IPython.extensions.autoreload import ImportFromTracker
+
+        # Create a test module with only 'foo' attribute
+        mod_name, mod_fn = self.new_module(
+            textwrap.dedent(
+                """
+                foo = "original_foo"
+                """
+            )
+        )
+
+        # Create a tracker
+        tracker = ImportFromTracker({}, {})
+
+        # First import: from mod_name import foo as bar
+        # Since we're simulating post-execution, this is a valid import
+        tracker.add_import(mod_name, "foo", "bar")
+
+        # Verify initial state
+        assert "foo" in tracker.imports_froms[mod_name]
+        assert tracker.symbol_map[mod_name]["foo"] == ["bar"]
+
+        # Second import: from mod_name import foo2 as bar (conflicting import)
+        # In the new approach, this would only be called if the import actually succeeded
+        # So this represents a case where the module was updated to have foo2
+        tracker.add_import(mod_name, "foo2", "bar")
+
+        # The new mapping should replace the old one since it's more recent
+        assert "foo2" in tracker.imports_froms[mod_name]
+        assert "foo" not in tracker.imports_froms[mod_name]  # Should be replaced
+        assert tracker.symbol_map[mod_name]["foo2"] == ["bar"]
+        assert "foo" not in tracker.symbol_map[mod_name]  # Should be replaced
+
+    def test_import_from_tracker_integration(self):
+        """Test the integration of ImportFromTracker with autoreload"""
+        # Create a test module
+        mod_name, mod_fn = self.new_module(
+            textwrap.dedent(
+                """
+                foo = "original_foo"
+                bar = "original_bar"
+                """
+            )
+        )
+
+        # Enable autoreload mode 3 (complete)
+        self.shell.magic_autoreload("3")
+
+        # First import: from mod_name import foo as bar
+        # This will naturally load the module into sys.modules
+        self.shell.run_code(f"from {mod_name} import foo as bar")
+        assert self.shell.user_ns["bar"] == "original_foo"
+
+        # Second import: from mod_name import bar (should override the alias)
+        # The module is already in sys.modules, so this should work with our validation
+        self.shell.run_code(f"from {mod_name} import bar")
+        assert self.shell.user_ns["bar"] == "original_bar"  # Should now be the real bar
+
+        # Modify the module
+        self.write_file(
+            mod_fn,
+            textwrap.dedent(
+                """
+                foo = "modified_foo"
+                bar = "modified_bar"
+                """
+            ),
+        )
+
+        # Trigger autoreload by running any code
+        self.shell.run_code("x = 1")
+
+        # The 'bar' variable should now contain the modified 'bar', not 'foo'
+        assert self.shell.user_ns["bar"] == "modified_bar"
+
+    def test_autoreload3_double_import(self):
+        """Test the integration of ImportFromTracker with autoreload"""
+        # Create a test module
+        mod_name, mod_fn = self.new_module(
+            textwrap.dedent(
+                """
+                foo = "original_foo"
+                bar = "original_bar"
+                """
+            )
+        )
+        # Enable autoreload mode 3 (complete)
+        self.shell.magic_autoreload("3")
+
+        # First import: from mod_name import foo as bar
+        # This will naturally load the module into sys.modules
+        self.shell.run_code(f"from {mod_name} import foo as bar")
+        self.shell.run_code(f"from {mod_name} import foo")
+        assert self.shell.user_ns["bar"] == "original_foo"
+        assert self.shell.user_ns["foo"] == "original_foo"
+        # Modify the module
+        self.write_file(
+            mod_fn,
+            textwrap.dedent(
+                """
+                foo = "modified_foo"
+                bar = "modified_bar"
+                """
+            ),
+        )
+        self.shell.run_code("pass")
+        assert self.shell.user_ns["bar"] == "modified_foo"
+        assert self.shell.user_ns["foo"] == "modified_foo"
+
+    def test_import_from_tracker_unloaded_module(self):
+        """Test that ImportFromTracker works with the post-execution approach"""
+        from IPython.extensions.autoreload import ImportFromTracker
+
+        # With the new approach, we only track imports after successful execution
+        # So even if a module isn't initially in sys.modules, if an import executed
+        # successfully, we should track it
+
+        fake_mod_name = "test_module_12345"
+
+        # Create a tracker
+        tracker = ImportFromTracker({}, {})
+
+        # Simulate an import that executed successfully
+        # (In reality, this would only be called if the import actually succeeded)
+        tracker.add_import(fake_mod_name, "some_attr", "some_name")
+
+        # Since the import "succeeded", it should be tracked
+        assert fake_mod_name in tracker.imports_froms
+        assert fake_mod_name in tracker.symbol_map
+        assert "some_attr" in tracker.imports_froms[fake_mod_name]
+        assert tracker.symbol_map[fake_mod_name]["some_attr"] == ["some_name"]
+
+        # Simulate a conflict scenario - another import succeeded
+        tracker.add_import(fake_mod_name, "another_attr", "some_name")
+
+        # The newer import should replace the older one
+        assert fake_mod_name in tracker.imports_froms
+        assert fake_mod_name in tracker.symbol_map
+        assert "another_attr" in tracker.imports_froms[fake_mod_name]
+        assert (
+            "some_attr" not in tracker.imports_froms[fake_mod_name]
+        )  # Should be replaced
+        assert tracker.symbol_map[fake_mod_name]["another_attr"] == ["some_name"]
+        assert (
+            "some_attr" not in tracker.symbol_map[fake_mod_name]
+        )  # Should be replaced
+
+    def test_import_from_tracker_multiple_resolved_names(self):
+        """Test that the same original name can map to multiple resolved names"""
+        from IPython.extensions.autoreload import ImportFromTracker
+
+        # Create a tracker
+        tracker = ImportFromTracker({}, {})
+
+        fake_mod_name = "test_module_abc"
+
+        # Simulate: from test_module_abc import foo as bar
+        tracker.add_import(fake_mod_name, "foo", "bar")
+
+        # Verify initial state
+        assert "foo" in tracker.imports_froms[fake_mod_name]
+        assert tracker.symbol_map[fake_mod_name]["foo"] == ["bar"]
+
+        # Simulate: from test_module_abc import foo (same original name, different resolved name)
+        tracker.add_import(fake_mod_name, "foo", "foo")
+
+        # Both resolved names should be tracked for the same original name
+        assert "foo" in tracker.imports_froms[fake_mod_name]
+        assert set(tracker.symbol_map[fake_mod_name]["foo"]) == {"bar", "foo"}