| import os |
| import shutil |
| import pathlib |
| import shutil |
| import pathlib |
| import importlib |
| import subprocess |
| |
| 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.' |
| ) |
| |
| |
| 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 |
| ) |
| @click.pass_context |
| def changelog(ctx, 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.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, *args, **kwargs): |
| """🔧 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', |
| # Avoids pydata_sphinx_theme extension warning from default="auto". |
| default="1", |
| help=("Number of parallel build jobs." |
| "Can be set to `auto` to use all cores.") |
| ) |
| @click.pass_context |
| def docs(ctx, sphinx_target, clean, first_build, jobs, *args, **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 |
| |
| """ |
| meson.docs.ignore_unknown_options = True |
| |
| # See https://github.com/scientific-python/spin/pull/199 |
| # Can be changed when spin updates to 0.11, and moved to pyproject.toml |
| if clean: |
| clean_dirs = [ |
| './doc/build/', |
| './doc/source/reference/generated', |
| './doc/source/reference/random/bit_generators/generated', |
| './doc/source/reference/random/generated', |
| ] |
| |
| for target_dir in clean_dirs: |
| if os.path.isdir(target_dir): |
| print(f"Removing {target_dir!r}") |
| shutil.rmtree(target_dir) |
| |
| # 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) |
| |
| 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, *args, **kwargs): |
| """🔧 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 test -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 len(pytest_args) == 1 and not tests: |
| tests = pytest_args[0] |
| 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) |
| |
| |
| @click.command() |
| @click.argument("pytest_args", nargs=-1) |
| @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( |
| '--verbose', '-v', is_flag=True, default=False |
| ) |
| @click.pass_context |
| def check_docs(ctx, pytest_args, n_jobs, verbose, *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 = ('numpy',) |
| |
| if (n_jobs != "1") and ('-n' not in pytest_args): |
| pytest_args = ('-n', str(n_jobs)) + pytest_args |
| |
| if verbose: |
| pytest_args = ('-v',) + pytest_args |
| |
| # turn doctesting on: |
| doctest_args = ( |
| '--doctest-modules', |
| '--doctest-collect=api' |
| ) |
| |
| pytest_args = pytest_args + doctest_args |
| |
| ctx.params['pytest_args'] = pytest_args |
| |
| for extra_param in ('n_jobs', 'verbose'): |
| del ctx.params[extra_param] |
| |
| ctx.forward(meson.test) |
| |
| |
| @click.command() |
| @click.argument("pytest_args", nargs=-1) |
| @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( |
| '--verbose', '-v', is_flag=True, default=False |
| ) |
| @click.pass_context |
| def check_tutorials(ctx, pytest_args, n_jobs, verbose, *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 numpy/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 = ('numpy/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 |
| ) |
| |
| if (n_jobs != "1") and ('-n' not in pytest_args): |
| pytest_args = ('-n', str(n_jobs)) + pytest_args |
| |
| if verbose: |
| pytest_args = ('-v',) + pytest_args |
| |
| # turn doctesting on: |
| doctest_args = ( |
| '--doctest-glob=*rst', |
| ) |
| |
| pytest_args = pytest_args + doctest_args |
| |
| ctx.params['pytest_args'] = pytest_args |
| |
| for extra_param in ('n_jobs', '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}{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 |
| |
| 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 brach 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 |
| ) |
| @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, *args, **kwargs): |
| """🐍 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(use_preloading=True) |
| ) |
| |
| |
| @click.command() |
| @click.option( |
| "-v", "--version-override", |
| help="NumPy version of release", |
| required=False |
| ) |
| @click.pass_context |
| def notes(ctx, 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 = 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( |
| f"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 = 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() |