blob: ee9fa38346a70b265068a586549b156302faa58c [file] [log] [blame]
import os
import shutil
import pathlib
import importlib
import subprocess
import click
import spin
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.'
)
def _get_numpy_tools(filename):
filepath = pathlib.Path('tools', filename)
spec = importlib.util.spec_from_file_location(filename.stem, filepath)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@click.command()
@click.argument(
"token",
required=True
)
@click.argument(
"revision-range",
required=True
)
def changelog(token, revision_range):
"""👩 Get change log for provided revision range
\b
Example:
\b
$ spin authors -t $GH_TOKEN --revision-range v1.25.0..v1.26.0
"""
try:
from github.GithubException import GithubException
from git.exc import GitError
changelog = _get_numpy_tools(pathlib.Path('changelog.py'))
except ModuleNotFoundError as e:
raise click.ClickException(
f"{e.msg}. Install the missing packages to use this command."
)
click.secho(
f"Generating change log for range {revision_range}",
bold=True, fg="bright_green",
)
try:
changelog.main(token, revision_range)
except GithubException as e:
raise click.ClickException(
f"GithubException raised with status: {e.status} "
f"and message: {e.data['message']}"
)
except GitError as e:
raise click.ClickException(
f"Git error in command `{' '.join(e.command)}` "
f"with error message: {e.stderr}"
)
@click.option(
"--with-scipy-openblas", type=click.Choice(["32", "64"]),
default=None,
help="Build with pre-installed scipy-openblas32 or scipy-openblas64 wheel"
)
@spin.util.extend_command(spin.cmds.meson.build)
def build(*, parent_callback, with_scipy_openblas, **kwargs):
if with_scipy_openblas:
_config_openblas(with_scipy_openblas)
parent_callback(**kwargs)
@spin.util.extend_command(spin.cmds.meson.docs)
def docs(*, parent_callback, **kwargs):
"""📖 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
"""
kwargs['clean_dirs'] = [
'./doc/build/',
'./doc/source/reference/generated',
'./doc/source/reference/random/bit_generators/generated',
'./doc/source/reference/random/generated',
]
# Run towncrier without staging anything for commit. This is the way to get
# release notes snippets included in a local doc build.
cmd = ['towncrier', 'build', '--version', '2.x.y', '--keep', '--draft']
p = subprocess.run(cmd, check=True, capture_output=True, text=True)
outfile = curdir.parent / 'doc' / 'source' / 'release' / 'notes-towncrier.rst'
with open(outfile, 'w') as f:
f.write(p.stdout)
parent_callback(**kwargs)
# Override default jobs to 1
jobs_param = next(p for p in docs.params if p.name == 'jobs')
jobs_param.default = 1
@click.option(
"-m",
"markexpr",
metavar='MARKEXPR',
default="not slow",
help="Run tests with the given markers"
)
@spin.util.extend_command(spin.cmds.meson.test)
def test(*, parent_callback, pytest_args, tests, markexpr, **kwargs):
"""
By default, spin will run `-m 'not slow'`. To run the full test suite, use
`spin test -m full`
""" # noqa: E501
if (not pytest_args) and (not tests):
pytest_args = ('--pyargs', 'numpy')
if '-m' not in pytest_args:
if markexpr != "full":
pytest_args = ('-m', markexpr) + pytest_args
kwargs['pytest_args'] = pytest_args
parent_callback(**{'pytest_args': pytest_args, 'tests': tests, **kwargs})
@spin.util.extend_command(test, doc='')
def check_docs(*, parent_callback, pytest_args, **kwargs):
"""🔧 Run doctests of objects in the public API.
PYTEST_ARGS are passed through directly to pytest, e.g.:
spin check-docs -- --pdb
To run tests on a directory:
\b
spin check-docs numpy/linalg
To report the durations of the N slowest doctests:
spin check-docs -- --durations=N
To run doctests that match a given pattern:
\b
spin check-docs -- -k "slogdet"
spin check-docs numpy/linalg -- -k "det and not slogdet"
\b
Note:
-----
\b
- This command only runs doctests and skips everything under tests/
- This command only doctests public objects: those which are accessible
from the top-level `__init__.py` file.
""" # noqa: E501
try:
# prevent obscure error later
import scipy_doctest
except ModuleNotFoundError as e:
raise ModuleNotFoundError("scipy-doctest not installed") from e
if (not pytest_args):
pytest_args = ('--pyargs', 'numpy')
# turn doctesting on:
doctest_args = (
'--doctest-modules',
'--doctest-collect=api'
)
pytest_args = pytest_args + doctest_args
parent_callback(**{'pytest_args': pytest_args, **kwargs})
@spin.util.extend_command(test, doc='')
def check_tutorials(*, parent_callback, pytest_args, **kwargs):
"""🔧 Run doctests of user-facing rst tutorials.
To test all tutorials in the numpy doc/source/user/ directory, use
spin check-tutorials
To run tests on a specific RST file:
\b
spin check-tutorials doc/source/user/absolute-beginners.rst
\b
Note:
-----
\b
- This command only runs doctests and skips everything under tests/
- This command only doctests public objects: those which are accessible
from the top-level `__init__.py` file.
""" # noqa: E501
# handle all of
# - `spin check-tutorials` (pytest_args == ())
# - `spin check-tutorials path/to/rst`, and
# - `spin check-tutorials path/to/rst -- --durations=3`
if (not pytest_args) or all(arg.startswith('-') for arg in pytest_args):
pytest_args = ('doc/source/user',) + pytest_args
# make all paths relative to the numpy source folder
pytest_args = tuple(
str(curdir / '..' / arg) if not arg.startswith('-') else arg
for arg in pytest_args
)
# turn doctesting on:
doctest_args = (
'--doctest-glob=*rst',
)
pytest_args = pytest_args + doctest_args
parent_callback(**{'pytest_args': pytest_args, **kwargs})
# 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 = spin.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 = spin.util.run(['git', 'diff-files', '--quiet'])
# Staged changes
p1 = spin.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}{os.pathsep}{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
spin.util.run(cmd, cwd='benchmarks', env=env)
@click.command()
@click.option(
"-b", "--branch",
metavar='branch',
default="main",
)
@click.option(
'--uncommitted',
is_flag=True,
default=False,
required=False,
)
@click.pass_context
def lint(ctx, branch, uncommitted):
"""🔦 Run lint checks on diffs.
Provide target branch name or `uncommitted` to check changes before committing:
\b
Examples:
\b
For lint checks of your development branch with `main` or a custom branch:
\b
$ spin lint # defaults to main
$ spin lint --branch custom_branch
\b
To check just the uncommitted changes before committing
\b
$ spin lint --uncommitted
"""
try:
linter = _get_numpy_tools(pathlib.Path('linter.py'))
except ModuleNotFoundError as e:
raise click.ClickException(
f"{e.msg}. Install using requirements/linter_requirements.txt"
)
linter.DiffLinter(branch).run_lint(uncommitted)
@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
)
@meson.build_dir_option
@click.pass_context
def bench(ctx, tests, compare, verbose, quick, commits, build_dir):
"""🏋 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(build_dir)
p = spin.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)
@spin.util.extend_command(meson.python)
def python(*, parent_callback, **kwargs):
env = os.environ
env['PYTHONWARNINGS'] = env.get('PYTHONWARNINGS', 'all')
parent_callback(**kwargs)
@click.command(context_settings={
'ignore_unknown_options': True
})
@click.argument("ipython_args", metavar='', nargs=-1)
@meson.build_dir_option
def ipython(*, ipython_args, build_dir):
"""💻 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 = click.get_current_context()
ctx.invoke(build)
ppath = meson._set_pythonpath(build_dir)
print(f'💻 Launching IPython with PYTHONPATH="{ppath}"')
# In spin >= 0.13.1, can replace with extended command, setting `pre_import`
preimport = (r"import numpy as np; "
r"print(f'\nPreimported NumPy {np.__version__} as np')")
spin.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(use_preloading=True)
)
@click.command()
@click.option(
"-v", "--version-override",
help="NumPy version of release",
required=False
)
def notes(version_override):
"""🎉 Generate release notes and validate
\b
Example:
\b
$ spin notes --version-override 2.0
\b
To automatically pick the version
\b
$ spin notes
"""
project_config = spin.util.get_config()
version = version_override or project_config['project.version']
click.secho(
f"Generating release notes for NumPy {version}",
bold=True, fg="bright_green",
)
# Check if `towncrier` is installed
if not shutil.which("towncrier"):
raise click.ClickException(
"please install `towncrier` to use this command"
)
click.secho(
f"Reading upcoming changes from {project_config['tool.towncrier.directory']}",
bold=True, fg="bright_yellow"
)
# towncrier build --version 2.1 --yes
cmd = ["towncrier", "build", "--version", version, "--yes"]
p = spin.util.run(cmd=cmd, sys_exit=False, output=True, encoding="utf-8")
if p.returncode != 0:
raise click.ClickException(
f"`towncrier` failed returned {p.returncode} with error `{p.stderr}`"
)
output_path = project_config['tool.towncrier.filename'].format(version=version)
click.secho(
f"Release notes successfully written to {output_path}",
bold=True, fg="bright_yellow"
)
click.secho(
"Verifying consumption of all news fragments",
bold=True, fg="bright_green",
)
try:
test_notes = _get_numpy_tools(pathlib.Path('ci', 'test_all_newsfragments_used.py'))
except ModuleNotFoundError as e:
raise click.ClickException(
f"{e.msg}. Install the missing packages to use this command."
)
test_notes.main()