blob: 09556e67bbcec650b5b37d0caf41f23d0d425100 [file] [log] [blame]
import os
import shutil
import sys
import argparse
import tempfile
import pathlib
import shutil
import json
import pathlib
import click
from spin import util
from spin.cmds import meson
# Check that the meson git submodule is present
curdir = pathlib.Path(__file__).parent
meson_import_dir = curdir.parent / 'vendored-meson' / 'meson' / 'mesonbuild'
if not meson_import_dir.exists():
raise RuntimeError(
'The `vendored-meson/meson` git submodule does not exist! ' +
'Run `git submodule update --init` to fix this problem.'
)
@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"
)
@click.option(
"-v", "--verbose", is_flag=True,
help="Print all build output, even installation"
)
@click.option(
"--with-scipy-openblas", type=click.Choice(["32", "64"]),
default=None,
help="Build with pre-installed scipy-openblas32 or scipy-openblas64 wheel"
)
@click.argument("meson_args", nargs=-1)
@click.pass_context
def build(ctx, meson_args, with_scipy_openblas, jobs=None, clean=False, verbose=False, quiet=False):
"""🔧 Build package with Meson/ninja and install
MESON_ARGS are passed through e.g.:
spin build -- -Dpkg_config_path=/lib64/pkgconfig
The package is installed to build-install
By default builds for release, to be able to use a debugger set CFLAGS
appropriately. For example, for linux use
CFLAGS="-O0 -g" spin build
"""
# XXX keep in sync with upstream build
if with_scipy_openblas:
_config_openblas(with_scipy_openblas)
ctx.params.pop("with_scipy_openblas", None)
ctx.forward(meson.build)
@click.command()
@click.argument("sphinx_target", default="html")
@click.option(
"--clean", is_flag=True,
default=False,
help="Clean previously built docs before building"
)
@click.option(
"--build/--no-build",
"first_build",
default=True,
help="Build numpy before generating docs",
)
@click.option(
'--jobs', '-j',
metavar='N_JOBS',
default="auto",
help="Number of parallel build jobs"
)
@click.pass_context
def docs(ctx, sphinx_target, clean, first_build, jobs):
"""📖 Build Sphinx documentation
By default, SPHINXOPTS="-W", raising errors on warnings.
To build without raising on warnings:
SPHINXOPTS="" spin docs
To list all Sphinx targets:
spin docs targets
To build another Sphinx target:
spin docs TARGET
E.g., to build a zipfile of the html docs for distribution:
spin docs dist
"""
meson.docs.ignore_unknown_options = True
ctx.forward(meson.docs)
@click.command()
@click.argument("pytest_args", nargs=-1)
@click.option(
"-m",
"markexpr",
metavar='MARKEXPR',
default="not slow",
help="Run tests with the given markers"
)
@click.option(
"-j",
"n_jobs",
metavar='N_JOBS',
default="1",
help=("Number of parallel jobs for testing. "
"Can be set to `auto` to use all cores.")
)
@click.option(
"--tests", "-t",
metavar='TESTS',
help=("""
Which tests to run. Can be a module, function, class, or method:
\b
numpy.random
numpy.random.tests.test_generator_mt19937
numpy.random.tests.test_generator_mt19937::TestMultivariateHypergeometric
numpy.random.tests.test_generator_mt19937::TestMultivariateHypergeometric::test_edge_cases
\b
""")
)
@click.option(
'--verbose', '-v', is_flag=True, default=False
)
@click.pass_context
def test(ctx, pytest_args, markexpr, n_jobs, tests, verbose):
"""🔧 Run tests
PYTEST_ARGS are passed through directly to pytest, e.g.:
spin test -- --pdb
To run tests on a directory or file:
\b
spin test numpy/linalg
spin test numpy/linalg/tests/test_linalg.py
To report the durations of the N slowest tests:
spin test -- --durations=N
To run tests that match a given pattern:
\b
spin test -- -k "geometric"
spin test -- -k "geometric and not rgeometric"
By default, spin will run `-m 'not slow'`. To run the full test suite, use
`spin -m full`
For more, see `pytest --help`.
""" # noqa: E501
if (not pytest_args) and (not tests):
pytest_args = ('numpy',)
if '-m' not in pytest_args:
if markexpr != "full":
pytest_args = ('-m', markexpr) + pytest_args
if (n_jobs != "1") and ('-n' not in pytest_args):
pytest_args = ('-n', str(n_jobs)) + pytest_args
if tests and not ('--pyargs' in pytest_args):
pytest_args = ('--pyargs', tests) + pytest_args
if verbose:
pytest_args = ('-v',) + pytest_args
ctx.params['pytest_args'] = pytest_args
for extra_param in ('markexpr', 'n_jobs', 'tests', 'verbose'):
del ctx.params[extra_param]
ctx.forward(meson.test)
# From scipy: benchmarks/benchmarks/common.py
def _set_mem_rlimit(max_mem=None):
"""
Set address space rlimit
"""
import resource
import psutil
mem = psutil.virtual_memory()
if max_mem is None:
max_mem = int(mem.total * 0.7)
cur_limit = resource.getrlimit(resource.RLIMIT_AS)
if cur_limit[0] > 0:
max_mem = min(max_mem, cur_limit[0])
try:
resource.setrlimit(resource.RLIMIT_AS, (max_mem, cur_limit[1]))
except ValueError:
# on macOS may raise: current limit exceeds maximum limit
pass
def _commit_to_sha(commit):
p = util.run(['git', 'rev-parse', commit], output=False, echo=False)
if p.returncode != 0:
raise(
click.ClickException(
f'Could not find SHA matching commit `{commit}`'
)
)
return p.stdout.decode('ascii').strip()
def _dirty_git_working_dir():
# Changes to the working directory
p0 = util.run(['git', 'diff-files', '--quiet'])
# Staged changes
p1 = util.run(['git', 'diff-index', '--quiet', '--cached', 'HEAD'])
return (p0.returncode != 0 or p1.returncode != 0)
def _run_asv(cmd):
# Always use ccache, if installed
PATH = os.environ['PATH']
EXTRA_PATH = os.pathsep.join([
'/usr/lib/ccache', '/usr/lib/f90cache',
'/usr/local/lib/ccache', '/usr/local/lib/f90cache'
])
env = os.environ
env['PATH'] = f'EXTRA_PATH:{PATH}'
# Control BLAS/LAPACK threads
env['OPENBLAS_NUM_THREADS'] = '1'
env['MKL_NUM_THREADS'] = '1'
# Limit memory usage
try:
_set_mem_rlimit()
except (ImportError, RuntimeError):
pass
util.run(cmd, cwd='benchmarks', env=env)
@click.command()
@click.option(
'--tests', '-t',
default=None, metavar='TESTS', multiple=True,
help="Which tests to run"
)
@click.option(
'--compare', '-c',
is_flag=True,
default=False,
help="Compare benchmarks between the current branch and main "
"(unless other branches specified). "
"The benchmarks are each executed in a new isolated "
"environment."
)
@click.option(
'--verbose', '-v', is_flag=True, default=False
)
@click.option(
'--quick', '-q', is_flag=True, default=False,
help="Run each benchmark only once (timings won't be accurate)"
)
@click.argument(
'commits', metavar='',
required=False,
nargs=-1
)
@click.pass_context
def bench(ctx, tests, compare, verbose, quick, commits):
"""🏋 Run benchmarks.
\b
Examples:
\b
$ spin bench -t bench_lib
$ spin bench -t bench_random.Random
$ spin bench -t Random -t Shuffle
Two benchmark runs can be compared.
By default, `HEAD` is compared to `main`.
You can also specify the branches/commits to compare:
\b
$ spin bench --compare
$ spin bench --compare main
$ spin bench --compare main HEAD
You can also choose which benchmarks to run in comparison mode:
$ spin bench -t Random --compare
"""
if not commits:
commits = ('main', 'HEAD')
elif len(commits) == 1:
commits = commits + ('HEAD',)
elif len(commits) > 2:
raise click.ClickException(
'Need a maximum of two revisions to compare'
)
bench_args = []
for t in tests:
bench_args += ['--bench', t]
if verbose:
bench_args = ['-v'] + bench_args
if quick:
bench_args = ['--quick'] + bench_args
if not compare:
# No comparison requested; we build and benchmark the current version
click.secho(
"Invoking `build` prior to running benchmarks:",
bold=True, fg="bright_green"
)
ctx.invoke(build)
meson._set_pythonpath()
p = util.run(
['python', '-c', 'import numpy as np; print(np.__version__)'],
cwd='benchmarks',
echo=False,
output=False
)
os.chdir('..')
np_ver = p.stdout.strip().decode('ascii')
click.secho(
f'Running benchmarks on NumPy {np_ver}',
bold=True, fg="bright_green"
)
cmd = [
'asv', 'run', '--dry-run', '--show-stderr', '--python=same'
] + bench_args
_run_asv(cmd)
else:
# Ensure that we don't have uncommited changes
commit_a, commit_b = [_commit_to_sha(c) for c in commits]
if commit_b == 'HEAD' and _dirty_git_working_dir():
click.secho(
"WARNING: you have uncommitted changes --- "
"these will NOT be benchmarked!",
fg="red"
)
cmd_compare = [
'asv', 'continuous', '--factor', '1.05',
] + bench_args + [commit_a, commit_b]
_run_asv(cmd_compare)
@click.command(context_settings={
'ignore_unknown_options': True
})
@click.argument("python_args", metavar='', nargs=-1)
@click.pass_context
def python(ctx, python_args):
"""🐍 Launch Python shell with PYTHONPATH set
OPTIONS are passed through directly to Python, e.g.:
spin python -c 'import sys; print(sys.path)'
"""
env = os.environ
env['PYTHONWARNINGS'] = env.get('PYTHONWARNINGS', 'all')
ctx.forward(meson.python)
@click.command(context_settings={
'ignore_unknown_options': True
})
@click.argument("ipython_args", metavar='', nargs=-1)
@click.pass_context
def ipython(ctx, ipython_args):
"""💻 Launch IPython shell with PYTHONPATH set
OPTIONS are passed through directly to IPython, e.g.:
spin ipython -i myscript.py
"""
env = os.environ
env['PYTHONWARNINGS'] = env.get('PYTHONWARNINGS', 'all')
ctx.invoke(build)
ppath = meson._set_pythonpath()
print(f'💻 Launching IPython with PYTHONPATH="{ppath}"')
preimport = (r"import numpy as np; "
r"print(f'\nPreimported NumPy {np.__version__} as np')")
util.run(["ipython", "--ignore-cwd",
f"--TerminalIPythonApp.exec_lines={preimport}"] +
list(ipython_args))
@click.command(context_settings={"ignore_unknown_options": True})
@click.pass_context
def mypy(ctx):
"""🦆 Run Mypy tests for NumPy
"""
env = os.environ
env['NPY_RUN_MYPY_IN_TESTSUITE'] = '1'
ctx.params['pytest_args'] = [os.path.join('numpy', 'typing')]
ctx.params['markexpr'] = 'full'
ctx.forward(test)
@click.command(context_settings={
'ignore_unknown_options': True
})
@click.option(
"--with-scipy-openblas", type=click.Choice(["32", "64"]),
default=None, required=True,
help="Build with pre-installed scipy-openblas32 or scipy-openblas64 wheel"
)
def config_openblas(with_scipy_openblas):
"""🔧 Create .openblas/scipy-openblas.pc file
Also create _distributor_init_local.py
Requires a pre-installed scipy-openblas64 or scipy-openblas32
"""
_config_openblas(with_scipy_openblas)
def _config_openblas(blas_variant):
import importlib
basedir = os.getcwd()
openblas_dir = os.path.join(basedir, ".openblas")
pkg_config_fname = os.path.join(openblas_dir, "scipy-openblas.pc")
if blas_variant:
module_name = f"scipy_openblas{blas_variant}"
try:
openblas = importlib.import_module(module_name)
except ModuleNotFoundError:
raise RuntimeError(f"'pip install {module_name} first")
local = os.path.join(basedir, "numpy", "_distributor_init_local.py")
with open(local, "wt", encoding="utf8") as fid:
fid.write(f"import {module_name}\n")
os.makedirs(openblas_dir, exist_ok=True)
with open(pkg_config_fname, "wt", encoding="utf8") as fid:
fid.write(openblas.get_pkg_config().replace("\\", "/"))