Skip to content
Merged
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
110 changes: 109 additions & 1 deletion scripts/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,68 @@

from __future__ import annotations

import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent))
from _repo_layout import engine_dir, find_repo_root # noqa: E402

ENGINE_DIR = engine_dir(Path(__file__))
REPO_ROOT = find_repo_root(ENGINE_DIR)

# Whether a `.git` marker was actually found. With none, `find_repo_root` falls
# back to `start.parent`, which is right for a flat layout and short by one or
# more levels for a nested one — and nothing here can tell those apart, because
# the only signal that would is exactly what is missing.
#
# THREE ATTEMPTS TO BE CLEVER ABOUT THIS WERE WITHDRAWN, one per review round,
# and the next one should not be made here:
#
# round 1 — disable skipping entirely when no `.git` was found. Broke the FLAT
# sized-down tarball case, which had been skipping correctly: an
# accurate skip became a raw FileNotFoundError, #134's own harm class.
# round 2 — search both `REPO_ROOT` and its parent. Still wrong at nesting
# depth > 1 (`tools/internal/devkit/`), where it emitted a confident
# `not vendored` about a file present at the true root; and in the
# flat case the second candidate sits OUTSIDE the tree, so a
# same-named file above it suppressed a skip that should have fired.
# round 3 — this. No guess at all.
#
# The root is unknowable without `.git`, so the skip no longer pretends
# otherwise: it searches the one resolved root and SAYS the root was a guess.
# That is not a false claim — it states exactly what was checked — and it keeps
# the clean run a sized-down adopter is owed. #233 holds the resolution.
_ROOT_FOUND = any((c / ".git").exists() for c in (ENGINE_DIR, *ENGINE_DIR.parents))
_UNRESOLVED = (
"" if _ROOT_FOUND else f" (repo root unresolved — no .git above {ENGINE_DIR}; see #233)"
)


def require_kit_paths(*paths: str) -> None:
"""Skip the current test unless every path is present, from inside a fixture.

The fixture-time counterpart of the `kit_repo_only` marker, and the same
predicate. It exists because a dependency introduced by a FIXTURE cannot be
declared on the tests that use it without repeating a marker on every one of
them — and a marker repeated 38 times goes stale the first time someone adds
a 39th test. Expressed at the fixture, a new user of that fixture inherits
it. `test_kit_repo_only.py` scans for both spellings.
"""
_skip_if_missing(paths, "a fixture this test uses needs")


def _skip_if_missing(paths, prefix: str) -> None:
if not paths:
return
missing = [rel for rel in paths if not (REPO_ROOT / rel).exists()]
if missing:
pytest.skip(f"{prefix}: not vendored in this tree: {', '.join(missing)}{_UNRESOLVED}")


def pytest_configure(config) -> None:
"""Register `driftcheck` so `-m 'not driftcheck'` is a supported invocation.
"""Register the marks, so `-m` expressions naming them are supported.

Unregistered marks still *match* under `-m`, so the exclusion would work
without this — it would just warn (`PytestUnknownMarkWarning`) on every run.
Expand All @@ -41,3 +100,52 @@ def pytest_configure(config) -> None:
"(`-m 'not driftcheck'`) because ANY mutation fails it, which reads "
"as a kill while nothing behavioural caught anything (#33/#112).",
)
config.addinivalue_line(
"markers",
"kit_repo_only(*paths): asserts against files the kit ships but an "
"adopter need not vendor. Skipped when any named path is absent, so a "
"sized-down adoption gets a clean run instead of inapplicable failures "
"(#134 cause 2).",
)


def pytest_runtest_setup(item) -> None:
"""Skip a `kit_repo_only` test whose required paths this tree does not have.

**Why a skip and not a fix.** These tests are not path-portable-with-effort;
they are *inapplicable*. `test_init_sh.py` asserts on `init.sh`'s behaviour,
and an adopter who vendored engines and config has no `init.sh` to assert
about. Repairing the paths would only convert a `FileNotFoundError` into a
differently-worded failure — #134 says exactly that, and the repair of
cause 1 in PR #202 demonstrated it: it turned a collection abort into 90
legible failures rather than into a clean run.

**Why the marker takes paths rather than probing for "the kit's own repo".**
A test declares what it needs, and the answer is then a fact about the tree
rather than a judgement about which repo this is. Any such judgement would
be a bound the author sets — the shape `fallback-review-panel.md` records as
having opened a hole three times — and no marker file distinguishes the
kit's checkout from a full-vendor adoption anyway. A full vendor that keeps
`scripts/` runs these tests, correctly, because it genuinely has the files.

**The limit this leaves, corrected from a claim that was wrong.** An earlier
version of this docstring said a deleted kit file would "go quiet rather
than red". It does not: `test_kit_repo_only.py`'s positive control asserts
every marked path exists in a complete kit tree, and it fires on a deletion
exactly as readily as on a typo — measured by deleting each marked path in
turn. Deleting `scripts/kit_doctor.py` is louder still, since
`test_kit_doctor.py` imports it at module scope and collection aborts.

What the control genuinely cannot do is tell a deletion from a typo, and it
is scoped to trees that hold every file `kit-manifest.json` lists — so a
marker naming `init.sh`, which the manifest does not track, is the one path
whose typo could go unnoticed in a tree that is otherwise incomplete.
"""
# `iter_markers`, not `get_closest_marker`: a function-level marker must ADD
# to a module-level one rather than replace it. `test_init_sh.py`'s six
# seeding tests, which need `docs/templates` on top of the module's
# `init.sh`, are exactly that case, and
# with `get_closest_marker` the function marker silently dropped the
# module's `init.sh` requirement. Correctness lens, PR #232 round 1.
paths = sorted({rel for m in item.iter_markers("kit_repo_only") for rel in m.args})
_skip_if_missing(paths, "needs")
Loading
Loading