Repository: hylang/hy

Issue:
`(require foo *)` unconditionally gets all macros
`(import foo *)`, [per Python](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement), uses the variable `__all__` to decide what to import, or if `__all__` is not defined, it imports every name not beginning with an underscore. `(require foo *)`, by contrast, always gets all macros, although it's supposed to be analogous. So, what `(require foo *)` should do is:

1. Check a module variable, say `_hy_all_macros`, for a list of public names.
    - A name present in `_hy_all_macros` with no corresponding macro defined is an error at the time of `(require foo *)`.
2. If there is no such variable, require all macros whose names don't begin with an underscore.


Discussion:
Here's an idea for syntax with a macro that also addresses #1934:

```
(export
  :macros foo bar baz
  :objects bing ♥)
```

This would expand to:

```
(eval-and-compile
  (setv _hy_export_macros ["foo" "bar" "baz"]))
(setv __all__ ["bing" "hyx_Xblack_heart_suitX"])
```

The compiler would use `_hy_export_macros`, when it exists, as the set of macros to be imported by `(require … *)`.

Resolving patch:
diff --git a/conftest.py b/conftest.py
--- a/conftest.py
+++ b/conftest.py
@@ -3,6 +3,7 @@
 import sys
 from functools import reduce
 from operator import or_
+from pathlib import Path
 
 import pytest
 
@@ -37,9 +38,4 @@ def pytest_collect_file(parent, path):
         and NATIVE_TESTS in path.dirname + os.sep
         and path.basename != "__init__.hy"
     ):
-
-        if hasattr(pytest.Module, "from_parent"):
-            pytest_mod = pytest.Module.from_parent(parent, fspath=path)
-        else:
-            pytest_mod = pytest.Module(path, parent)
-        return pytest_mod
+        return pytest.Module.from_parent(parent, path=Path(path))
diff --git a/hy/core/result_macros.py b/hy/core/result_macros.py
--- a/hy/core/result_macros.py
+++ b/hy/core/result_macros.py
@@ -1680,7 +1680,7 @@ def compile_import_or_require(compiler, expr, root, entries):
     ret = Result()
 
     for entry in entries:
-        assignments = "ALL"
+        assignments = "EXPORTS"
         prefix = ""
 
         module, rest = entry
@@ -1702,10 +1702,10 @@ def compile_import_or_require(compiler, expr, root, entries):
         if root == "import":
             module_name = ast_module.lstrip(".")
             level = len(ast_module) - len(module_name)
-            if assignments == "ALL" and prefix == "":
+            if assignments == "EXPORTS" and prefix == "":
                 node = asty.ImportFrom
                 names = [asty.alias(module, name="*", asname=None)]
-            elif assignments == "ALL":
+            elif assignments == "EXPORTS":
                 compiler.scope.define(mangle(prefix))
                 node = asty.Import
                 names = [
@@ -1742,8 +1742,8 @@ def compile_import_or_require(compiler, expr, root, entries):
                         Symbol("None"),
                         Keyword("assignments"),
                         (
-                            String("ALL")
-                            if assignments == "ALL"
+                            String("EXPORTS")
+                            if assignments == "EXPORTS"
                             else [[String(k), String(v)] for k, v in assignments]
                         ),
                         Keyword("prefix"),
diff --git a/hy/macros.py b/hy/macros.py
--- a/hy/macros.py
+++ b/hy/macros.py
@@ -129,7 +129,8 @@ def require(source_module, target_module, assignments, prefix=""):
         target_module (Optional[Union[str, ModuleType]]): The module into which the
             macros will be loaded.  If `None`, then the caller's namespace.
             The latter is useful during evaluation of generated AST/bytecode.
-        assignments (Union[str, typing.Sequence[str]]): The string "ALL" or a list of macro name and alias pairs.
+        assignments (Union[str, typing.Sequence[str]]): The string "ALL", the string
+            "EXPORTS", or a list of macro name and alias pairs.
         prefix (str): If nonempty, its value is prepended to the name of each imported macro.
             This allows one to emulate namespaced macros, like "mymacromodule.mymacro",
             which looks like an attribute of a module. Defaults to ""
@@ -160,55 +161,59 @@ def require(source_module, target_module, assignments, prefix=""):
         return False
 
     if not inspect.ismodule(source_module):
+        package = None
+        if source_module.startswith("."):
+            source_dirs = source_module.split(".")
+            target_dirs = getattr(target_module, "__name__", target_module).split(".")
+            while len(source_dirs) > 1 and source_dirs[0] == "" and target_dirs:
+                source_dirs.pop(0)
+                target_dirs.pop()
+            package = ".".join(target_dirs + source_dirs[:-1])
         try:
-            if source_module.startswith("."):
-                source_dirs = source_module.split(".")
-                target_dirs = getattr(target_module, "__name__", target_module).split(
-                    "."
-                )
-                while len(source_dirs) > 1 and source_dirs[0] == "" and target_dirs:
-                    source_dirs.pop(0)
-                    target_dirs.pop()
-                package = ".".join(target_dirs + source_dirs[:-1])
-            else:
-                package = None
             source_module = importlib.import_module(source_module, package)
         except ImportError as e:
             raise HyRequireError(e.args[0]).with_traceback(None)
 
     source_macros = source_module.__dict__.setdefault("__macros__", {})
+    source_exports = getattr(
+        source_module,
+        "_hy_export_macros",
+        [k for k in source_macros.keys() if not k.startswith("_")],
+    )
 
     if not source_module.__macros__:
-        if assignments != "ALL":
-            for name, alias in assignments:
-                try:
-                    require(
-                        f"{source_module.__name__}.{mangle(name)}",
-                        target_module,
-                        "ALL",
-                        prefix=alias,
-                    )
-                except HyRequireError as e:
-                    raise HyRequireError(
-                        f"Cannot import name '{name}'"
-                        f" from '{source_module.__name__}'"
-                        f" ({source_module.__file__})"
-                    )
-            return True
-        else:
+        if assignments in ("ALL", "EXPORTS"):
             return False
+        for name, alias in assignments:
+            try:
+                require(
+                    f"{source_module.__name__}.{mangle(name)}",
+                    target_module,
+                    "ALL",
+                    prefix=alias,
+                )
+            except HyRequireError as e:
+                raise HyRequireError(
+                    f"Cannot import name '{name}'"
+                    f" from '{source_module.__name__}'"
+                    f" ({source_module.__file__})"
+                )
+        return True
 
     target_macros = target_namespace.setdefault("__macros__", {})
 
     if prefix:
         prefix += "."
 
-    if assignments == "ALL":
-        name_assigns = [(k, k) for k in source_macros.keys()]
-    else:
-        name_assigns = assignments
-
-    for name, alias in name_assigns:
+    for name, alias in (
+        assignments
+        if assignments not in ("ALL", "EXPORTS")
+        else (
+            (k, k)
+            for k in source_macros.keys()
+            if assignments == "ALL" or k in source_exports
+        )
+    ):
         _name = mangle(name)
         alias = mangle(
             "#" + prefix + unmangle(alias)[1:]


Test patch:
diff --git a/tests/native_tests/language.hy b/tests/native_tests/language.hy
--- a/tests/native_tests/language.hy
+++ b/tests/native_tests/language.hy
@@ -1272,6 +1272,7 @@ cee\"} dee" "ey bee\ncee dee"))
   (assert (= x "KL"))
   (assert (= y 1)))
 
+
 (defn test-require []
   (with [(pytest.raises NameError)]
     (qplah 1 2 3 4))
@@ -1316,12 +1317,19 @@ cee\"} dee" "ey bee\ncee dee"))
   (assert (= (✈ "silly") "plane silly"))
   (assert (= (hyx_XairplaneX "foolish") "plane foolish"))
 
-  (require tests.resources [tlib macros :as m])
+  (require tests.resources [tlib  macros :as m  exports-none])
   (assert (in "tlib.qplah" __macros__))
   (assert (in (hy.mangle "m.test-macro") __macros__))
+  (assert (in (hy.mangle "exports-none.cinco") __macros__))
   (require os [path])
   (with [(pytest.raises hy.errors.HyRequireError)]
-    (hy.eval '(require tests.resources [does-not-exist]))))
+    (hy.eval '(require tests.resources [does-not-exist])))
+
+  (require tests.resources.exports *)
+  (assert (= (casey 1 2 3) [11 1 2 3]))
+  (assert (= (☘ 1 2 3) [13 1 2 3]))
+  (with [(pytest.raises NameError)]
+    (brother 1 2 3 4)))
 
 
 (defn test-require-native []
@@ -1346,6 +1354,19 @@ cee\"} dee" "ey bee\ncee dee"))
   (assert (in "lb.xyzzy" __macros__)))
 
 
+(defn test-export-objects []
+  ; We use `hy.eval` here because of a Python limitation that
+  ; importing `*` is only allowed at the module level.
+  (hy.eval '(do
+    (import tests.resources.exports *)
+    (assert (= (jan) 21))
+    (assert (= (♥) 23))
+    (with [(pytest.raises NameError)]
+      (wayne))
+    (import tests.resources.exports [wayne])
+    (assert (= (wayne) 22)))))
+
+
 (defn test-encoding-nightmares []
   (assert (= (len "ℵℵℵ♥♥♥\t♥♥\r\n") 11)))
 
diff --git a/tests/resources/exports.hy b/tests/resources/exports.hy
new file mode 100644
--- /dev/null
+++ b/tests/resources/exports.hy
@@ -0,0 +1,21 @@
+(defn jan []
+  21)
+
+(defn wayne []
+  22)
+
+(defn ♥ []
+  23)
+
+(defmacro casey [#* tree]
+  `[11 ~@tree])
+
+(defmacro brother [#* tree]
+  `[12 ~@tree])
+
+(defmacro ☘ [#* tree]
+  `[13 ~@tree])
+
+(export
+  :objects [jan ♥]
+  :macros [casey ☘])
diff --git a/tests/resources/exports_none.hy b/tests/resources/exports_none.hy
new file mode 100644
--- /dev/null
+++ b/tests/resources/exports_none.hy
@@ -0,0 +1,5 @@
+(defmacro cinco [#* tree]
+  `[5 ~@tree])
+
+(setv _hy_export_macros [])
+  ; This should have no effect, because we never require `*` from this module.