Repository: hylang/hy

Issue:
`TypeError` from relative `require`
here is my module
```
.
├── __init__.py
├── args.hy
├── config.hy
├── log.hy
├── macros.hy
├── main.hy
├── type_helpers.hy
└── utils.hy
```

when i try to compile `hy2py args.hy` it raises:
```
hy.errors.HyMacroExpansionError:
  File "args.hy", line 10
    (require .macros *)
    ^-----------------^
expanding macro require
  TypeError: the 'package' argument is required to perform a relative import for '.macros'
```

if i remove dot
```hy
(require macros *)
```
then it works with `hy2py` but breaks running module with entry point


Discussion:
Interesting. Here's a minimal example:

```
$ touch '__init__.py'
$ echo '(setv foo 1)' > macros.hy
$ echo '(require .macros *)' > args.hy
$ hy2py args.hy
Traceback (most recent call last):
  File "/usr/local/bin/hy2py", line 8, in <module>
    sys.exit(hy2py_main())
hy.errors.HyMacroExpansionError: 
  File "args.hy", line 1
    (require .macros *)
    ^-----------------^
expanding macro require
  TypeError: the 'package' argument is required to perform a relative import for '.macros'
```

The exact semantics of relative imports are always tricky, but this looks like a bug. If nothing else, the error message can be improved.
Actually, you can provoke it with plain old `hy`, not just `hy2py`.
maybe should be added option to compile whole module?

for example:
```sh
hy2py some_module/

# or also specify output directory
hy2py --out-dir some_module_compiled/ some_module/
```
That's #2079, basically.
The failure of my example is in fact consistent with importing in Python:

```
$ mkdir pkg
$ touch pkg/__init__.py
$ echo >pkg/py_imported.py 'print("imported")'
$ echo >pkg/py_importer.py 'from .py_imported import *'
$ python3 -m pkg.py_importer
imported
$ python3 pkg/py_importer.py
Traceback (most recent call last):
  File "/tmp/pkg/py_importer.py", line 1, in <module>
    from .py_imported import *
ImportError: attempted relative import with no known parent package
```

And in Hy:

```
$ echo >pkg/hy_imported.hy '(print "imported")'
$ echo >pkg/hy_importer.hy '(import .hy_imported *)'
$ hy -m pkg.hy_importer
imported
$ hy pkg/hy_importer.hy 
Traceback (most recent call last):
…
ImportError: attempted relative import with no known parent package
```

Change the `import` to `require` and you get basically the same behavior with a worse error message (`TypeError: the 'package' argument is required to perform a relative import for '.hy_imported'`)—unless `hy_imported` happens to have already been byte-compiled. Then it runs without complaint. That looks like a bug.

An additional wrinkle is that while it's acceptable for `hy` to fail when trying to directly run a file that uses relative `import` or `require`, given that Python does the same thing, it isn't acceptable for `hy2py`, because you can't just `hy2py -m pkg.hy_importer` to get the Python for `hy_importer`. Perhaps this is a obstacle that would be solved best by a correct implementation of #2079, which would import a tree of modules in the way Python needs for relative imports to be resolved correctly.

Resolving patch:
diff --git a/hy/cmdline.py b/hy/cmdline.py
--- a/hy/cmdline.py
+++ b/hy/cmdline.py
@@ -5,6 +5,7 @@
 import os
 import platform
 import py_compile
+import re
 import runpy
 import sys
 from contextlib import nullcontext
@@ -332,7 +333,9 @@ def hyc_main():
 
 
 def hy2py_worker(source, options, filename, output_filepath=None):
+    source_path = None
     if isinstance(source, Path):
+        source_path = source
         source = source.read_text(encoding="UTF-8")
 
     if not output_filepath and options.output:
@@ -358,7 +361,13 @@ def printing_source(hst):
         hst.filename = filename
 
         with filtered_hy_exceptions():
-            _ast = hy_compile(hst, "__main__", filename=filename, source=source)
+            _ast = hy_compile(
+                 hst,
+                 re.sub(r'\.hy$', '', '.'.join(source_path.parts))
+                     if source_path
+                     else '__main__',
+                 filename=filename,
+                 source=source)
 
         if options.with_source:
             print()
@@ -385,7 +394,7 @@ def hy2py_main():
         "FILE",
         type=str,
         nargs="?",
-        help='Input Hy code (can be file or directory) (use STDIN if "-" or '
+        help='Input Hy code (can be file or module) (use STDIN if "-" or '
         "not provided)",
     )
     parser.add_argument(
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
@@ -1783,6 +1783,8 @@ def compile_require(compiler, expr, root, entries):
                         dotted("hy.macros.require"),
                         String(module_name),
                         Symbol("None"),
+                        Keyword("target_module_name"),
+                        String(compiler.module.__name__),
                         Keyword("assignments"),
                         (
                             String("EXPORTS")
diff --git a/hy/macros.py b/hy/macros.py
--- a/hy/macros.py
+++ b/hy/macros.py
@@ -197,7 +197,7 @@ def enable_readers(module, reader, names):
         reader.reader_macros[name] = namespace["_hy_reader_macros"][name]
 
 
-def require(source_module, target_module, assignments, prefix=""):
+def require(source_module, target_module, assignments, prefix="", target_module_name=None):
     """Load macros from one module into the namespace of another.
 
     This function is called from the macro also named `require`.
@@ -213,6 +213,7 @@ def require(source_module, target_module, assignments, prefix=""):
         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 ""
+        target_module_name: If true, overrides the apparent name of `target_module`.
 
     Returns:
         bool: Whether or not macros were actually transferred.
@@ -230,7 +231,8 @@ def require(source_module, target_module, assignments, prefix=""):
         return False
 
     if not inspect.ismodule(source_module):
-        source_module = import_module_from_string(source_module, target_module)
+        source_module = import_module_from_string(source_module,
+           target_module_name or target_module)
 
     source_macros = source_module.__dict__.setdefault("_hy_macros", {})
     source_exports = getattr(


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
@@ -1,5 +1,3 @@
-# fmt: off
-
 import ast
 from textwrap import dedent
 
diff --git a/tests/test_bin.py b/tests/test_bin.py
--- a/tests/test_bin.py
+++ b/tests/test_bin.py
@@ -1,5 +1,4 @@
 #!/usr/bin/env python
-# fmt: off
 
 import builtins
 import os
@@ -742,26 +741,53 @@ def test_assert(tmp_path, monkeypatch):
             assert ("bye" in err) == show_msg
 
 
-def test_hy2py_recursive(tmp_path):
-    (tmp_path / 'hy').mkdir()
-    (tmp_path / "hy/first.hy").write_text("""
-        (import folder.second [a b])
+def test_hy2py_recursive(monkeypatch, tmp_path):
+    (tmp_path / 'foo').mkdir()
+    (tmp_path / 'foo/__init__.py').touch()
+    (tmp_path / "foo/first.hy").write_text("""
+        (import foo.folder.second [a b])
         (print a)
         (print b)""")
-    (tmp_path / "hy/folder").mkdir()
-    (tmp_path / "hy/folder/second.hy").write_text("""
+    (tmp_path / "foo/folder").mkdir()
+    (tmp_path / "foo/folder/__init__.py").touch()
+    (tmp_path / "foo/folder/second.hy").write_text("""
         (setv a 1)
         (setv b "hello world")""")
 
-    _, err = run_cmd(["hy2py", (tmp_path / 'hy')], expect=1)
+    monkeypatch.chdir(tmp_path)
+
+    _, err = run_cmd("hy2py foo", expect=1)
     assert "ValueError" in err
 
-    run_cmd(["hy2py",
-        (tmp_path / 'hy'),
-        "--output", (tmp_path / 'py')])
-    assert set((tmp_path / 'py').rglob('*')) == {
-        tmp_path / 'py' / p
+    run_cmd("hy2py foo --output bar")
+    assert set((tmp_path / 'bar').rglob('*')) == {
+        tmp_path / 'bar' / p
         for p in ('first.py', 'folder', 'folder/second.py')}
 
-    output, _ = run_cmd("python3 first.py", cwd = tmp_path / 'py')
+    output, _ = run_cmd("python3 first.py", cwd = tmp_path / 'bar')
     assert output == "1\nhello world\n"
+
+
+@pytest.mark.parametrize('case', ['hy -m', 'hy2py'])
+def test_relative_require(case, monkeypatch, tmp_path):
+    # https://github.com/hylang/hy/issues/2204
+
+    (tmp_path / 'pkg').mkdir()
+    (tmp_path / 'pkg' / '__init__.py').touch()
+    (tmp_path / 'pkg' / 'a.hy').write_text('''
+        (defmacro m []
+          '(setv x (.upper "hello")))''')
+    (tmp_path / 'pkg' / 'b.hy').write_text('''
+        (require .a [m])
+        (m)
+        (print x)''')
+    monkeypatch.chdir(tmp_path)
+
+    if case == 'hy -m':
+        output, _ = run_cmd('hy -m pkg.b')
+    elif case == 'hy2py':
+        run_cmd('hy2py pkg -o out')
+        (tmp_path / 'out' / '__init__.py').touch()
+        output, _ = run_cmd('python3 -m out.b')
+
+    assert 'HELLO' in output