Skip to content

test: a pytest harness with a vacuity-refusal layer, 25 tests (#432) - #897

Open
OffgridwithJD wants to merge 4 commits into
commandprompt:mainfrom
OffgridwithJD:audit/432-pytest-base
Open

test: a pytest harness with a vacuity-refusal layer, 25 tests (#432)#897
OffgridwithJD wants to merge 4 commits into
commandprompt:mainfrom
OffgridwithJD:audit/432-pytest-base

Conversation

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

First of three for #432. This is the foundation: a cluster fixture, a direct psycopg connection, the vacuity-refusal layer that every later test depends on, and 25 tests.

The two follow-ups are ready and held back deliberately, because each is only reviewable once this one is agreed:

PR adds tests
this one harness, direct connection, refusal layer 25
2 the vacuity-mode inventory and three gaps it found +4
3 the ordered oracle and three run-shape guards +15

Why a second harness at all, stated honestly

The bash suites are not being replaced and this does not try to. test/ carries 4,429 anchored assertions across 256 suites; this PR ports one of them. That is 0.18%. Nobody should read this as coverage.

What it buys is the thing bash cannot do cheaply: a typed result. psql -At returns text, so a bash oracle compares strings and an int4 1 and a text '1' are the same value to it. psycopg returns int, Decimal, float, bytes, list, None, and a test can assert the type as well as the value. Per #432 this uses a direct connection everywhere and shells out to psql only where there is no alternative — currently nowhere in these 25 tests.

The layer is the point, not the tests

A pytest suite fails open. A test that asserts nothing passes; a filter that selects nothing exits 0; a fixture that skips greens every test under it. This project has already shipped a vacuity defect, so the harness refuses those shapes rather than documenting them.

pgc_vacuity.py is loaded for every run via pytest.ini, and it refuses:

  • a test that reaches the end having made no counted assertion
  • an empty result compared with an empty result
  • a value compared against itself, and an md5 of an empty oracle
  • a plan match by substring rather than by typed key, and an absence claim over an empty plan
  • cursor.rowcount of -1, which is a number and truthy
  • a broad except, found by walking the AST rather than by line regex
  • a bare skip, and xfail_strict = true so an xpass is not silently green
  • --pgc-expect-tests N, which asserts the run's own shape, and refuses N = 0

Every one of those has a red test in test_layer.py that runs pytest inside pytest through the pytester fixture and asserts on the inner run's outcome. That is what proves a guard refuses rather than assuming it. Each row of the table in TESTS.md also records the bare-pytest behaviour it exists to stop, measured: every one of those measurements exited 0.

Four of the ten layer tests are positive controls, deliberately. A guard with a bad false-positive rate gets switched off, and then whatever it replaced is gone too.

The escape hatches are all more expensive to type than the honest form: allow_empty takes a reason, not True; --pgc-expect-tests takes the real number; cannot_run takes a reason from a closed list. None can become the default by being shorter.

The port is proved against the bash suite it came from

test_native_projection.py ports test/native_projection.sh. compare_to_bash.py runs both and compares the property names each asserts, not the counts — a grep -c " PASSED" reported 6 of 7 because the first test's outcome shares a line with a fixture's print, and a count that is wrong for that reason looks exactly like a count that is right.

Not in the gate, and why

test/run_all_versions.sh does not run this. Registering it would add a psycopg build dependency to every CI leg for 0.18% of the assertions, and the point of these 25 tests today is the layer, not the coverage. README.md says what registering would cost and what has to be true before it is worth it.

Verification

At c8b2a9e, rebased onto edd729e:

25 passed, exit 0            serially
25 passed, exit 0            under -n 4

Exit codes read without a pipe, because a pipeline reports the exit status of its last stage and that has already produced one wrong verdict in this project.

I also derived which existing suites this branch can affect rather than guessing. Nothing in test/ names test/pytest/ or the design document, so the reachable surface is the harness's own accounting and the docs checks:

PG18  harness_selftest  PASSED (261 checks)     PG19  harness_selftest  PASSED
PG18  docs_style        PASSED (9 checks)       PG19  docs_style        PASSED

harness_selftest is the one that matters here: compare_to_bash.py is recorded 100755, and it failed that suite on both majors as 100644 with a shebang.

The rebase onto edd729e was verified not to have touched anything in this PR — git diff c02cfc4 HEAD -- test/pytest design is empty — and the 25 were re-run at the rebased tip rather than inherited from the pre-rebase run.

Reviewing this

The highest-value thing to attack is test_layer.py. If any guard there can be made to pass with the guard removed, the layer is decoration. I have run each one that way; a second pair of hands is worth more than my own repetition.

OffgridwithJD and others added 4 commits September 9, 2026 03:16
…prompt#432)

Issue commandprompt#432 asks whether to move the suite from bash to pytest. This is the pilot
the discussion on that issue asked for: a working harness, two suites' worth of
machinery, and one suite ported and proved against its bash original. It replaces
nothing. test/run_all_versions.sh remains the gate and no bash suite is deleted.

The port is not the hard part. The vacuity guard is.

A vacuity defect is a test that reports PASS while asserting nothing. Bare pytest
permits it in eight ways, all measured on pytest 9.1.1 and all exiting 0: a body
with no assert, empty compared with empty, parametrize over an empty list, every
test skipped, a non-strict xfail that passes, a test that returns instead of
asserting, pytest.raises(Exception) satisfied by an unrelated error, and a
substring match where a typed field was meant. The bash harness defends against
this class in several places, earned one defect at a time. pytest defends nowhere.

So the layer comes first, and it is structural rather than advisory:

  - a test passes only if it made a counted assertion, which also kills the
    return-instead-of-assert mode
  - comparisons refuse their degenerate cases: both sides empty, a value against
    itself, an empty expectation, a floor of zero
  - plan assertions read Custom Plan Provider from EXPLAIN (FORMAT JSON) by exact
    equality, because "ColumnarScan" in plan is as wrong as grep ColumnarScan when
    the real provider is PgColumnarScan
  - a bare skip fails the run; skipping needs a reason from the closed list lib.sh
    already uses
  - the run asserts its own collected count, so a filtered run cannot be green
  - the cluster identity check refuses a server whose data_directory is not ours,
    with a negative control proving it can say no

Seventeen of the 24 tests are the layer testing itself, run through pytester so
each guard is proven to refuse rather than assumed to. The layer's own tests obey
the layer: an exemption for the tests that prove the guard is the first step to
exempting everything else.

Direct connections, per the requirement. psycopg 3 returns int, Decimal, float,
bytes, list and None where psql -At returns text and leaves every conversion to
the reader. EXPLAIN (FORMAT JSON) arrives already parsed. The operations that
still run a binary are initdb, pg_ctl, pg_dump and pg_restore, and the build.
Terminating a backend is pg_terminate_backend over SQL, not a killed psql, because
killing the client leaves the backend running its statement.

Parallelism: one cluster per xdist worker, on a port derived from the worker id
below the ephemeral floor, with a private schema per test. Four workers run the
suite in 2.1s. Not -n auto: eight cores are shared with a desktop.

The differential is what makes the port credible. test/native_projection.sh and
its port, each arm building once so both harnesses measure the same library:

    ARM A unmutated         .so 8370e9b1beba   bash 8/0   pytest 7/0
    ARM B fan-out neutered  .so 9e9510593777   bash 0/8   pytest 0/7

Both go red together. compare_to_bash.py then diffs the two by assertion NAME:
every bash property is covered, plus one the bash suite lacks, a premise that the
DELETE removed rows. Without it both delete arms are satisfied by a projection
that never changed.

Measured on the leak question that opened the issue: after two gate matrix runs
this container held four orphaned postmasters from /tmp/pgcolumnar-test.* datadirs,
aged 18 to 35 minutes. The pytest harness left zero.

design/ISSUE_432_PYTEST_HARNESS.md carries the measurements, the psql-exception
list with a reason for each, the ordered red tests, what is out of scope, and a
mandatory section on what the layer still cannot catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
…prompt#432)

Two things, and the second is a defect in the harness the first commit landed.

THE PLAN PREDICATE WAS WIDER THAN THE HELPER IT PORTED.

The harness asserted `Custom Plan Provider == "PgColumnarScan"` and called that the
fix for the substring class, on the grounds that equality on a typed field cannot be
satisfied by a superstring the way `grep ColumnarScan` was by `PgColumnarScan`.
Equality was the right idea about the wrong field.

Measured on 18.4:

    plain scan            Custom Scan  provider='PgColumnarScan'  Projected Columns present
    ungrouped vector agg  Custom Scan  provider='PgColumnarScan'  Projected Columns ABSENT
    grouped vector agg    Custom Scan  provider='PgColumnarScan'  Projected Columns ABSENT

columnar_vector.c:806 assigns the vectorized aggregate node
&pgcolumnar_scan_methods, whose CustomName is PgColumnarScan
(columnar_customscan.c:167). So every pgcolumnar node reports that provider. With
the aggregate engaged the plan is a single node, the aggregate has absorbed the
scan, and the predicate still claims a scan is present. pgc_is_columnar_scan
answers no there, because it greps for `Columnar Projected Columns`, which only the
scan's callback emits (columnar_customscan.c:3631).

So expect.plan_marker() is added as the faithful port, expect.plan_node() keeps the
provider question and now says in its docstring what that question is not, and
test_the_provider_name_does_not_identify_a_scan pins all three facts so a revert
reddens rather than passing quietly. PgColumnarAgg is noted as unreachable in a
plan: it is the CustomName of a CustomPathMethods, and EXPLAIN prints the scan
methods' name.

This is the document's own argument in one example. Typed results removed the
parsing accident and left the harder mistake untouched. The assertion was
type-correct, exact, and about the wrong thing.

DOCUMENTATION.

test/pytest/TESTS.md, a per-test reference: every test with the property it asserts
and the measured fact behind it, every assertion helper with what it REFUSES, the
four controls that exist to catch a guard turning into a nuisance, the rules for
adding a test, and the traps this corpus records. Each of its numbers was checked
against the tree rather than written from memory.

design/ISSUE_432_PYTEST_HARNESS.md section 5.4 is rewritten, because it recommended
the predicate this commit removes. The test counts there are corrected and the
harness's own defect is recorded beside the other instrument defects.

25 tests, green serially and under four xdist workers, with the collected count
asserted at 25.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
… costs

An unregistered test suite rots, so this needs a decision rather than a default.
The decision has a measured price and the price was not what I assumed.

pgc_skip in lib.sh DOES NOT SKIP. It increments the failure count and prints "A
missing dependency is an environment defect, not a pass. Install it, or set
PGC_ALLOW_MISSING_<CAP>=1 to run knowingly without this coverage." That is a
deliberate house rule and the right one.

It also means a wrapper registered in SUITES cannot quietly stand aside on a machine
without pytest. CI has no pytest, no psycopg and no xdist, so registering the run
today makes every CI job red until ci.yml installs from
test/pytest/requirements-test.txt. Registering is therefore one line in SUITES plus a
real change to the gate every other PR depends on, and that belongs to whoever owns
the gate rather than to the pilot that wants it.

PGC_ALLOW_MISSING_PYTEST=1 in CI is worse than either option. It reads as coverage and
provides none, which is the vacuity defect this whole document exists to prevent, one
level up.

So the design document now carries section 1a with that reasoning, the scope list
names the omission explicitly rather than leaving it implied, and the README says
plainly that nothing gates these tests yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
…uires

Found by gating the branch rather than by assuming python files are inert. The bash
matrix failed on both majors:

    FAIL  every script that declares an interpreter is executable:
          got [[1: test/pytest/compare_to_bash.py]] want [[]]
    harness_selftest.sh: FAILED

test/selftest/300-a-test-script-must-be-runnable.sh requires that any file declaring
an interpreter be executable, and the file was mode 644 with a
#!/usr/bin/env python3 line. Mode is now 755 and recorded as 100755 in the index.

harness_selftest is 261 of 261 with the fix. Nothing else in the matrix objected to
the new directory: 244 of 246 on PG18 and 246 of 246 on PG19, with only the two
expected PG19-only skips.

The second failure in my first reproduction, "the installed .so is the one this run
built ... built <none>", was an artifact of running the selftest under
PGC_SKIP_BUILD=1. It passes when a build actually runs, so it was my probe rather
than the branch, and it is worth naming because that check exists precisely to catch
a suite reporting against a binary it did not build.

TESTS.md now tells the next person that this directory inherits every rule the older
ones follow, and names this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant