|
| 1 | +import os |
1 | 2 | import pathlib |
2 | 3 | import sys |
| 4 | + |
3 | 5 | import click |
| 6 | +import spin.cmds.meson as _meson |
4 | 7 |
|
5 | 8 | curdir = pathlib.Path(__file__).parent |
6 | 9 | rootdir = curdir.parent |
7 | 10 | toolsdir = rootdir / "tools" |
8 | 11 | sys.path.insert(0, str(toolsdir)) |
9 | 12 |
|
| 13 | +PACKAGE = "numpy_sr" |
| 14 | + |
| 15 | +# Registered in `python/_conftest/hooks.py`; both are skipped unless opted into. |
| 16 | +MARKERS = ("slow", "exhaustive") |
| 17 | + |
| 18 | + |
| 19 | +def _markexpr(markers): |
| 20 | + """`--marker slow,exhaustive` -> pytest's `-m "slow or exhaustive"`. |
| 21 | +
|
| 22 | + The conftest only arms its skip-the-marked gate while pytest's mark |
| 23 | + expression is empty, so `all` hands over a tautology: it disarms the gate |
| 24 | + without narrowing the selection.""" |
| 25 | + names = list( |
| 26 | + dict.fromkeys(n for spec in markers for n in spec.split(",") if n.strip()) |
| 27 | + ) |
| 28 | + choices = (*MARKERS, "all") |
| 29 | + unknown = [n for n in names if n not in choices] |
| 30 | + if unknown: |
| 31 | + raise click.BadParameter( |
| 32 | + f"unknown marker(s): {', '.join(unknown)}; pick from {', '.join(choices)}" |
| 33 | + ) |
| 34 | + if not names: |
| 35 | + return None |
| 36 | + if "all" in names: |
| 37 | + return f"{MARKERS[0]} or not {MARKERS[0]}" |
| 38 | + return " or ".join(names) |
| 39 | + |
| 40 | + |
| 41 | +def _is_path(arg): |
| 42 | + return not arg.startswith("-") and ( |
| 43 | + arg.endswith(".py") or "::" in arg or os.sep in arg |
| 44 | + ) |
| 45 | + |
| 46 | + |
| 47 | +def _as_path(arg, build_dir): |
| 48 | + """`numpy_sr.tests.test_trig[::node]` -> its installed file. A `--pyargs` |
| 49 | + module selection loads conftest.py too late to register --target & co, |
| 50 | + so a selection has to reach pytest as a path. Returns None if unresolved.""" |
| 51 | + if not (arg == PACKAGE or arg.startswith(f"{PACKAGE}.")): |
| 52 | + return None |
| 53 | + module, sep, node = arg.partition("::") |
| 54 | + base = pathlib.Path(_meson._get_site_packages(build_dir), *module.split(".")) |
| 55 | + for candidate in (base.with_suffix(".py"), base): |
| 56 | + if candidate.exists(): |
| 57 | + return f"{candidate}{sep}{node}" |
| 58 | + return None |
| 59 | + |
| 60 | + |
| 61 | +def _selection(pytest_args, tests, build_dir): |
| 62 | + """Resolve test selections to paths and re-add the `--pyargs numpy_sr` that |
| 63 | + upstream drops as soon as any pytest argument is given -- without it pytest |
| 64 | + collects the whole of site-packages.""" |
| 65 | + args = tuple(_as_path(a, build_dir) or a for a in pytest_args) |
| 66 | + resolved = _as_path(tests, build_dir) if tests else None |
| 67 | + if resolved: |
| 68 | + args, tests = (*args, resolved), None |
| 69 | + if tests or any(_is_path(a) for a in args): |
| 70 | + return args, tests |
| 71 | + return ("--pyargs", PACKAGE, *args), tests |
| 72 | + |
| 73 | + |
| 74 | +@click.command( |
| 75 | + help=f"""🔧 Run tests |
| 76 | +
|
| 77 | + Wraps `spin.cmds.meson.test`, exposing the suite's own pytest options |
| 78 | + directly and keeping collection rooted at `--pyargs {PACKAGE}`: |
| 79 | +
|
| 80 | + \b |
| 81 | + spin test --target avx2,sse4 |
| 82 | + spin test --accuracy high --seed 12345 |
| 83 | + spin test --collect-worst |
| 84 | + spin test --marker slow |
| 85 | +
|
| 86 | + Plain pytest options still go after `--`: |
| 87 | +
|
| 88 | + \b |
| 89 | + spin test --target sse2 -- -k trig -x |
| 90 | + """ |
| 91 | +) |
| 92 | +@click.argument("pytest_args", nargs=-1) |
| 93 | +@click.option( |
| 94 | + "--target", |
| 95 | + metavar="NAMES", |
| 96 | + help="Restrict ISA targets; comma-separated (default: every supported " |
| 97 | + "target except references).", |
| 98 | +) |
| 99 | +@click.option( |
| 100 | + "--accuracy", |
| 101 | + metavar="NAMES", |
| 102 | + help="Restrict accuracy profiles; comma-separated (default: high,low).", |
| 103 | +) |
| 104 | +@click.option( |
| 105 | + "--seed", |
| 106 | + metavar="INT", |
| 107 | + help="Root seed for every random stream (default: drawn fresh, echoed at " |
| 108 | + "the end of the run).", |
| 109 | +) |
| 110 | +@click.option( |
| 111 | + "--marker", |
| 112 | + "-m", |
| 113 | + "markers", |
| 114 | + metavar="NAMES", |
| 115 | + multiple=True, |
| 116 | + help="Opt into marker-gated tests, comma-separated or repeated: " |
| 117 | + f"{', '.join(MARKERS)}, or `all` to add them to the regular run. Naming " |
| 118 | + "one runs only its tests (default: neither, both are skipped).", |
| 119 | +) |
| 120 | +@click.option( |
| 121 | + "--collect-worst", |
| 122 | + is_flag=True, |
| 123 | + default=False, |
| 124 | + help="Fold measured inputs into the worst-case book instead of only " |
| 125 | + "replaying it as a gate.", |
| 126 | +) |
| 127 | +@click.option( |
| 128 | + "-j", |
| 129 | + "n_jobs", |
| 130 | + metavar="N_JOBS", |
| 131 | + default="1", |
| 132 | + help="Number of xdist workers; `auto` for all cores.", |
| 133 | +) |
| 134 | +@click.option( |
| 135 | + "--tests", |
| 136 | + "-t", |
| 137 | + metavar="TESTS", |
| 138 | + help=f"Module, class or function to run, e.g. {PACKAGE}.tests.test_trig.", |
| 139 | +) |
| 140 | +@click.option("--verbose", "-v", is_flag=True, default=False) |
| 141 | +@_meson.build_option |
| 142 | +@_meson.build_dir_option |
| 143 | +@click.pass_context |
| 144 | +def test( |
| 145 | + ctx, |
| 146 | + *, |
| 147 | + pytest_args, |
| 148 | + target, |
| 149 | + accuracy, |
| 150 | + seed, |
| 151 | + markers, |
| 152 | + collect_worst, |
| 153 | + n_jobs, |
| 154 | + tests, |
| 155 | + verbose, |
| 156 | + build, |
| 157 | + build_dir, |
| 158 | +): |
| 159 | + suite_args = [] |
| 160 | + for name, value in ( |
| 161 | + ("--target", target), |
| 162 | + ("--accuracy", accuracy), |
| 163 | + ("--seed", seed), |
| 164 | + ): |
| 165 | + if value is not None: |
| 166 | + suite_args += [name, value] |
| 167 | + if collect_worst: |
| 168 | + suite_args.append("--collect-worst") |
| 169 | + |
| 170 | + markexpr = _markexpr(markers) |
| 171 | + if markexpr: |
| 172 | + if any(a == "-m" or a.startswith("-m") for a in pytest_args): |
| 173 | + raise click.UsageError( |
| 174 | + "--marker clashes with the `-m` handed to pytest after `--`; " |
| 175 | + "keep just one of them." |
| 176 | + ) |
| 177 | + suite_args += ["-m", markexpr] |
| 178 | + |
| 179 | + pytest_args, tests = _selection(tuple(suite_args) + pytest_args, tests, build_dir) |
| 180 | + ctx.invoke( |
| 181 | + _meson.test, |
| 182 | + pytest_args=pytest_args, |
| 183 | + n_jobs=n_jobs, |
| 184 | + tests=tests, |
| 185 | + verbose=verbose, |
| 186 | + build=build, |
| 187 | + build_dir=build_dir, |
| 188 | + ) |
| 189 | + |
10 | 190 |
|
11 | 191 | @click.command(help="Generate sollya c++/python based files") |
12 | 192 | @click.option("-f", "--force", is_flag=True, help="Force regenerate all files") |
13 | 193 | def sollya(*, force): |
14 | 194 | import sollya # type: ignore[import] |
15 | 195 |
|
16 | 196 | sollya.main(force=force) |
| 197 | + |
| 198 | + |
| 199 | +# Markers in tools/ulp_report/index.html -> what replaces them in the bundle. |
| 200 | +# Tabulator stays a CDN <script>/<link>, so only our own css/js/data are inlined. |
| 201 | +_CSS_LINK = '<link rel="stylesheet" href="viewer.css">' |
| 202 | +_DATA_SLOT = '<script type="application/json" id="ulp-data"></script>' |
| 203 | +_SIDECAR = '<script src="../../ulp-report.js"></script>' |
| 204 | +_VIEWER = '<script src="viewer.js"></script>' |
| 205 | + |
| 206 | + |
| 207 | +def _replace_once(html, marker, repl): |
| 208 | + if html.count(marker) != 1: |
| 209 | + raise SystemExit(f"index.html: expected exactly one {marker!r}") |
| 210 | + return html.replace(marker, repl) |
| 211 | + |
| 212 | + |
| 213 | +def _bundle(json_text): |
| 214 | + viewdir = toolsdir / "ulp_report" |
| 215 | + html = (viewdir / "index.html").read_text() |
| 216 | + # "</" inside JSON strings would close the inline script tag early. |
| 217 | + data = json_text.replace("</", "<\\/") |
| 218 | + html = _replace_once( |
| 219 | + html, _CSS_LINK, f"<style>\n{(viewdir / 'viewer.css').read_text()}</style>" |
| 220 | + ) |
| 221 | + html = _replace_once( |
| 222 | + html, |
| 223 | + _DATA_SLOT, |
| 224 | + f'<script type="application/json" id="ulp-data">{data}</script>', |
| 225 | + ) |
| 226 | + html = _replace_once(html, _SIDECAR, "") |
| 227 | + return _replace_once( |
| 228 | + html, _VIEWER, f"<script>\n{(viewdir / 'viewer.js').read_text()}</script>" |
| 229 | + ) |
| 230 | + |
| 231 | + |
| 232 | +@click.command(help="Bundle the ULP JSON report + viewer into one self-contained HTML") |
| 233 | +@click.argument("json_path", required=False, type=click.Path(path_type=pathlib.Path)) |
| 234 | +@click.option( |
| 235 | + "-o", |
| 236 | + "--output", |
| 237 | + type=click.Path(path_type=pathlib.Path), |
| 238 | + default=pathlib.Path("ulp-report.html"), |
| 239 | + show_default=True, |
| 240 | + help="Output HTML path", |
| 241 | +) |
| 242 | +@click.option( |
| 243 | + "--open/--no-open", |
| 244 | + "show", |
| 245 | + default=True, |
| 246 | + show_default=True, |
| 247 | + help="Open the result in the default browser (--no-open for CI)", |
| 248 | +) |
| 249 | +def ulp_report(json_path, output, show): |
| 250 | + import webbrowser |
| 251 | + |
| 252 | + json_path = json_path or rootdir / "ulp-report.json" |
| 253 | + if not json_path.exists(): |
| 254 | + raise SystemExit( |
| 255 | + f"{json_path}: not found. Run the test suite first, or pass a report path." |
| 256 | + ) |
| 257 | + json_text = json_path.read_text() |
| 258 | + output.write_text(_bundle(json_text)) |
| 259 | + print(f"{output} ({output.stat().st_size:,} bytes)") |
| 260 | + # Sidecar for the live index.html over file://, where fetch() is CORS-blocked. |
| 261 | + sidecar = json_path.with_suffix(".js") |
| 262 | + sidecar.write_text(f"window.NPSR_ULP_DATA = {json_text};\n") |
| 263 | + print(f"{sidecar} ({sidecar.stat().st_size:,} bytes)") |
| 264 | + if show: |
| 265 | + webbrowser.open(output.resolve().as_uri()) |
0 commit comments