Repository: hylang/hy

Issue:
hy.eval crashes when evaluating macros when passed a "<...>" string to module arg 
Noticed this while working on the reader macro tests. 

`hy.eval` is supposed to allow passing a module name surrounded by `<` `>` to signify creating a new module to evaluate in (the value is passed directly to `compiler.get_compiler_module`).

 This works as long as you don't try to evaluate a `defmacro` call which will crash out with an `HyEvalError`. `defmacro`'s call to `macros.install_macro` attempts to inject the macro into the module's `__macros__` attribute, but since the `<module-name>` passed to `hy.eval` isn't added to `sys.modules`, `inspect.getmodule` will return `None`, immediately crashing when we try to get the module's `.__dict__` attr.
 
 ```clojure
 => (hy.eval (hy.read "(defmacro a [] 1)") :module "<test>")
hy.eval(hy.read('(defmacro a [] 1)'), module='<test>')
None
Traceback (most recent call last):
  File "stdin-0ddca9f7dc853219212315199ccdb4534920a36f", line 1, in <module>
    (hy.eval (hy.read "(defmacro a [] 1)") :module "<test>")
hy.errors.HyEvalError: 'NoneType' object has no attribute '__dict__'
 ```

I've been working around this in the tests i've been writing with a context manager to temporarily add the module to `sys.modules`:

```clojure
(with-decorator
  contextmanager
  (defn temp-module [module-name]
    (let [module (types.ModuleType module-name)
          old-module (sys.modules.get module-name)]
      (setv (get sys.modules module-name) module)
      (try
        (yield module)
        (finally
          (if old-module
              (setv (get sys.modules module-name) module)
              (sys.modules.pop module-name)))))))
```


Discussion:
> `hy.eval` is supposed to allow passing a module name surrounded by `<` `>` to signify creating a new module to evaluate in

This doesn't seem to be documented, so this should be a problem only for us, and not users.
Incidentally, `hy.eval` should perhaps take `module` first and `locals` second, so as to be more like Python's `eval`.

Resolving patch:
diff --git a/hy/__init__.py b/hy/__init__.py
--- a/hy/__init__.py
+++ b/hy/__init__.py
@@ -40,7 +40,7 @@ def __getattr__(self, s):
     read_many="hy.reader",
     mangle="hy.reader",
     unmangle="hy.reader",
-    eval=["hy.compiler", "hy_eval"],
+    eval=["hy.compiler", "hy_eval_user"],
     repr=["hy.core.hy_repr", "hy_repr"],
     repr_register=["hy.core.hy_repr", "hy_repr_register"],
     gensym="hy.core.util",
diff --git a/hy/compiler.py b/hy/compiler.py
--- a/hy/compiler.py
+++ b/hy/compiler.py
@@ -670,10 +670,7 @@ def get_compiler_module(module=None, compiler=None, calling_frame=False):
     module = getattr(compiler, "module", None) or module
 
     if isinstance(module, str):
-        if module.startswith("<") and module.endswith(">"):
-            module = types.ModuleType(module)
-        else:
-            module = importlib.import_module(mangle(module))
+        module = importlib.import_module(mangle(module))
 
     if calling_frame and not module:
         module = calling_module(n=2)
@@ -686,82 +683,17 @@ def get_compiler_module(module=None, compiler=None, calling_frame=False):
 
 def hy_eval(
     hytree,
-    locals=None,
+    locals,
     module=None,
-    ast_callback=None,
     compiler=None,
     filename=None,
     source=None,
     import_stdlib=True,
+    globals=None,
 ):
-    """Evaluates a quoted expression and returns the value.
-
-    If you're evaluating hand-crafted AST trees, make sure the line numbers
-    are set properly.  Try `fix_missing_locations` and related functions in the
-    Python `ast` library.
-
-    Examples:
-      ::
-
-         => (hy.eval '(print "Hello World"))
-         "Hello World"
-
-      If you want to evaluate a string, use ``read-str`` to convert it to a
-      form first::
-
-         => (hy.eval (hy.read-str "(+ 1 1)"))
-         2
-
-    Args:
-      hytree (Object):
-          The Hy AST object to evaluate.
-
-      locals (Optional[dict]):
-          Local environment in which to evaluate the Hy tree.  Defaults to the
-          calling frame.
-
-      module (Optional[Union[str, types.ModuleType]]):
-          Module, or name of the module, to which the Hy tree is assigned and
-          the global values are taken.
-          The module associated with `compiler` takes priority over this value.
-          When neither `module` nor `compiler` is specified, the calling frame's
-          module is used.
-
-      ast_callback (Optional[Callable]):
-          A callback that is passed the Hy compiled tree and resulting
-          expression object, in that order, after compilation but before
-          evaluation.
-
-      compiler (Optional[HyASTCompiler]):
-          An existing Hy compiler to use for compilation.  Also serves as
-          the `module` value when given.
-
-      filename (Optional[str]):
-          The filename corresponding to the source for `tree`.  This will be
-          overridden by the `filename` field of `tree`, if any; otherwise, it
-          defaults to "<string>".  When `compiler` is given, its `filename` field
-          value is always used.
-
-      source (Optional[str]):
-          A string containing the source code for `tree`.  This will be
-          overridden by the `source` field of `tree`, if any; otherwise,
-          if `None`, an attempt will be made to obtain it from the module given by
-          `module`.  When `compiler` is given, its `source` field value is always
-          used.
-
-    Returns:
-      Any: Result of evaluating the Hy compiled tree.
-    """
 
     module = get_compiler_module(module, compiler, True)
 
-    if locals is None:
-        frame = inspect.stack()[1][0]
-        locals = inspect.getargvalues(frame).locals
-
-    if not isinstance(locals, dict):
-        raise TypeError("Locals must be a dictionary")
-
     # Does the Hy AST object come with its own information?
     filename = getattr(hytree, "filename", filename) or "<string>"
     source = getattr(hytree, "source", source)
@@ -776,14 +708,54 @@ def hy_eval(
         import_stdlib=import_stdlib,
     )
 
-    if ast_callback:
-        ast_callback(_ast, expr)
+    if globals is None:
+        globals = module.__dict__
 
     # Two-step eval: eval() the body of the exec call
-    eval(ast_compile(_ast, filename, "exec"), module.__dict__, locals)
+    eval(ast_compile(_ast, filename, "exec"), globals, locals)
 
     # Then eval the expression context and return that
-    return eval(ast_compile(expr, filename, "eval"), module.__dict__, locals)
+    return eval(ast_compile(expr, filename, "eval"), globals, locals)
+
+
+def hy_eval_user(model, globals = None, locals = None, module = None):
+    # This function is advertised as `hy.eval`.
+    """An equivalent of Python's :func:`eval` for evaluating Hy code. The chief difference is that the first argument should be a :ref:`model <models>` rather than source text. If you have a string of source text you want to evaluate, convert it to a model first with :hy:func:`hy.read` or :hy:func:`hy.read-many`::
+
+        (hy.eval '(+ 1 1))             ; => 2
+        (hy.eval (hy.read "(+ 1 1)"))  ; => 2
+
+    The optional arguments ``globals`` and ``locals`` work as in the case of ``eval``. There's one more optional argument, ``module``, which can be a module object or a string naming a module. The module's ``__dict__`` attribute can fill in for ``globals`` (and hence also for ``locals``) if ``module`` is provided but ``globals`` isn't, but the primary purpose of ``module`` is to control where macro calls are looked up. Without this argument, the calling module of ``hy.eval`` is used instead. ::
+
+        (defmacro my-test-mac [] 3)
+        (hy.eval '(my-test-mac))                 ; => 3
+        (import hyrule)
+        (hy.eval '(my-test-mac) :module hyrule)  ; NameError
+        (hy.eval '(list-n 3 1) :module hyrule)   ; => [1 1 1]"""
+
+    if locals is None:
+        locals = globals
+    hy_was = None
+    if locals and 'hy' in locals:
+        hy_was = (locals['hy'],)
+    try:
+        value = hy_eval(
+            hytree = model,
+            globals = globals,
+            locals = (inspect.getargvalues(inspect.stack()[1][0]).locals
+                if locals is None and module is None
+                else locals),
+            module = get_compiler_module(module, None, True))
+    finally:
+        if locals is not None:
+            if hy_was:
+                # Restore the old value of `hy`.
+                locals['hy'], = hy_was
+            else:
+                # Remove the implicitly added `hy` (if execution
+                # reached far enough to add it).
+                locals.pop('hy', None)
+    return value
 
 
 def hy_compile(
diff --git a/hy/importer.py b/hy/importer.py
--- a/hy/importer.py
+++ b/hy/importer.py
@@ -135,10 +135,12 @@ def _hy_source_to_code(self, data, path, _optimize=-1):
     def _hy_compile_source(pathname, source):
         if not pathname.endswith(".hy"):
             return _py_compile_source(pathname, source)
+        mname = f"<zip:{pathname}>"
+        sys.modules[mname] = types.ModuleType(mname)
         return compile(
             hy_compile(
                 read_many(source.decode("UTF-8"), filename=pathname, skip_shebang=True),
-                f"<zip:{pathname}>",
+                sys.modules[mname],
             ),
             pathname,
             "exec",


Test patch:
diff --git a/tests/compilers/test_ast.py b/tests/compilers/test_ast.py
--- a/tests/compilers/test_ast.py
+++ b/tests/compilers/test_ast.py
@@ -3,8 +3,9 @@
 
 import pytest
 
+import hy
 from hy._compat import PY3_11
-from hy.compiler import hy_compile, hy_eval
+from hy.compiler import hy_compile
 from hy.errors import HyError, HyLanguageError
 from hy.reader import read_many
 from hy.reader.exceptions import LexException, PrematureEndOfInput
@@ -24,7 +25,7 @@ def can_compile(expr, import_stdlib=False, iff=True):
 
 
 def can_eval(expr):
-    return hy_eval(read_many(expr))
+    return hy.eval(read_many(expr))
 
 
 def cant_compile(expr):
diff --git a/tests/importer/test_importer.py b/tests/importer/test_importer.py
--- a/tests/importer/test_importer.py
+++ b/tests/importer/test_importer.py
@@ -8,7 +8,7 @@
 import pytest
 
 import hy
-from hy.compiler import hy_compile, hy_eval
+from hy.compiler import hy_compile
 from hy.errors import HyLanguageError, hy_exc_handler
 from hy.importer import HyLoader
 from hy.reader import read_many
@@ -103,7 +103,7 @@ def import_from_path(path):
 
 def test_eval():
     def eval_str(s):
-        return hy_eval(hy.read(s), filename="<string>", source=s)
+        return hy.eval(hy.read(s))
 
     assert eval_str("[1 2 3]") == [1, 2, 3]
     assert eval_str('{"dog" "bark" "cat" "meow"}') == {"dog": "bark", "cat": "meow"}
diff --git a/tests/native_tests/comprehensions.hy b/tests/native_tests/comprehensions.hy
--- a/tests/native_tests/comprehensions.hy
+++ b/tests/native_tests/comprehensions.hy
@@ -264,7 +264,7 @@
 
 
 (defmacro eval-isolated [#* body]
-  `(hy.eval '(do ~@body) :module "<test>" :locals {}))
+  `(hy.eval '(do ~@body) :module (hy.M.types.ModuleType "<test>") :locals {}))
 
 
 (defn test-lfor-nonlocal []
diff --git a/tests/native_tests/hy_eval.hy b/tests/native_tests/hy_eval.hy
--- a/tests/native_tests/hy_eval.hy
+++ b/tests/native_tests/hy_eval.hy
@@ -1,3 +1,6 @@
+"Tests of the user-facing function `hy.eval`."
+
+
 (import
   re
   pytest)
@@ -36,24 +39,6 @@
   (assert (= (hy.eval '#{}) #{})))
 
 
-(defn test-eval-global-dict []
-  (assert (= 'bar (hy.eval (quote foo) {"foo" 'bar})))
-  (assert (= 1 (do (setv d {}) (hy.eval '(setv x 1) d) (hy.eval (quote x) d))))
-  (setv d1 {}  d2 {})
-  (hy.eval '(setv x 1) d1)
-  (with [e (pytest.raises NameError)]
-    (hy.eval (quote x) d2)))
-
-
-(defn test-eval-failure []
-  ; yo dawg
-  (with [(pytest.raises TypeError)] (hy.eval '(hy.eval)))
-  (defclass C)
-  (with [(pytest.raises TypeError)] (hy.eval (C)))
-  (with [(pytest.raises TypeError)] (hy.eval 'False []))
-  (with [(pytest.raises TypeError)] (hy.eval 'False {} 1)))
-
-
 (defn test-eval-quasiquote []
   ; https://github.com/hylang/hy/issues/1174
 
@@ -84,3 +69,154 @@
   (setv d {"a" 1 "b" 2})
   (setv k "b")
   (assert (= (hy.eval `(get ~d ~k)) 2)))
+
+
+(setv outer "O")
+
+(defn test-globals []
+  (assert (= (hy.eval 'foo {"foo" 2}) 2))
+  (with [(pytest.raises NameError)]
+    (hy.eval 'foo {}))
+
+  (assert (= outer "O"))
+  (assert (= (hy.eval 'outer) "O"))
+  (with [(pytest.raises NameError)]
+    (hy.eval 'outer {}))
+
+  (hy.eval '(do
+    (global outer)
+    (setv outer "O2")))
+  (assert (= outer "O2"))
+
+  (hy.eval :globals {"outer" "I"} '(do
+    (global outer)
+    (setv outer "O3")))
+  (assert (= outer "O2"))
+
+  ; If `globals` is provided but not `locals`, then `globals`
+  ; substitutes in for `locals`.
+  (defn try-it [#** eval-args]
+    (setv d (dict :g1 1 :g2 2))
+    (hy.eval :globals d #** eval-args '(do
+      (global g2 g3)
+      (setv  g2 "newv"  g3 3  l 4)))
+    (del (get d "__builtins__"))
+    d)
+  (setv ls {})
+  (assert (= (try-it)            (dict :g1 1 :g2 "newv" :g3 3 :l 4)))
+  (assert (= (try-it :locals ls) (dict :g1 1 :g2 "newv" :g3 3)))
+  (assert (= ls {"l" 4}))
+
+  ; If `module` is provided but `globals` isn't, the dictionary of
+  ; `module` is used for globals. If `locals` also isn't provided,
+  ; the same dictionary is used for that, too.
+  (import string)
+  (assert (=
+    (hy.eval 'digits :module string)
+    "0123456789"))
+  (assert (=
+    (hy.eval 'digits :module "string")
+    "0123456789"))
+  (assert (=
+    (hy.eval 'digits :module string :globals {"digits" "boo"})
+    "boo"))
+  (with [(pytest.raises NameError)]
+    (hy.eval 'digits :module string :globals {}))
+  (hy.eval :module string '(do
+    (global hytest-string-g)
+    (setv hytest-string-g "hi")
+    (setv hytest-string-l "bye")))
+  (assert (= string.hytest-string-g "hi"))
+  (assert (= string.hytest-string-l "bye")))
+
+
+(defn test-locals []
+  (assert (= (hy.eval 'foo :locals {"foo" 2}) 2))
+  (with [(pytest.raises NameError)]
+    (hy.eval 'foo :locals {}))
+
+  (setv d (dict :l1 1 :l2 2 :hippopotamus "local_v"))
+  (hy.eval :locals d '(do
+    (global hippopotamus)
+    (setv  l2 "newv"  l3 3  hippopotamus "global_v")))
+  (assert (= d (dict :l1 1 :l2 "newv" :l3 3 :hippopotamus "local_v")))
+  (assert (= (get (globals) "hippopotamus") "global_v"))
+  (assert (= hippopotamus "global_v"))
+
+  ; `hy` is implicitly available even when `locals` and `globals` are
+  ; provided.
+  (assert (=
+    (hy.eval :locals {"foo" "A"} :globals {"bar" "B"}
+      '(hy.repr (+ foo bar)))
+    #[["AB"]]))
+  ; Even though `hy.eval` deletes the `hy` implicitly added to
+  ; `locals`, references in returned code still work.
+  (setv d {"a" 1})
+  (setv f (hy.eval '(fn [] (hy.repr "hello")) :locals d))
+  (assert (= d {"a" 1}))
+  (assert (= (f) #[["hello"]])))
+
+
+(defn test-globals-and-locals []
+  (setv gd (dict :g1 "apple" :g2 "banana"))
+  (setv ld (dict :l1 "Austin" :l2 "Boston"))
+  (hy.eval :globals gd :locals ld '(do
+    (global g2 g3)
+    (setv  g2 "newg-val"  g3 "newg-var"  l2 "newl-val"  l3 "newl-var")))
+  (del (get gd "__builtins__"))
+  (assert (= gd (dict :g1 "apple" :g2 "newg-val" :g3 "newg-var")))
+  (assert (= ld (dict :l1 "Austin" :l2 "newl-val" :l3 "newl-var"))))
+
+
+(defn test-no-extra-hy-removal []
+  "`hy.eval` shouldn't remove `hy` from a provided namespace if it
+  was already there."
+  (setv g {})
+  (exec "import hy" g)
+  (assert (= (hy.eval '(hy.repr [1 2]) g) "[1 2]"))
+  (assert (in "hy" g)))
+
+
+(defmacro test-macro []
+  '(setv blah "test from here"))
+(defmacro cheese []
+  "gorgonzola")
+
+(defn test-macros []
+  (setv M "tests.resources.macros")
+
+  ; Macros defined in `module` can be called.
+  (assert (= (hy.eval '(do (test-macro) blah)) "test from here"))
+  (assert (= (hy.eval '(do (test-macro) blah) :module M) 1))
+
+  ; `defmacro` creates a new macro in the module.
+  (hy.eval '(defmacro bilb-ono [] "creative consulting") :module M)
+  (assert (= (hy.eval '(bilb-ono) :module M) "creative consulting"))
+  (with [(pytest.raises NameError)]
+    (hy.eval '(bilb-ono)))
+
+  ; When `module` is provided, implicit access to macros in the
+  ; current scope is lost.
+  (assert (= (hy.eval '(cheese)) "gorgonzola"))
+  (with [(pytest.raises NameError)]
+    (hy.eval '(cheese) :module M))
+
+  ; You can still use `require` inside `hy.eval`.
+  (hy.eval '(require tests.resources.tlib [qplah]))
+  (assert (= (hy.eval '(qplah 1)) [8 1])))
+
+
+(defn test-filename []
+  (setv m (hy.read "(/ 1 0)" :filename "bad_math.hy"))
+  (with [e (pytest.raises ZeroDivisionError)]
+    (hy.eval m))
+  (assert (in "bad_math.hy" (get (hy.M.traceback.format-tb e.tb) -1))))
+
+
+(defn test-eval-failure []
+  ; yo dawg
+  (with [(pytest.raises TypeError)] (hy.eval '(hy.eval)))
+  (defclass C)
+  (with [(pytest.raises TypeError)] (hy.eval (C)))
+  (with [(pytest.raises TypeError)] (hy.eval 'False []))
+  (with [(pytest.raises TypeError)] (hy.eval 'False {} 1)))
diff --git a/tests/native_tests/let.hy b/tests/native_tests/let.hy
--- a/tests/native_tests/let.hy
+++ b/tests/native_tests/let.hy
@@ -506,7 +506,7 @@
 
 
 (defmacro eval-isolated [#* body]
-  `(hy.eval '(do ~@body) :module "<test>" :locals {}))
+  `(hy.eval '(do ~@body) :module (hy.M.types.ModuleType "<test>") :locals {}))
 
 
 (defn test-let-bound-nonlocal []
diff --git a/tests/test_reader.py b/tests/test_reader.py
--- a/tests/test_reader.py
+++ b/tests/test_reader.py
@@ -672,13 +672,11 @@ def test_read_error():
     pointing to the source position where the error arose."""
 
     import traceback
-
-    from hy.compiler import hy_eval
+    import hy
     from hy.errors import HySyntaxError, hy_exc_handler
-    from hy.reader import read
 
     with pytest.raises(HySyntaxError) as e:
-        hy_eval(read("(do (defn))"))
+        hy.eval(hy.read("(do (defn))"))
     assert "".join(traceback.format_exception_only(e.type, e.value)).startswith(
         '  File "<string>", line 1\n    (do (defn))\n         ^\n'
     )