Repository: pyodide/pyodide
Pull request: Fix `pyimport` for some corner cases
### Description

Fix #4992

### Checklists

- [x] Add a [CHANGELOG](https://github.com/pyodide/pyodide/blob/main/docs/project/changelog.md) entry
- [x] Add / update tests


Problem statement:
`pyimport` fail to import a module from a module
## 🐛 Bug

After manually deleting the child module from `sys.modules` or deleting the attribute from the parent module, `pyimport` fail to import the child module, which is inconsistent with the behavior of `from ... import ...` statement.

### To Reproduce

```py
# setup files
from pathlib import Path
Path("a").mkdir()
Path("a/b.py").write_text("c = 1")

# import the first time and delete it
import a.b
del a.b

# from-import works fine:
from a import b
print(b.c)

# pyimport raises
from pyodide_js import pyimport
pyimport("a.b")

# but importing `a.b.c` is fine
pyimport("a.b.c")
```

### Expected behavior

```
Traceback (most recent call last):
  File "/lib/python312.zip/_pyodide/_base.py", line 655, in pyimport_impl
    res = getattr(res, fromlist[0])
          ^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: module 'a' has no attribute 'b'
```

### Environment

- Pyodide Version<!-- (e.g. 0.25.0) -->: 0.26.2
- Browser version<!-- (e.g. Chrome 122.0.4638.54) -->: Edge
- Any other relevant information:

<!-- If you are building Pyodide by yourself, please also include these information: -->

<!--
- Commit hash of Pyodide git repository:
- Build environment<!--(e.g. Ubuntu 22.04)- ->:
-->

### Additional context

Probably this is because of some caching mechanism of python.

Question: why don't we just implement `pyimport` by simply compile the path into `from-import` statement and exec it?

Hints from issue discussion:

Commit message: fix `pyimport`

Patch to review:
diff --git a/src/py/_pyodide/_base.py b/src/py/_pyodide/_base.py
index 4c1af43d5cd..24d67391e30 100644
--- a/src/py/_pyodide/_base.py
+++ b/src/py/_pyodide/_base.py
@@ -11,6 +11,7 @@
 import tokenize
 from collections.abc import Generator
 from copy import deepcopy
+from importlib import import_module
 from io import StringIO
 from textwrap import dedent
 from types import CodeType
@@ -649,8 +650,11 @@ def find_imports(source: str) -> list[str]:
 
 
 def pyimport_impl(path: str) -> Any:
-    [stem, *fromlist] = path.rsplit(".", 1)
-    res = __import__(stem, fromlist=fromlist)
-    if fromlist:
-        res = getattr(res, fromlist[0])
-    return res
+    if "." not in path:
+        return import_module(path)
+    stem, name = path.rsplit(".", 1)
+    mod = import_module(stem)
+    try:
+        return getattr(mod, name)
+    except AttributeError:
+        return import_module(path)


Code review comments:
diff_hunk: @@ -649,8 +650,11 @@ def find_imports(source: str) -> list[str]:
 
 
 def pyimport_impl(path: str) -> Any:
-    [stem, *fromlist] = path.rsplit(".", 1)
-    res = __import__(stem, fromlist=fromlist)
-    if fromlist:
-        res = getattr(res, fromlist[0])
-    return res
+    if "." not in path:
+        return import_module(path)
+    stem, name = path.rsplit(".", 1)
+    mod = import_module(stem)
+    try:
+        return getattr(mod, name)
+    except AttributeError:
+        return import_module(path)
path: src/py/_pyodide/_base.py
text: @user1:
How about this:
```suggestion
    # If multiple components, behave like `from a.b.c import last_part`
    # This compiles into 
    # IMPORT_NAME a.b.c
    # IMPORT_FROM last_part
    # for only one component, there is only an IMPORT_NAME
    [stem, *fromlist] = path.rsplit(".", 1)
    res = __import__(stem, fromlist=fromlist)
    if not fromlist:
       # Only one component, no IMPORT_FROM step so we can return now
       return res
    # Now imitate IMPORT_FROM
    try:
        return getattr(mod, fromlist[0])
    except AttributeError:
        return import_module(path)
```

@author:
In fact using `__import__` or `import_module` is just preference, I don't see differences in practices.
And both of them have a weird behavior:

`__import__("a.b")` != `__import__("a", fromlist=["b"]).b`

So if you want minimal changes, this way would work:

```diff
    try:
        return getattr(mod, fromlist[0])
    except AttributeError:
+       return __import__(path)
```

I overthought it before.

@author:
Well I am wrong. Your code is just right. I know so little about `__import__`