diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1af33c5..5ce9047 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,8 +65,9 @@ The broad areas that need help: compare by shape, dtype and contents with no tolerance, and datetimes and UUIDs are quarantined rather than normalised (issue #12). Reopening either means naming the real change the new policy would hide. -- **Performance.** Recording currently costs ~8x. `sys.monitoring` - (Python 3.12+) should be much cheaper than wrapping functions. +- **Performance.** Recording currently costs ~4x. Note that `sys.monitoring` + (Python 3.12+) is *not* the lever it looks like: profiling shows function + interception costs about 1% of the overhead, and serialisation the rest. - **Portability.** Windows is supported and tested in CI. Where `SIGALRM` does not exist the per-call timeout falls back to a watchdog thread, which cannot interrupt a call blocked inside C code. diff --git a/README.md b/README.md index fd07c06..55425cb 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ otherwise, but the gap is real. mode, size and a hash of the bytes). Network calls and database queries are still invisible. -**Recording is slow.** Expect roughly an 8x slowdown on the recorded run. +**Recording is slow.** Expect roughly a 4x slowdown on the recorded run. Tests that assert on wall-clock time may fail while recording. ## Measured behaviour @@ -166,7 +166,7 @@ Start with the [good first issues][gfi]. The broad areas that need help: - **Side-effect capture** — intercept `requests`, `sqlalchemy` - **Comparators** for types that still need a policy — datetimes and UUIDs are settled: quarantined, not normalised (issue #12) -- **Performance** — recording currently costs ~8x +- **Performance** — recording currently costs ~4x - **Framework adapters** beyond pytest The most valuable bug report of all is a **false positive**: if `nodrift check` diff --git a/pyproject.toml b/pyproject.toml index e716199..298f412 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "nodrift" -version = "0.1.3" +version = "0.1.4" description = "Prove a refactor changed nothing — by replaying your tests' real inputs against both versions." readme = "README.md" requires-python = ">=3.9" diff --git a/src/nodrift/__init__.py b/src/nodrift/__init__.py index 147e552..a1bb2a8 100644 --- a/src/nodrift/__init__.py +++ b/src/nodrift/__init__.py @@ -7,7 +7,7 @@ No model reviews the code. The verdict comes from execution. """ -__version__ = "0.1.3" +__version__ = "0.1.4" from .compare import compare from .fingerprint import fingerprint diff --git a/src/nodrift/cli.py b/src/nodrift/cli.py index 4dfae6a..b8ab71d 100644 --- a/src/nodrift/cli.py +++ b/src/nodrift/cli.py @@ -191,6 +191,11 @@ def cmd_check(args: argparse.Namespace) -> int: return _print_report(report, args, abandoned) +def _plural(count: int, word: str) -> str: + """"1 function", "2 functions" — the report is read by people.""" + return f"{count} {word}" if count == 1 else f"{count} {word}s" + + def _print_not_covered(abandoned: list[str], verbose: bool) -> None: """Say which functions the verdict above does not speak for. @@ -202,7 +207,7 @@ def _print_not_covered(abandoned: list[str], verbose: bool) -> None: """ if not abandoned: return - print(f" {len(abandoned)} function(s) not fully recorded " + print(f" {_plural(len(abandoned), 'function')} not fully recorded " f"(inputs too large, or not picklable) — not covered by this check") if verbose: for name in abandoned: @@ -224,8 +229,8 @@ def _print_report(report: dict, args: argparse.Namespace, quarantined = report.get("quarantined", 0) if quarantined: - print(f" {quarantined} record(s) quarantined as nondeterministic " - f"(not compared)") + print(f" {_plural(quarantined, 'record')} quarantined as " + f"nondeterministic (not compared)") # A bare count tells a user nothing about whether the function they # care about is among the ones that stopped being checked. targets = report.get("quarantined_targets") or [] @@ -241,12 +246,13 @@ def _print_report(report: dict, args: argparse.Namespace, _print_not_covered(abandoned, getattr(args, "verbose", False)) if not differs: - print(f"\n no behaviour change across {total} recorded inputs " - f"in {report['total_targets']} functions\n") + print(f"\n no behaviour change across " + f"{_plural(total, 'recorded input')} in " + f"{_plural(report['total_targets'], 'function')}\n") return 0 print(f"\n {differs} of {total} recorded inputs behave differently " - f"({report['changed_targets']} functions)\n") + f"({_plural(report['changed_targets'], 'function')})\n") for target, counts in sorted( report["changed"].items(), key=lambda kv: -kv[1]["differs"] ):