Repository: hylang/hy

Issue:
`hy.M` sugar for import on demand
It's annoying to have to import to a gensym when you want to use a function from another module in a macro expansion. Suppose you could say `(hy.M.math.sqrt 2)` to call `math.sqrt` without actually binding `math` or `math.sqrt` to some name in the local scope. In addition to making macros neater, this could be convenient as a general shorthand for using something from a module, particularly a module with a long name, without having to add a separate `import` form.

To get a module whose name itself has dots, one could use `/` instead of the dots, so `(hy.M.foo/bar/baz.bing)` would work like `(import foo.bar.baz) (foo.bar.baz.bing)`.

Here's a simple implementation:

```python
import hy

class M:
    def __call__(self, module_name):
        import importlib
        return importlib.import_module(module_name)
    def __getattr__(self, s):
        return self('.'.join(hy.unmangle(s).split('/')))

hy.M = M()
hy.eval(hy.read_many('''
(print hy.M.sklearn/neighbors.KNeighborsRegressor)
'''))
```

The nice thing about this feature is that it can be added without changing Hy's syntax or touching the set of core macros.

`__call__` is provided so you can also say `(hy.M "foo.bar.baz")` to get the module `foo.bar.baz`, which may be more convenient when the module name isn't known until runtime.

This sort of feature could probably be provided for requiring macros, too.


Discussion:
Ooh I like this idea, but the syntax feels clunky; perhaps we could instead have it as a reader macro?

For example:
```hy
(#module:math/sqrt 2)
(#module:foo.bar.baz/bing)
(print #module:some.submod/CONSTANT)
(type #module:re) ; -> ModuleType
```

We could swap `.` and `/` and `:` with other things; these were just my personal preferences (and we should be able to use `.` like this in a reader macro).
Probably also want to call `#module` something else.

As an aside, wouldn't `(hy.M module-name)` be the same thing as `(importlib.import-module module-name)`?
`#module:math/sqrt` calls a reader macro named `module:math/sqrt`, not `module`. You'd need to make it `#module math/sqrt` or the like.

> As an aside, wouldn't `(hy.M module-name)` be the same thing as `(importlib.import-module module-name)`?

Yeah, except you don't need to import `importlib` in the calling scope.

Resolving patch:
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -70,6 +70,10 @@
     py3_10=("https://docs.python.org/3.10/", None),
     hyrule=("https://hyrule.readthedocs.io/en/master/", None),
 )
+
+import hy
+hy.M = type(hy.M)  # A trick to enable `hy:autoclass:: hy.M`
+
 # ** Generate Cheatsheet
 import json
 from itertools import zip_longest
diff --git a/hy/__init__.py b/hy/__init__.py
--- a/hy/__init__.py
+++ b/hy/__init__.py
@@ -15,6 +15,23 @@ def _initialize_env_var(env_var, default_val):
 hy.importer._inject_builtins()
 # we import for side-effects.
 
+
+class M:
+    """``hy.M`` is an object that provides syntactic sugar for imports. It allows syntax like ``(hy.M.math.sqrt 2)``  to mean ``(import math) (math.sqrt 2)``, except without bringing ``math`` or ``math.sqrt`` into scope. This is useful in macros to avoid namespace pollution. To refer to a module with dots in its name, use slashes instead: ``hy.M.os/path.basename`` gets the function ``basename`` from the module ``os.path``.
+
+    You can also call ``hy.M`` like a function, as in ``(hy.M "math")``, which is useful when the module name isn't known until run-time. This interface just calls :py:func:`importlib.import_module`, avoiding (1) mangling due to attribute lookup, and (2) the translation of ``/`` to ``.`` in the module name. The advantage of ``(hy.M modname)`` over ``importlib.import_module(modname)`` is merely that it avoids bringing ``importlib`` itself into scope."""
+    def __call__(self, module_name):
+        import importlib
+        return importlib.import_module(module_name)
+    def __getattr__(self, s):
+        import re
+        return self(hy.mangle(re.sub(
+            r'/(-*)',
+            lambda m: '.' + '_' * len(m.group(1)),
+            hy.unmangle(s))))
+M = M()
+
+
 # Import some names on demand so that the dependent modules don't have
 # to be loaded if they're not needed.
 


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -39,10 +39,11 @@ jobs:
       shell: bash
       run: |
         if [[ ${{ matrix.python }} = pyodide ]] ; then
-            npm install pyodide
+            npm install pyodide@0.22.1
+              # 0.23.0 has a regression: https://github.com/pyodide/pyodide/issues/3730
             pip install 'pip >= 22.3.1'
               # Older pips may fail to install `pyodide-build`.
-            pip install pyodide-build
+            pip install 'pyodide-build == 0.22.1'
             pyodide venv .venv-pyodide
             source .venv-pyodide/bin/activate
         fi
diff --git a/tests/native_tests/hy_misc.hy b/tests/native_tests/hy_misc.hy
--- a/tests/native_tests/hy_misc.hy
+++ b/tests/native_tests/hy_misc.hy
@@ -1,5 +1,5 @@
 ;; Tests of `hy.gensym`, `hy.macroexpand`, `hy.macroexpand-1`,
-;; `hy.disassemble`, and `hy.read`
+;; `hy.disassemble`, `hy.read`, and `hy.M`
 
 (import
   pytest)
@@ -86,3 +86,59 @@
   (assert (is (type (hy.read "[]")) (type '[])))
   (assert (= (hy.read "0") '0))
   (assert (is (type (hy.read "0")) (type '0))))
+
+
+(defn test-hyM []
+  (defmacro no-name [name]
+    `(with [(pytest.raises NameError)] ~name))
+
+  ; `hy.M` doesn't bring the imported stuff into scope.
+  (assert (= (hy.M.math.sqrt 4) 2))
+  (assert (= (.sqrt (hy.M "math") 4) 2))
+  (no-name math)
+  (no-name sqrt)
+
+  ; It's independent of bindings to such names.
+  (setv math (type "Dummy" #() {"sqrt" "hello"}))
+  (assert (= (hy.M.math.sqrt 4) 2))
+  (assert (= math.sqrt "hello"))
+
+  ; It still works in a macro expansion.
+  (defmacro frac [a b]
+    `(hy.M.fractions.Fraction ~a ~b))
+  (assert (= (* 6 (frac 1 3)) 2))
+  (no-name fractions)
+  (no-name Fraction)
+
+  ; You can use `/` for dotted module names.
+  (assert (= (hy.M.os/path.basename "foo/bar") "bar"))
+  (no-name os)
+  (no-name path)
+
+  ; `hy.M.__getattr__` attempts to cope with mangling.
+  (with [e (pytest.raises ModuleNotFoundError)]
+    (hy.M.a-b☘c-d/e.z))
+  (assert (= e.value.name (hy.mangle "a-b☘c-d")))
+  ; `hy.M.__call__` doesn't.
+  (with [e (pytest.raises ModuleNotFoundError)]
+    (hy.M "a-b☘c-d/e.z"))
+  (assert (= e.value.name "a-b☘c-d/e")))
+
+
+(defn test-hyM-mangle-chain [tmp-path monkeypatch]
+  ; We can get an object from a submodule with various kinds of
+  ; mangling in the name chain.
+
+  (setv p tmp-path)
+  (for [e ["foo" "foo?" "_foo" "☘foo☘"]]
+    (/= p (hy.mangle e))
+    (.mkdir p :exist-ok True)
+    (.write-text (/ p "__init__.py") ""))
+  (.write-text (/ p "foo.hy") "(setv foo 5)")
+  (monkeypatch.syspath-prepend (str tmp-path))
+
+  ; Python will reuse any `foo` imported in an earlier test if we
+  ; don't reload it explicitly.
+  (import foo) (import importlib) (importlib.reload foo)
+
+  (assert (= hy.M.foo/foo?/_foo/☘foo☘/foo.foo 5)))