Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,7 @@ While `dev` contains the most complete set of install dependencies, a number of
pip install -e ".[dev]"
~~~

The `dev` extra includes QMCPy's PyPI-hosted MPMC dependencies. MPMC additionally
requires a platform-specific `pyg_lib` wheel that is not available from PyPI.
After installing `dev`, let the QMCPy installer select the wheel page matching
the installed PyTorch build:
The `dev` extra includes QMCPy's PyPI-hosted MPMC dependencies. MPMC additionally requires a platform-specific `pyg_lib` wheel that is not available from PyPI. After installing `dev`, let the QMCPy installer select the wheel page matching the installed PyTorch build:

~~~bash
qmcpy-install-mpmc
Expand Down Expand Up @@ -145,6 +142,10 @@ make tests

Please see the targets in the makefile for more granular control over tests.

### Test file layout

Unit tests live flat in `test/`, named `test_<area>_<topic>.py` where `<area>` is a short code for the `qmcpy` subpackage under test (`tm` true_measure, `dd` discrete_distribution, `sc` stopping_criterion, `ig` integrand, ...) or a cross-cutting bucket (`ee`, `sr`). So `pytest test/ -k test_tm_` runs every true-measure test. New files should also be written as a `unittest.TestCase` subclass rather than bare `def test_*` functions. `make check_test_style` lists any file that breaks either convention (informational; also runs inside `make format`; `STRICT=--strict` makes it fail). The full area table is in [`test/README.md`](test/README.md#test-file-organization).

## Documentation

### Ensure `pyreverse` Is On Your PATH
Expand Down
5 changes: 1 addition & 4 deletions docs/api/discrete_distributions.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,7 @@ python -m pip install "qmcpy[mpmc]"
qmcpy-install-mpmc
```

The second command selects the `pyg_lib` wheel page matching the installed
PyTorch and accelerator builds. For GPU support or platform-specific wheels,
see the [PyTorch installation guide](https://pytorch.org/get-started/locally/)
and the [PyTorch Geometric installation guide](https://pytorch-geometric.readthedocs.io/en/latest/install/installation.html).
The second command selects the `pyg_lib` wheel page matching the installed PyTorch and accelerator builds. For GPU support or platform-specific wheels, see the [PyTorch installation guide](https://pytorch.org/get-started/locally/) and the [PyTorch Geometric installation guide](https://pytorch-geometric.readthedocs.io/en/latest/install/installation.html).

::: qmcpy.discrete_distribution.mpmc.mpmc.MPMC

Expand Down
7 changes: 2 additions & 5 deletions docs/mpmc-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,14 @@ This gives one place to enforce modern MPMC compatibility without forcing the en

## Local Developer Commands

Install the usual test and MPMC extras first, then add the platform-specific
PyG runtime with QMCPy's installed helper command:
Install the usual test and MPMC extras first, then add the platform-specific PyG runtime with QMCPy's installed helper command:

```bash
python -m pip install -e ".[test,test_torch,test_gpytorch,test_botorch,mpmc]"
qmcpy-install-mpmc
```

The `mpmc` extra contains dependencies available from PyPI. The helper handles
`pyg_lib` separately because its wheel page depends on the installed PyTorch
version and accelerator build, which standard project metadata cannot select.
The `mpmc` extra contains dependencies available from PyPI. The helper handles `pyg_lib` separately because its wheel page depends on the installed PyTorch version and accelerator build, which standard project metadata cannot select.

Then run the MPMC-specific checks:

Expand Down
42 changes: 42 additions & 0 deletions docs/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,48 @@ This document describes the available test targets in the Makefile for QMCSoftwa
| `make delcoverage` | Reset coverage tracking | Instant | Start fresh coverage analysis |


## Test File Organization

Unit tests live flat in `test/` (no subpackage subfolders). Every file is named:

```
test_<area>_<topic>.py
```

`<area>` is a short code for the `qmcpy` subpackage under test, or a cross-cutting bucket:

| area | scope |
|------|-------|
| `dd` | `qmcpy/discrete_distribution` |
| `ft` | `qmcpy/fast_transform` |
| `ig` | `qmcpy/integrand` |
| `kn` | `qmcpy/kernel` |
| `sc` | `qmcpy/stopping_criterion` |
| `tm` | `qmcpy/true_measure` |
| `ut` | `qmcpy/util` |
| `ee` | end-to-end / cross-cutting pipeline (`integrate()`, worked problems such as Keister and pi) |
| `sr` | `scripts/` tooling, packaging, and docs checks |

This keeps related tests adjacent when the directory is sorted, and lets you run one area at a time:

```bash
python -m pytest test/ -k test_tm_ # every true_measure test
make unittests PYTEST_EXTRA_ARGS="-k test_sc_"
```

Notebook tests are separate: they live in `test/booktests/` as `tb_*.py` and are generated from `demos/` (see `test/booktests/README.md`).

### Conventions checked by `make check_test_style`

1. **Area prefix** — the filename must start with a recognized `test_<area>_` prefix from the table above.
2. **Object class** — write a test file as one or more `unittest.TestCase` subclasses rather than bare `def test_*` pytest functions. A class groups related assertions under a name (so `pytest -k TestCubMCG` selects them and a failure report names the group), shares construction through `setUp` / `setUpClass` / `self.addCleanup`, and runs identically under `pytest`, `python -m unittest`, and the coverage and booktest runners without depending on pytest fixtures. Most of the suite already follows this; a few legacy files still use bare functions and new files should not.

`make check_test_style` lists any violation and is informational (exit 0). It also runs as part of `make format`. To make it fail instead — for a pre-commit hook or CI gate — pass `--strict`:

```bash
STRICT=--strict make check_test_style
```

## Detailed Descriptions

## Scope
Expand Down
15 changes: 15 additions & 0 deletions makefile
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ clean_local_only_files:
clean_coverage:
rm -fr artifacts/coverage/ .coverage* test/booktests/.coverage*

TEST_STYLE_PATH ?= test
# Check test/test_*.py against two suite conventions: (1) written as a
# unittest.TestCase subclass ("object class"), not bare pytest functions;
# (2) named test_<area>_*.py where <area> is the qmcpy subpackage under test
# (dd ft ig kn sc tm ut) or a cross-cutting bucket (ee sr).
# Informational by default; pass --strict to make it fail
# (e.g. STRICT=--strict make check_test_style).
check_test_style:
@$(PYTHON) scripts/check_test_style.py $(TEST_STYLE_PATH) $(STRICT)

##########################################################
# Doctests
##########################################################
Expand Down Expand Up @@ -406,8 +416,13 @@ MARKDOWN_UNWRAP_PATH ?= $(FORMAT_PATH)

format:
$(MAKE) flatten_qmcpy_imports
@echo ""
$(MAKE) markdown-unwrap MARKDOWN_UNWRAP_PATH="$(MARKDOWN_UNWRAP_PATH)"
@echo ""
$(MAKE) rm_trailing_whitespace FORMAT_PATH="$(FORMAT_PATH)"
@echo ""
$(MAKE) check_test_style
@echo ""

flatten_qmcpy_imports:
$(PYTHON) scripts/flatten_qmcpy_imports.py
Expand Down
1 change: 0 additions & 1 deletion qmcpy/accumulate_data/__init__.py

This file was deleted.

131 changes: 131 additions & 0 deletions scripts/check_test_style.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Check ``test/test_*.py`` files against two suite conventions.

1. **Object class.** A test file should be written as a ``unittest.TestCase``
subclass, not as bare ``def test_*`` pytest functions. The numeric-correctness
backbone (``test_tm_true_measures.py``, ``test_sc_stopping_criteria.py``,
``test_dd_discrete_distribs.py``, ...) already follows this; newer per-measure
and tooling files do not. The split is listed so it stays visible in review.

2. **Area prefix.** A test file should be named ``test_<area>_<rest>.py`` where
``<area>`` marks the ``qmcpy`` subpackage under test (or a cross-cutting
bucket). Recognized areas:

dd discrete_distribution tm true_measure
ft fast_transform ut util
ig integrand ee end-to-end / cross-cutting pipeline
kn kernel sr scripts/ tooling, packaging, docs checks
sc stopping_criterion

Usage:
python scripts/check_test_style.py [TEST_DIR] [--strict] [--quiet]

TEST_DIR defaults to ``test``. With ``--strict`` the exit code is non-zero when
any file violates either convention (so it can gate CI); otherwise it is always
0 and the output is informational.
"""
import ast
import re
import sys
from pathlib import Path

AREA_PREFIXES = {
"dd": "discrete_distribution",
"ft": "fast_transform",
"ig": "integrand",
"kn": "kernel",
"sc": "stopping_criterion",
"tm": "true_measure",
"ut": "util",
"ee": "end-to-end / cross-cutting pipeline",
"sr": "scripts/ tooling, packaging, docs checks",
}
AREA_RE = re.compile(r"^test_(?:" + "|".join(sorted(AREA_PREFIXES)) + r")_.+\.py$")


def _area_ok(path):
"""True if the filename starts with a recognized ``test_<area>_`` prefix."""
return bool(AREA_RE.match(path.name))


def _subclasses_testcase(node):
"""True if a ClassDef lists ``TestCase`` / ``unittest.TestCase`` as a base."""
for base in node.bases:
if isinstance(base, ast.Attribute) and base.attr == "TestCase":
return True
if isinstance(base, ast.Name) and base.id == "TestCase":
return True
return False


def classify(path):
"""Return (has_testcase_class, has_test_callables)."""
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
nodes = list(ast.walk(tree))
has_class = any(
isinstance(n, ast.ClassDef) and _subclasses_testcase(n) for n in nodes
)
has_tests = any(
isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
and n.name.startswith("test_")
for n in nodes
)
return has_class, has_tests


def main(argv):
strict = "--strict" in argv
quiet = "--quiet" in argv
positional = [a for a in argv if not a.startswith("-")]
test_dir = Path(positional[0]) if positional else Path("test")

files = sorted(test_dir.glob("test_*.py"))
if not files:
print(f"no test_*.py files under {test_dir}/", file=sys.stderr)
return 1

class_based, function_based, no_tests = [], [], []
for f in files:
has_class, has_tests = classify(f)
if has_class:
class_based.append(f)
elif has_tests:
function_based.append(f)
else:
no_tests.append(f)

misnamed = [f for f in files if not _area_ok(f)]

if not quiet:
print(f"{len(class_based)}/{len(files)} file(s) use a unittest.TestCase class")
if function_based:
print(
f"{len(function_based)} file(s) use bare pytest functions "
f"(no unittest.TestCase class):"
)
for f in function_based:
print(f" {f.as_posix()}")
elif not quiet:
print(" no bare-function test files found")
if no_tests and not quiet:
print(f"{len(no_tests)} file(s) define no test_* callables:")
for f in no_tests:
print(f" {f.as_posix()}")

if not quiet:
print(
f"{len(files) - len(misnamed)}/{len(files)} file(s) use a "
f"test_<area>_ prefix ({', '.join(sorted(AREA_PREFIXES))})"
)
if misnamed:
print(f" {len(misnamed)} file(s) have no recognized test_<area>_ prefix:")
for f in misnamed:
print(f" {f.as_posix()}")
elif not quiet:
print(" no misnamed test files found")

return 1 if (strict and (function_based or misnamed)) else 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
27 changes: 12 additions & 15 deletions scripts/flatten_qmcpy_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,7 @@ def main(argv: list[str] | None = None) -> int:
file=sys.stderr,
)

changed_files = 0
changed = [] # list of (display_path, import_count)
changed_imports = 0
for path in targets:
original = path.read_bytes()
Expand All @@ -919,29 +919,26 @@ def main(argv: list[str] | None = None) -> int:
if not count:
continue

changed_files += 1
changed.append((_display_path(path, repository_root), count))
changed_imports += count
if not args.check:
path.write_bytes(updated)
action = "Would update" if args.check else "Updated"
import_label = "import" if count == 1 else "imports"
print(
f"{action}: {_display_path(path, repository_root)} "
f"({count} {import_label})"
)

if changed_files:
action = "need updates" if args.check else "updated"
action = "would update" if args.check else "updated"
if changed:
file_label = "file" if len(changed) == 1 else "files"
import_label = "import" if changed_imports == 1 else "imports"
file_label = "file" if changed_files == 1 else "files"
print(
f"{changed_imports} {import_label} in "
f"{changed_files} {file_label} {action}."
f"qmcpy imports {action}: {len(changed)} {file_label}, "
f"{changed_imports} {import_label}:"
)
for display_path, count in sorted(changed):
per = "import" if count == 1 else "imports"
print(f" {display_path} ({count} {per})")
else:
print("All eligible QMCPy imports already use the top-level package.")
print(f" qmcpy imports {action}: 0 files")

return int(args.check and changed_files > 0)
return int(args.check and bool(changed))


if __name__ == "__main__":
Expand Down
14 changes: 10 additions & 4 deletions scripts/remove_trailing_whitespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,17 @@ def main() -> int:
parser.add_argument("paths", nargs="+", help="tracked files or directories to process")
args = parser.parse_args()

changed = [
path for path in iter_source_files(args.paths) if remove_trailing_whitespace(path, args.check)
]
changed = sorted(
path for path in iter_source_files(args.paths)
if remove_trailing_whitespace(path, args.check)
)
action = "would update" if args.check else "updated"
print(f"trailing whitespace {action}: {len(changed)} file(s)")
if changed:
print(f"trailing whitespace {action}: {len(changed)} file(s):")
for path in changed:
print(f" {path}")
else:
print(f" trailing whitespace {action}: 0 file(s)")
return int(args.check and bool(changed))


Expand Down
22 changes: 15 additions & 7 deletions scripts/unwrap_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,23 +280,31 @@ def main() -> int:
print("error: no .md or .ipynb files found", file=sys.stderr)
return 2

changed_files = 0
changed_paths = []
changed_cells = 0
for path in targets:
suffix = path.suffix.lower()
if suffix == ".md":
changed = process_markdown_file(path, args.check)
changed_files += int(changed)
if process_markdown_file(path, args.check):
changed_paths.append(path)
elif suffix == ".ipynb":
changed, cell_count = process_notebook(path, args.check)
changed_files += int(changed)
if changed:
changed_paths.append(path)
changed_cells += cell_count

mode = "would update" if args.check else "updated"
print(
f"markdown unwrap {mode}: {changed_files} file(s), {changed_cells} markdown cell(s)",
summary = (
f"markdown unwrap {mode}: {len(changed_paths)} file(s), "
f"{changed_cells} markdown cell(s)"
)
return 1 if args.check and changed_files else 0
if changed_paths:
print(summary + ":")
for path in sorted(changed_paths):
print(f" {path}")
else:
print(" " + summary)
return 1 if args.check and changed_paths else 0


if __name__ == "__main__":
Expand Down
Loading
Loading