diff --git a/spin/cmds/meson.py b/spin/cmds/meson.py index c188183..a16c390 100644 --- a/spin/cmds/meson.py +++ b/spin/cmds/meson.py @@ -13,9 +13,6 @@ from .util import get_commands, get_config from .util import run as _run -install_dir = "build-install" -build_dir = "build" - class GcovReportFormat(str, Enum): html = "html" @@ -68,16 +65,15 @@ def _is_editable_install_of_same_source(distname): return editable_path and os.path.samefile(_editable_install_path(distname), ".") -def _set_pythonpath(quiet=False): +def _set_pythonpath(build_dir, quiet=False): """Set first entry of PYTHONPATH to site packages directory. + For editable installs, leave the PYTHONPATH alone. + Returns ------- site_packages """ - site_packages = _get_site_packages() - env = os.environ - cfg = get_config() distname = cfg.get("project.name", None) if distname: @@ -101,6 +97,9 @@ def _set_pythonpath(quiet=False): fg="bright_red", ) + site_packages = _get_site_packages(build_dir) + env = os.environ + if "PYTHONPATH" in env: env["PYTHONPATH"] = f"{site_packages}{os.pathsep}{env['PYTHONPATH']}" else: @@ -114,7 +113,12 @@ def _set_pythonpath(quiet=False): return site_packages -def _get_site_packages(): +def _get_install_dir(build_dir): + return f"{build_dir}-install" + + +def _get_site_packages(build_dir): + install_dir = _get_install_dir(build_dir) try: cfg = get_config() distname = cfg.get("project.name", None) @@ -168,9 +172,9 @@ def _meson_version(): pass -def _meson_version_configured(): +def _meson_version_configured(build_dir): try: - meson_info_fn = os.path.join("build", "meson-info", "meson-info.json") + meson_info_fn = os.path.join(build_dir, "meson-info", "meson-info.json") with open(meson_info_fn) as f: meson_info = json.load(f) return meson_info["meson_version"]["full"] @@ -194,7 +198,7 @@ def _meson_coverage_configured() -> bool: return False -def _check_coverage_tool_installation(coverage_type: GcovReportFormat): +def _check_coverage_tool_installation(coverage_type: GcovReportFormat, build_dir): requirements = { # https://github.com/mesonbuild/meson/blob/6e381714c7cb15877e2bcaa304b93c212252ada3/docs/markdown/Unit-tests.md?plain=1#L49-L62 GcovReportFormat.html: ["Gcovr/GenHTML", "lcov"], GcovReportFormat.xml: ["Gcovr (version 3.3 or higher)"], @@ -226,6 +230,16 @@ def _check_coverage_tool_installation(coverage_type: GcovReportFormat): ) +build_dir_option = click.option( + "-C", + "--build-dir", + default="build", + show_envvar=True, + envvar="SPIN_BUILD_DIR", + help="Meson build directory; package is installed into './{build-dir}-install'.", +) + + @click.command() @click.option("-j", "--jobs", help="Number of parallel tasks to launch", type=int) @click.option("--clean", is_flag=True, help="Clean build directory before build") @@ -238,12 +252,21 @@ def _check_coverage_tool_installation(coverage_type: GcovReportFormat): help="Enable C code coverage using `gcov`. Use `spin test --gcov` to generate reports.", ) @click.argument("meson_args", nargs=-1) +@build_dir_option def build( - *, meson_args, jobs=None, clean=False, verbose=False, gcov=False, quiet=False + *, + meson_args, + jobs=None, + clean=False, + verbose=False, + gcov=False, + quiet=False, + build_dir=None, ): """🔧 Build package with Meson/ninja - The package is installed to `build-install`. + The package is installed to `build-install` (unless a different + build directory is specified with `-C`). MESON_ARGS are passed through e.g.: @@ -257,7 +280,23 @@ def build( or set CFLAGS appropriately: CFLAGS="-O0 -g" spin build + + Build into a different build/build-install directory via the + `-C/--build-dir` flag: + + spin build -C build-for-feature-x + + This feature is useful in combination with a shell alias, e.g.: + + $ alias spin-clang="SPIN_BUILD_DIR=build-clang CC=clang spin" + + Which can then be used to build (`spin-clang build`), to test (`spin-clang test ...`), etc. + """ + abs_build_dir = os.path.abspath(build_dir) + install_dir = _get_install_dir(build_dir) + abs_install_dir = os.path.abspath(install_dir) + cfg = get_config() distname = cfg.get("project.name", None) if distname and _is_editable_install_of_same_source(distname): @@ -283,7 +322,7 @@ def build( if os.path.isdir(install_dir): shutil.rmtree(install_dir) - if not (os.path.exists(build_dir) and _meson_version_configured()): + if not (os.path.exists(build_dir) and _meson_version_configured(build_dir)): p = _run(setup_cmd, sys_exit=False, output=not quiet) if p.returncode != 0: raise RuntimeError( @@ -293,7 +332,7 @@ def build( # Build dir has been configured; check if it was configured by # current version of Meson - if (_meson_version() != _meson_version_configured()) or ( + if (_meson_version() != _meson_version_configured(build_dir)) or ( gcov and not _meson_coverage_configured() ): _run(setup_cmd + ["--reconfigure"], output=not quiet) @@ -301,6 +340,9 @@ def build( # Any other conditions that warrant a reconfigure? compile_flags = ["-v"] if verbose else [] + if jobs: + compile_flags += ["-j", str(jobs)] + p = _run( _meson_cli() + ["compile"] + compile_flags + ["-C", build_dir], sys_exit=True, @@ -314,7 +356,9 @@ def build( "-C", build_dir, "--destdir", - f"../{install_dir}", + install_dir + if os.path.isabs(install_dir) + else os.path.relpath(abs_install_dir, abs_build_dir), ], output=(not quiet) and verbose, ) @@ -372,6 +416,7 @@ def _get_configured_command(command_name): default="html", help=f"Format of the gcov report. Can be one of {', '.join(e.value for e in GcovReportFormat)}.", ) +@build_dir_option @click.pass_context def test( ctx, @@ -383,6 +428,7 @@ def test( coverage=False, gcov=None, gcov_format=None, + build_dir=None, ): """🔧 Run tests @@ -469,13 +515,11 @@ def test( "Invoking `build` prior to running tests:", bold=True, fg="bright_green" ) if gcov is not None: - ctx.invoke(build_cmd, gcov=bool(gcov)) + ctx.invoke(build_cmd, build_dir=build_dir, gcov=bool(gcov)) else: - ctx.invoke(build_cmd) + ctx.invoke(build_cmd, build_dir=build_dir) - site_path = _set_pythonpath() - if site_path: - print(f'$ export PYTHONPATH="{site_path}"') + site_path = _set_pythonpath(build_dir) # Sanity check that library built properly # @@ -520,6 +564,7 @@ def test( else: cmd = ["pytest"] + install_dir = _get_install_dir(build_dir) if not os.path.exists(install_dir): os.mkdir(install_dir) @@ -534,7 +579,7 @@ def test( bold=True, fg="bright_yellow", ) - _check_coverage_tool_installation(gcov_format) + _check_coverage_tool_installation(gcov_format, build_dir) # Generate report click.secho( @@ -568,8 +613,9 @@ def test( @click.command() @click.option("--code", "-c", help="Python program passed in as a string") @click.argument("gdb_args", nargs=-1) +@build_dir_option @click.pass_context -def gdb(ctx, *, code, gdb_args): +def gdb(ctx, *, code, gdb_args, build_dir): """👾 Execute code through GDB spin gdb -c 'import numpy as np; print(np.__version__)' @@ -595,9 +641,9 @@ def gdb(ctx, *, code, gdb_args): click.secho( "Invoking `build` prior to invoking gdb:", bold=True, fg="bright_green" ) - ctx.invoke(build_cmd) + ctx.invoke(build_cmd, build_dir=build_dir) - _set_pythonpath() + _set_pythonpath(build_dir) gdb_args = list(gdb_args) if gdb_args and gdb_args[0].endswith(".py"): @@ -620,8 +666,9 @@ def gdb(ctx, *, code, gdb_args): @click.command() @click.argument("ipython_args", nargs=-1) +@build_dir_option @click.pass_context -def ipython(ctx, *, ipython_args): +def ipython(ctx, *, ipython_args, build_dir): """💻 Launch IPython shell with PYTHONPATH set IPYTHON_ARGS are passed through directly to IPython, e.g.: @@ -633,9 +680,9 @@ def ipython(ctx, *, ipython_args): click.secho( "Invoking `build` prior to invoking ipython:", bold=True, fg="bright_green" ) - ctx.invoke(build_cmd) + ctx.invoke(build_cmd, build_dir=build_dir) - p = _set_pythonpath() + p = _set_pythonpath(build_dir) if p: print(f'💻 Launching IPython with PYTHONPATH="{p}"') _run(["ipython", "--ignore-cwd"] + list(ipython_args), replace=True) @@ -643,8 +690,9 @@ def ipython(ctx, *, ipython_args): @click.command() @click.argument("shell_args", nargs=-1) +@build_dir_option @click.pass_context -def shell(ctx, shell_args=[]): +def shell(ctx, shell_args=[], build_dir=None): """💻 Launch shell with PYTHONPATH set SHELL_ARGS are passed through directly to the shell, e.g.: @@ -659,9 +707,9 @@ def shell(ctx, shell_args=[]): click.secho( "Invoking `build` prior to invoking shell:", bold=True, fg="bright_green" ) - ctx.invoke(build_cmd) + ctx.invoke(build_cmd, build_dir=build_dir) - p = _set_pythonpath() + p = _set_pythonpath(build_dir) if p: print(f'💻 Launching shell with PYTHONPATH="{p}"') @@ -674,8 +722,9 @@ def shell(ctx, shell_args=[]): @click.command() @click.argument("python_args", nargs=-1) +@build_dir_option @click.pass_context -def python(ctx, *, python_args): +def python(ctx, *, python_args, build_dir): """🐍 Launch Python shell with PYTHONPATH set PYTHON_ARGS are passed through directly to Python, e.g.: @@ -687,9 +736,9 @@ def python(ctx, *, python_args): click.secho( "Invoking `build` prior to invoking Python:", bold=True, fg="bright_green" ) - ctx.invoke(build_cmd) + ctx.invoke(build_cmd, build_dir=build_dir) - p = _set_pythonpath() + p = _set_pythonpath(build_dir) if p: print(f'🐍 Launching Python with PYTHONPATH="{p}"') @@ -714,9 +763,10 @@ def python(ctx, *, python_args): @click.command(context_settings={"ignore_unknown_options": True}) +@build_dir_option @click.argument("args", nargs=-1) @click.pass_context -def run(ctx, *, args): +def run(ctx, *, args, build_dir=None): """🏁 Run a shell command with PYTHONPATH set \b @@ -740,7 +790,7 @@ def run(ctx, *, args): # Redirect spin generated output with contextlib.redirect_stdout(sys.stderr): # Also ask build to be quiet - ctx.invoke(build_cmd, quiet=True) + ctx.invoke(build_cmd, build_dir=build_dir, quiet=True) is_posix = sys.platform in ("linux", "darwin") shell = len(args) == 1 @@ -751,7 +801,7 @@ def run(ctx, *, args): # On Windows, we're going to try to use bash cmd_args = ["bash", "-c", cmd_args] - _set_pythonpath(quiet=True) + _set_pythonpath(build_dir, quiet=True) p = _run(cmd_args, echo=False, shell=shell, sys_exit=False) # Is the user trying to run a Python script, without calling the Python interpreter? @@ -789,6 +839,7 @@ def run(ctx, *, args): help="Sphinx gallery: enable/disable plots", ) @click.option("--jobs", "-j", default="auto", help="Number of parallel build jobs") +@build_dir_option @click.pass_context def docs( ctx, @@ -799,6 +850,7 @@ def docs( jobs, sphinx_gallery_plot, clean_dirs=None, + build_dir=None, ): """📖 Build Sphinx documentation @@ -853,10 +905,10 @@ def docs( click.secho( "Invoking `build` prior to building docs:", bold=True, fg="bright_green" ) - ctx.invoke(build_cmd) + ctx.invoke(build_cmd, build_dir=build_dir) try: - site_path = _get_site_packages() + site_path = _get_site_packages(build_dir) except FileNotFoundError: print("No built numpy found; run `spin build` first.") sys.exit(1) @@ -889,8 +941,9 @@ def docs( @click.command() @click.option("--code", "-c", help="Python program passed in as a string") @click.argument("lldb_args", nargs=-1) +@build_dir_option @click.pass_context -def lldb(ctx, *, code, lldb_args): +def lldb(ctx, *, code, lldb_args, build_dir=None): """👾 Execute code through LLDB spin lldb -c 'import numpy as np; print(np.__version__)' @@ -918,9 +971,9 @@ def lldb(ctx, *, code, lldb_args): click.secho( "Invoking `build` prior to invoking lldb:", bold=True, fg="bright_green" ) - ctx.invoke(build_cmd) + ctx.invoke(build_cmd, build_dir=build_dir) - _set_pythonpath() + _set_pythonpath(build_dir) lldb_args = list(lldb_args) if code: diff --git a/spin/cmds/util.py b/spin/cmds/util.py index 7ce7401..2a840d3 100644 --- a/spin/cmds/util.py +++ b/spin/cmds/util.py @@ -1,15 +1,18 @@ +from __future__ import ( + annotations, # noqa: F401 # TODO: remove once only >3.8 is supported +) + import os import shlex import subprocess import sys -from typing import Optional import click def run( cmd: list[str], - cwd: Optional[str] = None, # in 3.10 and up: str | None + cwd: str | None = None, # in 3.10 and up: str | None replace: bool = False, sys_exit: bool = True, output: bool = True, diff --git a/spin/color_format.py b/spin/color_format.py index f553943..4cf6450 100644 --- a/spin/color_format.py +++ b/spin/color_format.py @@ -36,7 +36,7 @@ def write_dl(self, items): }, default={"bold": True, "fg": "cyan"}, ) - val_fmt = RegexpFormatter({r"\[default: .*?\]": {"dim": True}}) + val_fmt = RegexpFormatter({r"\[(env var: .*?; )?default: .*?\]": {"dim": True}}) items = [(key_fmt(key), val_fmt(val)) for (key, val) in items] super().write_dl(items) diff --git a/spin/tests/test_build_cmds.py b/spin/tests/test_build_cmds.py index 39e0ae7..70d4af9 100644 --- a/spin/tests/test_build_cmds.py +++ b/spin/tests/test_build_cmds.py @@ -5,7 +5,14 @@ from pathlib import Path import pytest -from testutil import skip_on_windows, skip_unless_linux, skip_unless_macos, spin, stdout +from testutil import ( + skip_on_windows, + skip_py_lt_311, + skip_unless_linux, + skip_unless_macos, + spin, + stdout, +) from spin.cmds.util import run @@ -147,3 +154,23 @@ def test_lldb(): "--batch", ) assert "hi" in stdout(p) + + +@skip_py_lt_311 # python command does not run on older pythons +def test_parallel_builds(): + spin("build") + spin("build", "-C", "parallel/build") + p = spin("python", "--", "-c", "import example_pkg; print(example_pkg.__file__)") + example_pkg_path = stdout(p).split("\n")[-1] + p = spin( + "python", + "-C", + "parallel/build", + "--", + "-c", + "import example_pkg; print(example_pkg.__file__)", + ) + example_pkg_parallel_path = stdout(p).split("\n")[-1] + assert "build-install" in example_pkg_path + assert "parallel/build-install" in example_pkg_parallel_path + assert "parallel/build-install" not in example_pkg_path diff --git a/spin/tests/test_meson.py b/spin/tests/test_meson.py index fa355ee..79aaf2d 100644 --- a/spin/tests/test_meson.py +++ b/spin/tests/test_meson.py @@ -15,91 +15,84 @@ def make_paths(root, paths): os.makedirs(pjoin(root, p.lstrip("/"))) -def test_path_discovery(monkeypatch): +def test_path_discovery(): version = sys.version_info X, Y = version.major, version.minor - # With multiple site-packages, choose the one that - # matches the current Python version + # With multiple site-packages, choose the one that matches the + # current Python version with tempfile.TemporaryDirectory() as d: - with monkeypatch.context() as m: - install_dir = pjoin(d, "build-install") - m.setattr(meson, "install_dir", install_dir) - - make_paths( - install_dir, - [ - f"/usr/lib64/python{X}.{Y}/site-packages", - f"/usr/lib64/python{X}.{Y + 1}/site-packages", - f"/usr/lib64/python{X}.{Y + 2}/site-packages", - ], - ) - assert ( - normpath(f"/usr/lib64/python{X}.{Y}/site-packages") - in meson._get_site_packages() - ) + build_dir = pjoin(d, "./build") + install_dir = pjoin(d, "./build-install") + + make_paths( + install_dir, + [ + f"/usr/lib64/python{X}.{Y}/site-packages", + f"/usr/lib64/python{X}.{Y + 1}/site-packages", + f"/usr/lib64/python{X}.{Y + 2}/site-packages", + ], + ) + assert normpath( + f"/usr/lib64/python{X}.{Y}/site-packages" + ) in meson._get_site_packages(build_dir) # Debian uses dist-packages with tempfile.TemporaryDirectory() as d: - with monkeypatch.context() as m: - install_dir = pjoin(d, "build-install") - m.setattr(meson, "install_dir", install_dir) - - make_paths( - install_dir, - [ - f"/usr/lib64/python{X}.{Y}/dist-packages", - ], - ) - assert ( - normpath(f"/usr/lib64/python{X}.{Y}/dist-packages") - in meson._get_site_packages() - ) + build_dir = pjoin(d, "./build") + install_dir = pjoin(d, "./build-install") + + make_paths( + install_dir, + [ + f"/usr/lib64/python{X}.{Y}/dist-packages", + ], + ) + assert normpath( + f"/usr/lib64/python{X}.{Y}/dist-packages" + ) in meson._get_site_packages(build_dir) # If there is no version information in site-packages, # use whatever site-packages can be found with tempfile.TemporaryDirectory() as d: - with monkeypatch.context() as m: - install_dir = pjoin(d, "build-install") - m.setattr(meson, "install_dir", install_dir) + build_dir = pjoin(d, "./build") + install_dir = pjoin(d, "./build-install") - make_paths(install_dir, ["/Python3/site-packages"]) - assert normpath("/Python3/site-packages") in meson._get_site_packages() + make_paths(install_dir, ["/Python3/site-packages"]) + assert normpath("/Python3/site-packages") in meson._get_site_packages(build_dir) # Raise if no site-package directory present with tempfile.TemporaryDirectory() as d: - with monkeypatch.context() as m: - install_dir = pjoin(d, "build-install") - m.setattr(meson, "install_dir", install_dir) + install_dir = pjoin(d, "-install") - with pytest.raises(FileNotFoundError): - meson._get_site_packages() + with pytest.raises(FileNotFoundError): + meson._get_site_packages(build_dir) # If there are multiple site-package paths, but without version information, # refuse the temptation to guess with tempfile.TemporaryDirectory() as d: - install_dir = pjoin(d, "build-install") + build_dir = pjoin(d, "./build") + install_dir = pjoin(d, "./build-install") make_paths( install_dir, ["/Python3/x/site-packages", "/Python3/y/site-packages"] ) with pytest.raises(FileNotFoundError): - meson._get_site_packages() + meson._get_site_packages(build_dir) # Multiple site-package paths found, but none that matches our Python with tempfile.TemporaryDirectory() as d: - with monkeypatch.context() as m: - install_dir = pjoin(d, "build-install") - m.setattr(meson, "install_dir", install_dir) - - make_paths( - install_dir, - [ - f"/usr/lib64/python{X}.{Y + 1}/site-packages", - f"/usr/lib64/python{X}.{Y + 2}/site-packages", - ], - ) - with pytest.raises(FileNotFoundError): - meson._get_site_packages() + build_dir = pjoin(d, "./build") + install_dir = pjoin(d, "./build-install") + + make_paths( + install_dir, + [ + f"/usr/lib64/python{X}.{Y + 1}/site-packages", + f"/usr/lib64/python{X}.{Y + 2}/site-packages", + ], + ) + with pytest.raises(FileNotFoundError): + meson._get_site_packages(build_dir) def test_meson_cli_discovery(monkeypatch): diff --git a/spin/tests/testutil.py b/spin/tests/testutil.py index 99333dd..fb81540 100644 --- a/spin/tests/testutil.py +++ b/spin/tests/testutil.py @@ -17,6 +17,10 @@ not sys.platform.startswith("darwin"), reason="Skipped; platform not macOS" ) +skip_py_lt_311 = pytest.mark.skipif( + sys.version_info[:2] < (3, 11), reason="Skipped; Python < 3.11" +) + def spin(*args, **user_kwargs): args = (str(el) for el in args)