Repository: pyscript/pyscript

Issue:
js_modules not behaving like in Polyscript
### Checklist

- [X] I added a descriptive title
- [X] I searched for other issues and couldn't find a solution or duplication
- [X] I already searched in Google and didn't find any good information or help

### What happened?

Apparently `from pyscript.js_modules import Thing` doesn't work in *PyScript* the same way it does on *Polyscript*.

The main difference is that in *PyScript* that's exported within the Python code, as opposite of being registered as JS module like it is for *Polyscript* where *js_modules* use `registerJSModule` utility instead.

### What browsers are you seeing the problem on? (if applicable)

_No response_

### Console info

_No response_

### Additional Context

_No response_


Discussion:
Yeah you can just do `from js_modules import xxx` in PyScript `2023.12.1`. [Example](https://jeff.glass/post/whats-new-pyscript-2023-12-1/#js-module-import).

I don't think registerJsModule allows registering a submodule of an existing package.... 

the `js_modules` is not directly registered or exported so I don't understand how that works ... in PyScript, AFAIK, you need to do `from pyscript.js_modules import thing ` because `js_modules` is a `pyscript` module reference ... in *polyscript* though, Pyodide does some magic there because `polyscript` is the only registered module but somehow `from polyscript.js_modules import ...` works .. .that **doesn't** in PyScript, which is the issue I am trying to solve.

To provide more context:

  * we agreed that having `js_modules` as registered module in the JS world *might* be the best approach so that your example/code would work as expected (but it shouldn't right now)
  * Python doesn't resolve modules entries as sub-modules so that `from numpy.array import dtype` makes no sense (thiswas just an example, the point here is that dictionaries don't resolve as modules)

Would you be OK if we remove `js_modules` from `pyscript` or `polyscript` and have this registered a part, like it is for the `js` reference everyone is already familiar with?
**Update** the *pyodide* approach is indeed, and intentional, magic: https://github.com/pyodide/pyodide/discussions/4346#discussioncomment-7895369

In *polyscript* though, I've managed to provide both behavior without issues: https://github.com/pyscript/polyscript/pull/74

The next thing to do is to bring a similar approach to PyScript too and align the dance in both *py* and *mpy* cases.

Meanwhile, MicroPython now (after that MR) also supports *js_modules* which is pretty good news.

Resolving patch:
diff --git a/pyscript.core/src/stdlib/pyscript/magic_js.py b/pyscript.core/src/stdlib/pyscript/magic_js.py
--- a/pyscript.core/src/stdlib/pyscript/magic_js.py
+++ b/pyscript.core/src/stdlib/pyscript/magic_js.py
@@ -1,9 +1,28 @@
+import sys
+
 import js as globalThis
 from polyscript import js_modules
 from pyscript.util import NotSupported
 
 RUNNING_IN_WORKER = not hasattr(globalThis, "document")
 
+
+# allow `from pyscript.js_modules.xxx import yyy`
+class JSModule(object):
+    def __init__(self, name):
+        self.name = name
+
+    def __getattr__(self, field):
+        # avoid pyodide looking for non existent fields
+        if not field.startswith("_"):
+            return getattr(getattr(js_modules, self.name), field)
+
+
+# generate N modules in the system that will proxy the real value
+for name in globalThis.Reflect.ownKeys(js_modules):
+    sys.modules[f"pyscript.js_modules.{name}"] = JSModule(name)
+sys.modules["pyscript.js_modules"] = js_modules
+
 if RUNNING_IN_WORKER:
     import js
     import polyscript


Test patch:
diff --git a/pyscript.core/test/js_modules.html b/pyscript.core/test/js_modules.html
new file mode 100644
--- /dev/null
+++ b/pyscript.core/test/js_modules.html
@@ -0,0 +1,39 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <link rel="stylesheet" href="../dist/core.css">
+    <script type="module" src="../dist/core.js"></script>
+</head>
+<body>
+    <mpy-config>
+        [js_modules.main]
+        "./js_modules.js" = "random_js"
+    </mpy-config>
+    <mpy-script>
+        from pyscript.js_modules.random_js import default as value
+        from pyscript.js_modules import random_js
+        from pyscript import js_modules
+
+        print("mpy", value)
+        print("mpy", random_js.default)
+        print("mpy", js_modules.random_js.default)
+    </mpy-script>
+    <py-config>
+        [js_modules.main]
+        "./js_modules.js" = "random_js"
+    </py-config>
+    <py-script>
+        from pyscript.js_modules.random_js import default as value
+        from pyscript.js_modules import random_js
+        from pyscript import js_modules, document
+
+        print("py", value)
+        print("py", random_js.default)
+        print("py", js_modules.random_js.default)
+
+        document.documentElement.classList.add('done')
+    </py-script>
+</body>
+</html>
diff --git a/pyscript.core/test/js_modules.js b/pyscript.core/test/js_modules.js
new file mode 100644
--- /dev/null
+++ b/pyscript.core/test/js_modules.js
@@ -0,0 +1 @@
+export default Math.random();
diff --git a/pyscript.core/test/mpy.spec.js b/pyscript.core/test/mpy.spec.js
--- a/pyscript.core/test/mpy.spec.js
+++ b/pyscript.core/test/mpy.spec.js
@@ -35,3 +35,19 @@ test('MicroPython hooks', async ({ page }) => {
     'worker onAfterRun',
   ].join('\n'));
 });
+
+test('MicroPython + Pyodide js_modules', async ({ page }) => {
+  const logs = [];
+  page.on('console', msg => {
+    const text = msg.text();
+    if (!text.startsWith('['))
+      logs.push(text);
+  });
+  await page.goto('http://localhost:8080/test/js_modules.html');
+  await page.waitForSelector('html.done');
+  await expect(logs.length).toBe(6);
+  await expect(logs[0]).toBe(logs[1]);
+  await expect(logs[1]).toBe(logs[2]);
+  await expect(logs[3]).toBe(logs[4]);
+  await expect(logs[4]).toBe(logs[5]);
+});