diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cad4cd899..552800e5d 100644 --- 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/NEWS.rst b/NEWS.rst index 6c8cb58f5..6db42c8b4 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -16,6 +16,7 @@ New Features case they're no-ops. * The `py` macro now implicitly parenthesizes the input code, so Python's indentation restrictions don't apply. +* New built-in object `hy.M` for easy imports in macros. 0.26.0 (released 2023-02-08) ============================= diff --git a/docs/api.rst b/docs/api.rst index d01a6ca44..6d64a97cf 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1312,6 +1312,8 @@ the following methods .. hy:autofunction:: hy.as-model +.. hy:autoclass:: hy.M + .. _reader-macros: Reader Macros diff --git a/docs/conf.py b/docs/conf.py index 8c00214b3..03ad67207 100644 --- 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 index ca6adfa1c..263f8c2ef 100644 --- 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. diff --git a/tests/native_tests/hy_misc.hy b/tests/native_tests/hy_misc.hy index 1f283823d..a50e35d64 100644 --- 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)))