tests/conftest.py had grown to 2,343 lines, past the 2,000-line gate. Move three self-contained topics out, unchanged: - env_filter.py: the credential / behavioral env-var name tables the hermetic fixture blanks; - live_system_guard.py: the autouse live-system guard fixture, its marks and the protected checkout roots; - platform_gating.py: the platforms() marker evaluation used by the collection hook. conftest imports them instead of listing them in pytest_plugins: it is not the rootdir conftest, and pytest fails a run that loads a non-root conftest carrying pytest_plugins after startup. Imported fixtures register on the conftest module under their old names, so autouse order is unchanged. Tests that reached into the moved symbols now import the new modules.
166 lines
6.9 KiB
Python
166 lines
6.9 KiB
Python
"""Host-OS gating for ``@pytest.mark.platforms(...)``, applied by the conftest collection hook."""
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OS gating
|
|
#
|
|
# Hermes runs on Linux, macOS and native Windows, and a lot of its behaviour
|
|
# genuinely differs per host: PTY vs pywinpty, taskkill vs SIGTERM, launchd
|
|
# vs systemd, Keychain vs libsecret, ``%LOCALAPPDATA%`` vs ``~/.hermes``.
|
|
#
|
|
# Historically those code paths were tested by *faking* the host — patching
|
|
# ``sys.platform`` to ``"win32"`` inside a Linux CI job. That gives a green
|
|
# test on a machine where the code under test could not actually run: the
|
|
# fake covers the ``if sys.platform == "win32"`` branch selection but nothing
|
|
# underneath it (``msvcrt`` still isn't importable, ``taskkill`` still isn't
|
|
# on PATH, paths are still POSIX, ``signal.SIGKILL`` still exists). The
|
|
# result was tests that pass on Linux and tell us nothing about Windows.
|
|
#
|
|
# So: a test whose subject is genuinely OS-specific declares the OS it
|
|
# belongs to and runs there for real —
|
|
#
|
|
# @pytest.mark.platforms("windows") → only on native Windows (``sys.platform == "win32"``)
|
|
# @pytest.mark.platforms("macos") → only on macOS (``sys.platform == "darwin"``)
|
|
# @pytest.mark.platforms("linux") → only on Linux (``sys.platform.startswith("linux")``)
|
|
#
|
|
# Elsewhere the test is skipped, not faked. CI runs a dedicated macOS job
|
|
# (``-m platforms("macos")``) and a dedicated Windows job (``-m platforms("windows")``) so
|
|
# those markers are actually exercised on their own host rather than
|
|
# quietly skipped everywhere.
|
|
#
|
|
# This does NOT mean every mention of another platform must be gated. Two
|
|
# things are legitimately host-independent and stay on the Linux runner:
|
|
#
|
|
# • Pure functions that TAKE a platform as data — e.g.
|
|
# ``hidden_windows_child_options(opts, is_windows=True)`` or a
|
|
# ``resolve_launcher(platform_name)`` helper. Passing "win32" as an
|
|
# argument is not faking the host; the function's whole contract is
|
|
# that it maps input to output.
|
|
# • Declaration/packaging invariants — e.g. "pyproject declares tzdata
|
|
# with a ``sys_platform == 'win32'`` marker". That's an assertion about
|
|
# a file, not about runtime behaviour.
|
|
#
|
|
# The line is: if the test needs the interpreter to BELIEVE it is on
|
|
# another OS in order to pass, it belongs on that OS.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PLATFORM_ALIASES = {
|
|
"linux": ("linux",),
|
|
"macos": ("darwin", "macos"),
|
|
"windows": ("win32", "windows"),
|
|
"posix": ("linux", "darwin"),
|
|
"any": (),
|
|
}
|
|
|
|
|
|
def _platform_machine() -> str:
|
|
import platform as _platform
|
|
|
|
machine = (_platform.machine() or "").lower()
|
|
return {"amd64": "x86_64", "x86": "x86_64", "aarch64": "arm64"}.get(machine, machine)
|
|
|
|
|
|
def _host_matches_platforms(conditions, arch=None, arch_negate=False):
|
|
"""Evaluate a platforms() marker payload against the running host.
|
|
|
|
Returns ``(ok, skip_reason)``.
|
|
"""
|
|
host = sys.platform.lower()
|
|
machine = _platform_machine()
|
|
specs = [str(c).strip().lower() for c in conditions if str(c).strip()]
|
|
if not specs:
|
|
return True, "platforms() with no specs matches every host"
|
|
# An unknown spec is a collection error, never a skip: a typo like
|
|
# platforms("linx") would otherwise drop the test on every host while
|
|
# both lanes stay green — the exact failure the gate exists to catch.
|
|
unknown = [spec for spec in specs if spec.removeprefix("not ").strip() not in _PLATFORM_ALIASES]
|
|
if unknown:
|
|
raise pytest.UsageError(
|
|
f"platforms(): unknown spec(s) {', '.join(map(repr, unknown))} — valid: "
|
|
f"{', '.join(sorted(_PLATFORM_ALIASES))}, each optionally prefixed with 'not '"
|
|
)
|
|
for spec in specs:
|
|
negate = spec.startswith("not ")
|
|
leaf = spec[4:].strip() if negate else spec
|
|
wanted = _PLATFORM_ALIASES[leaf]
|
|
matched = (not wanted) or host in wanted
|
|
if negate:
|
|
matched = not matched
|
|
if matched:
|
|
break
|
|
else:
|
|
return False, f"platforms({', '.join(specs)}); host is {sys.platform}"
|
|
if arch is not None:
|
|
arch_l = str(arch).lower()
|
|
arch_hit = machine == arch_l or (
|
|
arch_l in {"arm64", "aarch64"} and machine == "arm64"
|
|
)
|
|
if arch_negate:
|
|
arch_hit = not arch_hit
|
|
if not arch_hit:
|
|
return False, (
|
|
f"platforms(arch={'not ' if arch_negate else ''}{arch}); "
|
|
f"host machine is {machine or 'unknown'}"
|
|
)
|
|
return True, ""
|
|
|
|
|
|
def _platforms_gate_reason(item):
|
|
"""Skip reason when the item's platforms() gating excludes this host."""
|
|
for mark in item.iter_markers("platforms"):
|
|
kwargs = dict(mark.kwargs)
|
|
conds = list(mark.args)
|
|
try:
|
|
ok, reason = _host_matches_platforms(
|
|
conds,
|
|
arch=kwargs.pop("arch", None),
|
|
arch_negate=kwargs.pop("arch_negate", False),
|
|
)
|
|
except pytest.UsageError as exc:
|
|
raise pytest.UsageError(f"{item.nodeid}: {exc}") from None
|
|
if kwargs:
|
|
raise pytest.UsageError(
|
|
f"{item.nodeid}: platforms() got unexpected keyword(s) "
|
|
f"{sorted(kwargs)} — valid: arch, arch_negate"
|
|
)
|
|
if not ok:
|
|
return reason
|
|
return None
|
|
|
|
|
|
def _reject_contradictory_platform_marks(items):
|
|
"""Fail collection when one test carries two platforms() markers.
|
|
|
|
Two markers are ANDed by the gate, so a stacked pair is not always wrong
|
|
in principle — but the historic failure this guard exists for (a
|
|
module-level gate stacking with a per-test gate so the test is skipped
|
|
on every host while both lanes report green) is only diagnosable at
|
|
collection time. A test that needs a compound condition writes ONE
|
|
marker: platforms("linux", arch="arm64").
|
|
"""
|
|
offenders = []
|
|
retired = []
|
|
for item in items:
|
|
marks = list(item.iter_markers("platforms"))
|
|
if len(marks) > 1:
|
|
offenders.append(f" {item.nodeid}: {len(marks)} platforms() marks")
|
|
for legacy in ("linux_only", "macos_only", "windows_only"):
|
|
if item.get_closest_marker(legacy):
|
|
retired.append(f" {item.nodeid}: {legacy}")
|
|
if retired:
|
|
# An unregistered mark is a warning, so a merge that resurrects the old
|
|
# trio would make a host-gated test RUN on every host, unnoticed.
|
|
raise pytest.UsageError(
|
|
"linux_only/macos_only/windows_only were replaced by platforms(...); rewrite:\n"
|
|
+ "\n".join(retired)
|
|
)
|
|
if offenders:
|
|
raise pytest.UsageError(
|
|
"a test may carry at most one platforms() marker — combine the "
|
|
'specs into one call (platforms("linux", arch="arm64") instead '
|
|
"of stacking two markers); these carry several:\n"
|
|
+ "\n".join(offenders)
|
|
)
|