Repository: hylang/hy

Issue:
Import from zip files
It would be handy to be able to import .hy files from .zip archives (for those of us who like to deploy stuff in single file bundles).

Right now I'm working around this by doing:

```
BUNDLE=sushy.zip

clean:
    rm *.zip

bundle:
    hyc sushy/*.hy
    zip -r9 $(BUNDLE) . -i *.py *.pyc

test-bundle:
    PYTHONPATH=$(BUNDLE) python -m sushy
```


Discussion:
Kodiologist: It looks like Python 3.7 implemented `zipimport` in C. That would make monkey-patching it pretty difficult. 3.7 has less than a year until end-of-life, anyway, so I think I'll just say that this is only supported on 3.8 and up.

Patch:
diff --git a/NEWS.rst b/NEWS.rst
index 5f83fd54..d91d57ab 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -1,5 +1,15 @@
 .. default-role:: code
 
+Unreleased
+=============================
+
+New Features
+------------------------------
+* On Pythons ≥ 3.7, Hy modules can now be imported from ZIP
+  archives in the same way as Python modules, via `zipimport`_.
+
+.. _zipimport: https://docs.python.org/3.11/library/zipimport.html
+
 0.25.0 (released 2022-11-08)
 ==============================
 
diff --git a/hy/importer.py b/hy/importer.py
index 9579f168..7f9a4d7c 100644
--- a/hy/importer.py
+++ b/hy/importer.py
@@ -5,10 +5,12 @@ import os
 import pkgutil
 import sys
 import types
+import zipimport
 from contextlib import contextmanager
 from functools import partial
 
 import hy
+from hy._compat import PY3_8
 from hy.compiler import hy_compile
 from hy.reader import read_many
 
@@ -107,10 +109,8 @@ _py_source_to_code = importlib.machinery.SourceFileLoader.source_to_code
 
 def _could_be_hy_src(filename):
     return os.path.isfile(filename) and (
-        filename.endswith(".hy")
-        or not any(
-            filename.endswith(ext) for ext in importlib.machinery.SOURCE_SUFFIXES[1:]
-        )
+        os.path.splitext(filename)[1]
+        not in set(importlib.machinery.SOURCE_SUFFIXES) - {".hy"}
     )
 
 
@@ -128,6 +128,27 @@ def _hy_source_to_code(self, data, path, _optimize=-1):
 
 importlib.machinery.SourceFileLoader.source_to_code = _hy_source_to_code
 
+
+if PY3_8 and (".hy", False, False) not in zipimport._zip_searchorder:
+    zipimport._zip_searchorder += ((".hy", False, False),)
+    _py_compile_source = zipimport._compile_source
+
+    def _hy_compile_source(pathname, source):
+        if not pathname.endswith(".hy"):
+            return _py_compile_source(pathname, source)
+        return compile(
+            hy_compile(
+                read_many(source.decode("UTF-8"), filename=pathname, skip_shebang=True),
+                f"<zip:{pathname}>",
+            ),
+            pathname,
+            "exec",
+            dont_inherit=True,
+        )
+
+    zipimport._compile_source = _hy_compile_source
+
+
 #  This is actually needed; otherwise, pre-created finders assigned to the
 #  current dir (i.e. `''`) in `sys.path` will not catch absolute imports of
 #  directory-local modules!


Test patch:
diff --git a/tests/importer/test_importer.py b/tests/importer/test_importer.py
index 3dcd1e08..382a8d5f 100644
--- a/tests/importer/test_importer.py
+++ b/tests/importer/test_importer.py
@@ -8,6 +8,7 @@ from pathlib import Path
 import pytest
 
 import hy
+from hy._compat import PY3_8
 from hy.compiler import hy_compile, hy_eval
 from hy.errors import HyLanguageError, hy_exc_handler
 from hy.importer import HyLoader
@@ -273,3 +274,23 @@ def test_filtered_importlib_frames(capsys):
     captured_w_filtering = capsys.readouterr()[-1].strip()
 
     assert "importlib._" not in captured_w_filtering
+
+
+@pytest.mark.skipif(
+    not PY3_8,
+    reason="Python 3.7's `zipimport` is written in C, it can't be monkey-patched",
+)
+def test_zipimport(tmp_path):
+    from zipfile import ZipFile
+
+    zpath = tmp_path / "archive.zip"
+    with ZipFile(zpath, "w") as o:
+        o.writestr("example.hy", '(setv x "Hy from ZIP")')
+
+    try:
+        sys.path.insert(0, str(zpath))
+        import example
+    finally:
+        sys.path = [p for p in sys.path if p != str(zpath)]
+    assert example.x == "Hy from ZIP"
+    assert example.__file__ == str(zpath / "example.hy")