diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b96428..1758b059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [v0.3.7] +### Fixed + * Fix a regression introduced in #1176 where the testrunner couldn't handle relative paths in `sys.path`, causing `basilisp test` to fail when no arugments were provided (#1204) + * Fix a bug where `basilisp.process/exec` could deadlock reading process output if that output exceeded the buffer size (#1202) + * Fix `basilisp boostrap` issue on MS-Windows where the boostrap file loaded too early, before Basilisp was in `sys.path` (#1208) + ## [v0.3.6] ### Added * Added support for the `:decorators` meta key in anonymous `fn`s (#1178) @@ -693,6 +699,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Basilisp language and compiler base. +[v0.3.6]: https://github.com/basilisp-lang/basilisp/compare/v0.3.6..v0.3.7 [v0.3.6]: https://github.com/basilisp-lang/basilisp/compare/v0.3.5..v0.3.6 [v0.3.5]: https://github.com/basilisp-lang/basilisp/compare/v0.3.4..v0.3.5 [v0.3.4]: https://github.com/basilisp-lang/basilisp/compare/v0.3.3..v0.3.4 diff --git a/pyproject.toml b/pyproject.toml index 8cc681d2..f704ec1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "basilisp" -version = "0.3.6" +version = "0.3.7" description = "A Clojure-like lisp written for Python" authors = ["Christopher Rink "] license = "Eclipse Public License 1.0 (EPL-1.0)" diff --git a/src/basilisp/cli.py b/src/basilisp/cli.py index 889454f6..d761d6ed 100644 --- a/src/basilisp/cli.py +++ b/src/basilisp/cli.py @@ -398,7 +398,7 @@ def _subcommand( Callable[["argparse._SubParsersAction"], None], ]: def _wrap_add_subcommand( - f: Callable[[argparse.ArgumentParser], None] + f: Callable[[argparse.ArgumentParser], None], ) -> Callable[["argparse._SubParsersAction"], None]: def _wrapped_subcommand(subparsers: "argparse._SubParsersAction"): parser = subparsers.add_parser( @@ -425,11 +425,12 @@ def bootstrap_basilisp_installation(_, args: argparse.Namespace) -> None: ): print_("No Basilisp bootstrap files were found.") else: - for file in removed: - print_(f"Removed '{file}'") + if removed is not None: + print_(f"Removed '{removed}'") else: - basilisp.bootstrap_python(site_packages=args.site_packages) + path = basilisp.bootstrap_python(site_packages=args.site_packages) print_( + f"(Added {path})\n\n" "Your Python installation has been bootstrapped! You can undo this at any " "time with with `basilisp bootstrap --uninstall`." ) @@ -473,7 +474,6 @@ def _add_bootstrap_subcommand(parser: argparse.ArgumentParser) -> None: # Not intended to be used by end users. parser.add_argument( "--site-packages", - action="append", help=argparse.SUPPRESS, ) @@ -745,7 +745,7 @@ def test( "Cannot run tests without dependency PyTest. Please install PyTest and try again.", ) else: - pytest.main(args=list(extra)) + sys.exit(pytest.main(args=list(extra))) @_subcommand( @@ -768,7 +768,9 @@ def test( If all options are unambiguous (e.g. they are only either used by Basilisp or by PyTest), then you can omit the `--`: - `basilisp test -k vector -p other_dir`""" + `basilisp test -k vector -p other_dir` + + Returns the PyTest exit code as the exit code.""" ), handler=test, allows_extra=True, diff --git a/src/basilisp/contrib/pytest/testrunner.py b/src/basilisp/contrib/pytest/testrunner.py index 4f0cb23d..e573f9a3 100644 --- a/src/basilisp/contrib/pytest/testrunner.py +++ b/src/basilisp/contrib/pytest/testrunner.py @@ -17,6 +17,7 @@ from basilisp.lang import symbol as sym from basilisp.lang import vector as vec from basilisp.lang.obj import lrepr +from basilisp.lang.util import munge from basilisp.util import Maybe _EACH_FIXTURES_META_KW = kw.keyword("each-fixtures", "basilisp.test") @@ -183,9 +184,10 @@ def _get_fully_qualified_module_names(file: Path) -> list[str]: there, we derive a Python module name referring to the given module path.""" paths = [] for pth in sys.path: - root = Path(pth) + root = Path(pth).resolve() if file.is_relative_to(root): - elems = list(file.with_suffix("").relative_to(pth).parts) + elems = list(file.with_suffix("").relative_to(root).parts) + if elems[-1] == "__init__": elems.pop() paths.append(".".join(elems)) @@ -269,6 +271,12 @@ def collect(self): filename = self.path.name module = self._import_module() ns = module.__basilisp_namespace__ + + # Ensure the test module was loaded because it was directly + # relative to an entry in `sys.path`. + if module.__name__ != munge(str(ns)): + raise ModuleNotFoundError(f"Module named '{ns}' is not in sys.path") + once_fixtures, each_fixtures = self._collected_fixtures(ns) self._fixture_manager = FixtureManager(once_fixtures) for test in self._collected_tests(ns): diff --git a/src/basilisp/lang/compiler/__init__.py b/src/basilisp/lang/compiler/__init__.py index 7c5fcc96..1ca6615a 100644 --- a/src/basilisp/lang/compiler/__init__.py +++ b/src/basilisp/lang/compiler/__init__.py @@ -33,7 +33,10 @@ GeneratorContext, ) from basilisp.lang.compiler.generator import expressionize as _expressionize # noqa -from basilisp.lang.compiler.generator import gen_py_ast, py_module_preamble +from basilisp.lang.compiler.generator import ( + gen_py_ast, + py_module_preamble, +) from basilisp.lang.compiler.generator import statementize as _statementize from basilisp.lang.compiler.optimizer import PythonASTOptimizer from basilisp.lang.interfaces import ISeq diff --git a/src/basilisp/lang/compiler/analyzer.py b/src/basilisp/lang/compiler/analyzer.py index 1ff3a86f..956df14c 100644 --- a/src/basilisp/lang/compiler/analyzer.py +++ b/src/basilisp/lang/compiler/analyzer.py @@ -117,9 +117,21 @@ PyTuple, ) from basilisp.lang.compiler.nodes import Queue as QueueNode -from basilisp.lang.compiler.nodes import Quote, Recur, Reify, Require, RequireAlias +from basilisp.lang.compiler.nodes import ( + Quote, + Recur, + Reify, + Require, + RequireAlias, +) from basilisp.lang.compiler.nodes import Set as SetNode -from basilisp.lang.compiler.nodes import SetBang, SpecialFormNode, Throw, Try, VarRef +from basilisp.lang.compiler.nodes import ( + SetBang, + SpecialFormNode, + Throw, + Try, + VarRef, +) from basilisp.lang.compiler.nodes import Vector as VectorNode from basilisp.lang.compiler.nodes import ( WithMeta, diff --git a/src/basilisp/lang/compiler/generator.py b/src/basilisp/lang/compiler/generator.py index 26b128b3..87a4a184 100644 --- a/src/basilisp/lang/compiler/generator.py +++ b/src/basilisp/lang/compiler/generator.py @@ -86,11 +86,25 @@ PyTuple, ) from basilisp.lang.compiler.nodes import Queue as QueueNode -from basilisp.lang.compiler.nodes import Quote, Recur, Reify, Require +from basilisp.lang.compiler.nodes import ( + Quote, + Recur, + Reify, + Require, +) from basilisp.lang.compiler.nodes import Set as SetNode -from basilisp.lang.compiler.nodes import SetBang, T_withmeta, Throw, Try, VarRef +from basilisp.lang.compiler.nodes import ( + SetBang, + T_withmeta, + Throw, + Try, + VarRef, +) from basilisp.lang.compiler.nodes import Vector as VectorNode -from basilisp.lang.compiler.nodes import WithMeta, Yield +from basilisp.lang.compiler.nodes import ( + WithMeta, + Yield, +) from basilisp.lang.compiler.utils import ( ast_AsyncFunctionDef, ast_ClassDef, @@ -392,7 +406,7 @@ def attr_node(node, idx): def _simple_ast_generator( - gen_ast: Callable[P_simplegen, T_pynode] + gen_ast: Callable[P_simplegen, T_pynode], ) -> Callable[P_simplegen, GeneratedPyAST[T_pynode]]: """Wrap simpler AST generators to return a GeneratedPyAST.""" @@ -1737,7 +1751,7 @@ def __fn_meta( def __kwargs_support_decorator( - node: Union[Fn, DefTypeMethodArity, DefTypeClassMethod, DefTypeStaticMethod] + node: Union[Fn, DefTypeMethodArity, DefTypeClassMethod, DefTypeStaticMethod], ) -> Iterable[ast.expr]: if node.kwarg_support is None: return @@ -3533,8 +3547,8 @@ def _py_tuple_to_py_ast( def _with_meta_to_py_ast( ctx: GeneratorContext, node: WithMeta[T_withmeta], - *args: P_generator.args, - **kwargs: P_generator.kwargs, + *args, + **kwargs, ) -> GeneratedPyAST[ast.expr]: """Generate a Python AST node for Python interop method calls.""" assert node.op == NodeOp.WITH_META diff --git a/src/basilisp/lang/runtime.py b/src/basilisp/lang/runtime.py index b8526b9a..189513b9 100644 --- a/src/basilisp/lang/runtime.py +++ b/src/basilisp/lang/runtime.py @@ -2098,7 +2098,7 @@ def wrapped_f(*args, **kwargs): def _basilisp_fn( - arities: tuple[Union[int, kw.Keyword], ...] + arities: tuple[Union[int, kw.Keyword], ...], ) -> Callable[..., BasilispFunction]: """Create a Basilisp function, setting meta and supplying a with_meta method implementation.""" diff --git a/src/basilisp/main.py b/src/basilisp/main.py index dd188017..ea436d01 100644 --- a/src/basilisp/main.py +++ b/src/basilisp/main.py @@ -1,5 +1,6 @@ import importlib -import site +import os +import sysconfig from pathlib import Path from typing import Optional @@ -56,42 +57,41 @@ def bootstrap( getattr(mod, fn_name)() -def bootstrap_python(site_packages: Optional[list[str]] = None) -> None: - """Bootstrap a Python installation by installing a ``.pth`` file in the first - available ``site-packages`` directory (as by - :external:py:func:`site.getsitepackages`). +def bootstrap_python(site_packages: Optional[str] = None) -> str: + """Bootstrap a Python installation by installing a ``.pth`` file + in ``site-packages`` directory (corresponding to "purelib" in + :external:py:func:`sysconfig.get_paths`). Returns the path to the + ``.pth`` file. Subsequent startups of the Python interpreter will have Basilisp already bootstrapped and available to run.""" if site_packages is None: # pragma: no cover - site_packages = site.getsitepackages() + site_packages = sysconfig.get_paths()["purelib"] - assert site_packages, "Expected at least one site-package directory" + assert site_packages, "Expected a site-package directory" - for d in site_packages: - p = Path(d) - with open(p / "basilispbootstrap.pth", mode="w") as f: - f.write("import basilisp.sitecustomize") - break + pth = Path(site_packages) / "basilispbootstrap.pth" + with open(pth, mode="w") as f: + f.write("import basilisp.sitecustomize") + return str(pth) -def unbootstrap_python(site_packages: Optional[list[str]] = None) -> list[str]: - """Remove any `basilispbootstrap.pth` files found in any Python site-packages - directory (as by :external:py:func:`site.getsitepackages`). Return a list of - removed filenames.""" + +def unbootstrap_python(site_packages: Optional[str] = None) -> Optional[str]: + """Remove the `basilispbootstrap.pth` file found in the Python site-packages + directory (corresponding to "purelib" in :external:py:func:`sysconfig.get_paths`). + Return the path of the removed file, if any.""" if site_packages is None: # pragma: no cover - site_packages = site.getsitepackages() - - assert site_packages, "Expected at least one site-package directory" - - removed = [] - for d in site_packages: - p = Path(d) - for file in p.glob("basilispbootstrap.pth"): - try: - file.unlink() - except FileNotFoundError: # pragma: no cover - pass - else: - removed.append(str(file)) + site_packages = sysconfig.get_paths()["purelib"] + + assert site_packages, "Expected a site-package directory" + + removed = None + pth = Path(site_packages) / "basilispbootstrap.pth" + try: + os.unlink(pth) + except FileNotFoundError: # pragma: no cover + pass + else: + removed = str(pth) return removed diff --git a/src/basilisp/process.lpy b/src/basilisp/process.lpy index 35a75cfd..90142246 100644 --- a/src/basilisp/process.lpy +++ b/src/basilisp/process.lpy @@ -265,7 +265,7 @@ (defn exec "Execute a command as by :lpy:fn:`start` and, upon successful return, return the - captured value of the process ``stdout`` as by :lpy:fn:`basilisp.core/slurp`. + captured value of the process ``stdout`` as if by :lpy:fn:`basilisp.core/slurp`. If ``opts`` are specified, they should be provided as a map in the first argument position. ``opts`` are exactly the same as those in :lpy:fn:`start`. @@ -273,12 +273,14 @@ If the return code is non-zero, throw :external:py:exc:`subprocess.CalledProcessError`." [& opts+args] - (let [process (apply start opts+args) - retcode (.wait process)] + (let [process (apply start opts+args) + [stdout _] (.communicate process) + retcode (.-returncode process)] (if (zero? retcode) - (if-let [out (.-stdout process)] - (slurp out) - "") + (cond + (byte-string? stdout) (.decode stdout "utf-8") + stdout stdout + :else "") (throw (subprocess/CalledProcessError retcode (.-args process) diff --git a/tests/basilisp/cli_test.py b/tests/basilisp/cli_test.py index 8072196a..70724554 100644 --- a/tests/basilisp/cli_test.py +++ b/tests/basilisp/cli_test.py @@ -10,6 +10,7 @@ import sys import tempfile import time +import venv from collections.abc import Sequence from threading import Thread from typing import Optional @@ -108,6 +109,7 @@ def test_install(self, tmp_path: pathlib.Path, run_cli): assert bootstrap_file.read_text() == "import basilisp.sitecustomize" assert res.out == ( + f"(Added {bootstrap_file})\n\n" "Your Python installation has been bootstrapped! You can undo this at any " "time with with `basilisp bootstrap --uninstall`.\n" ) @@ -135,6 +137,41 @@ def test_install_quiet(self, tmp_path: pathlib.Path, run_cli, capsys): res = capsys.readouterr() assert res.out == "" + @pytest.mark.slow + def test_install_import(self, tmp_path: pathlib.Path): + venv_path = tmp_path / "venv" + venv.create(venv_path, with_pip=True) + + venv_bin = venv_path / ("Scripts" if sys.platform == "win32" else "bin") + pip_path = venv_bin / "pip" + python_path = venv_bin / "python" + basilisp_path = venv_bin / "basilisp" + + result = subprocess.run( + [pip_path, "install", "."], capture_output=True, text=True, cwd=os.getcwd() + ) + + lpy_file = tmp_path / "boottest.lpy" + lpy_file.write_text("(ns boottest) (defn abc [] (println (+ 155 4)))") + + cmd_import = [python_path, "-c", "import boottest; boottest.abc()"] + result = subprocess.run( + cmd_import, capture_output=True, text=True, cwd=tmp_path + ) + assert "No module named 'boottest'" in result.stderr, result + + result = subprocess.run( + [basilisp_path, "bootstrap"], capture_output=True, text=True, cwd=tmp_path + ) + assert ( + "Your Python installation has been bootstrapped!" in result.stdout + ), result + + result = subprocess.run( + cmd_import, capture_output=True, text=True, cwd=tmp_path + ) + assert result.stdout.strip() == "159", result + def test_nothing_to_uninstall(self, tmp_path: pathlib.Path, run_cli, capsys): bootstrap_file = tmp_path / "basilispbootstrap.pth" assert not bootstrap_file.exists() diff --git a/tests/basilisp/test_process.lpy b/tests/basilisp/test_process.lpy index 70584358..0ab52d6e 100644 --- a/tests/basilisp/test_process.lpy +++ b/tests/basilisp/test_process.lpy @@ -1,5 +1,6 @@ (ns tests.basilisp.test-process - (:import pathlib + (:import os + pathlib subprocess sys) (:require @@ -141,10 +142,12 @@ (is (= "" (process/exec sys/executable "-c" "pass"))) (is (= "" (process/exec {:out :inherit} sys/executable "-c" "print(\"hi\")"))) (is (= "" (process/exec sys/executable "-c" "import sys; print(\"hi\", file=sys.stderr)"))) - (is (= "hi\n" (process/exec sys/executable "-c" "print(\"hi\")"))) + (is (= (str "hi" os/linesep) + (process/exec sys/executable "-c" "print(\"hi\")"))) (is (thrown? subprocess/CalledProcessError (process/exec sys/executable "-c" "import sys; sys.exit(2)"))) - (is (= "BASILISP\n" (process/exec {:env {"PYTHON_HOSTED_LANG" "BASILISP"}} - sys/executable - "-c" - "import os; print(os.environ[\"PYTHON_HOSTED_LANG\"])")))) + (is (= (str "BASILISP" os/linesep) + (process/exec {:env {"PYTHON_HOSTED_LANG" "BASILISP"}} + sys/executable + "-c" + "import os; print(os.environ[\"PYTHON_HOSTED_LANG\"])")))) diff --git a/tests/basilisp/testrunner_test.py b/tests/basilisp/testrunner_test.py index 8791543d..3b259b56 100644 --- a/tests/basilisp/testrunner_test.py +++ b/tests/basilisp/testrunner_test.py @@ -1,4 +1,6 @@ import platform +import shutil +import subprocess import sys import pytest @@ -263,6 +265,30 @@ def test_fixtures_with_errors( result.assert_outcomes(passed=passes, failed=failures, errors=errors) +def test_basilisp_test_noargs(pytester: pytest.Pytester): + runtime.Namespace.remove(sym.symbol("a.test-path")) + + code = """ + (ns tests.test-path + (:require + [basilisp.test :refer [deftest is]])) + (deftest passing-test + (is true)) + """ + pytester.makefile(".lpy", **{"./tests/test_path": code}) + + # I couldn't find a way to directly manipulate the pytester's + # `sys.path` with the precise control needed by this test, so we're + # invoking `basilisp test` directly as a subprocess instead ... + basilisp = shutil.which("basilisp") + cmd = [basilisp, "test"] + result = subprocess.run(cmd, capture_output=True, text=True, cwd=pytester.path) + + assert "==== 1 passed" in result.stdout.strip() + + assert result.returncode == 0 + + def test_ns_in_syspath(pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch): runtime.Namespace.remove(sym.symbol("a.test-path")) @@ -324,11 +350,18 @@ def test_ns_not_in_syspath(pytester: pytest.Pytester): (:require [basilisp.test :refer [deftest is]])) """ - pytester.makefile(".lpy", **{"./test/a/test_path": code}) + # In this test, we use a `testabc` directory instead of `test`, as + # the latter can cause issues on macOS. Specifically, macOS has a + # `/Library/Frameworks/Python.framework/Versions/3.xx/lib/python3.13/test` + # directory is picked up, resulting in a slightly different error + # message. + pytester.makefile(".lpy", **{"./testabc/a/test_path": code}) pytester.syspathinsert() - result: pytest.RunResult = pytester.runpytest("test") + result: pytest.RunResult = pytester.runpytest("testabc") assert result.ret != 0 - result.stdout.fnmatch_lines(["*ModuleNotFoundError: No module named 'test.a'"]) + result.stdout.fnmatch_lines( + ["*ModuleNotFoundError: Module named 'a.test-path' is not in sys.path"] + ) def test_ns_with_underscore(pytester: pytest.Pytester): diff --git a/tests/conftest.py b/tests/conftest.py index c6481d5f..162f6b80 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1,19 @@ +import pytest + pytest_plugins = ["pytester"] + + +def pytest_configure(config): + config.addinivalue_line("markers", "slow: Marks tests as slow") + + +def pytest_addoption(parser): + parser.addoption("--run-slow", action="store_true", help="Run slow tests") + + +@pytest.fixture(autouse=True) +def skip_slow(request): + if request.node.get_closest_marker("slow") and not request.config.getoption( + "--run-slow" + ): + pytest.skip("Skipping slow test. Use --run-slow to enable.") diff --git a/tox.ini b/tox.ini index 868a76ba..9e6e2014 100644 --- a/tox.ini +++ b/tox.ini @@ -20,6 +20,8 @@ commands = -m pytest \ --import-mode=importlib \ --junitxml={toxinidir}/junit/pytest/{envname}.xml \ + # also enable pytest marked as slow \ + --run-slow \ {posargs} [testenv:coverage]