blob: 4b0985e65a9cf50dd958f3cb6ec7e02b284480d8 [file]
"""Collection time gating shared by both suites.
One vocabulary of markers, evaluated once per session against the httpd that
:func:`pyhttpd.build.resolve_build` found:
need_module(*names) every named module is available
need_cgi() mod_cgi or mod_cgid
need_php() a PHP SAPI module, or php-fpm via mod_proxy_fcgi
need_ssl() mod_ssl
need_lwp() always satisfied (kept from the Perl vocabulary)
need_min_apache_version(v) httpd >= v
not_backported(rev, what, backported_in=None, platform=None)
expected to fail on httpd without the trunk change
(the test itself: errors in fixtures stay errors)
skip_not_backported(rev, what, backported_in=None, platform=None)
not run there (hangs, crashes, or leaves errors
in the error log)
and, from pyhttpd.depends, ``needs_dependency`` for everything that is not
httpd itself (a client tool, a python package, an ACME server, ...).
A missing module, CGI/PHP/SSL support or dependency is a *missing dependency*:
the test is skipped, or fails under --strict-optional. A test that does not
apply here (httpd too old, change not backported) always skips or xfails. The
"does not apply" gates are added first, so in strict mode they win, exactly as
for ``needs_dependency`` (see pyhttpd.depends).
On trunk the backport marks do nothing; see pyhttpd.backport for backported_in
and platform.
This is a pytest plugin, loaded from test/conftest.py. The markers themselves
are registered in test/pyproject.toml.
"""
import pytest
from .backport import lacks_change, reason
from .build import HttpdInfo, resolve_build
from .depends import apply_gate, strict_optional_enabled
GATE_MARKERS = (
"need_module", "need_cgi", "need_php", "need_ssl", "need_lwp",
"need_min_apache_version", "not_backported", "skip_not_backported",
)
_PHP_SAPI_MODULES = ("sapi_apache2", "mod_php4", "mod_php5", "mod_php7", "mod_php")
# callables (HttpdInfo) -> None that may add to info.modules, for modules the
# suite builds itself and which therefore exist at test time, not yet now
_module_providers = []
_augmented = []
def add_module_provider(provider) -> None:
"""Let a suite declare modules it will build before it runs its tests.
The classic suite compiles its c-modules/ in a session fixture, so
need_module("authany") has to be satisfied at collection time already.
"""
if provider not in _module_providers:
_module_providers.append(provider)
def _augment(info: HttpdInfo) -> None:
if any(i is info for i in _augmented):
return
_augmented.append(info)
for provider in _module_providers:
provider(info)
def _parse_version(ver) -> tuple:
parts = tuple(int(x) for x in str(ver).split("."))
return parts + (0,) * (3 - len(parts))
def _applies(item, info: HttpdInfo) -> None:
"""Add the "does not apply to this httpd" skips and xfails."""
for mark in item.iter_markers(name="need_min_apache_version"):
ver = mark.args[0]
if info.version < _parse_version(ver):
item.add_marker(pytest.mark.skip(
reason=f"httpd {info.version_str} < required {ver}"))
return
for mark in item.iter_markers(name="skip_not_backported"):
if lacks_change(info.version, mark.kwargs.get("backported_in"),
mark.kwargs.get("platform")):
rev, what = mark.args
item.add_marker(pytest.mark.skip(
reason=reason(rev, what, mark.kwargs.get("platform"))))
return
for mark in item.iter_markers(name="not_backported"):
if lacks_change(info.version, mark.kwargs.get("backported_in"),
mark.kwargs.get("platform")):
rev, what = mark.args
item.stash[_EXPECTED_FAILURE] = reason(rev, what, mark.kwargs.get("platform"))
return
# not_backported is an xfail of the test itself, not of its fixtures: a
# pytest.mark.xfail would also turn a broken server setup, a missing client or
# an error log check failing in teardown into an expected failure.
_EXPECTED_FAILURE = pytest.StashKey[str]()
@pytest.hookimpl(wrapper=True)
def pytest_runtest_makereport(item, call):
rep = yield
why = item.stash.get(_EXPECTED_FAILURE, None)
if why is None or call.when != "call" or rep.skipped \
or item.config.option.runxfail:
return rep
# what pytest reports for a non-strict xfail: XFAIL, or XPASS
if rep.failed:
rep.outcome = "skipped"
rep.wasxfail = why
return rep
def _missing(item, info: HttpdInfo, php_fpm: bool) -> list:
"""The unmet dependencies of *item* as (dep, reason, detected) tuples."""
where = f"httpd {info.version_str}" + (f" in {info.prefix}" if info.prefix else "")
missing = []
for mark in item.iter_markers(name="need_module"):
for name in mark.args:
if not info.has_module(name):
missing.append((f"module {name}", f"module {name} not available",
f"not built or loaded: {where}"))
if any(item.iter_markers(name="need_cgi")) and not (
info.has_module("mod_cgi") or info.has_module("mod_cgid")
):
missing.append(("mod_cgi or mod_cgid",
"no CGI module (mod_cgi/mod_cgid) available", where))
if any(item.iter_markers(name="need_php")):
have_sapi = any(info.has_module(m) for m in _PHP_SAPI_MODULES)
have_fpm = php_fpm and info.has_module("mod_proxy_fcgi")
if not (have_sapi or have_fpm):
missing.append(("PHP", "no PHP SAPI module or php-fpm available",
f"no SAPI module, no --php-fpm: {where}"))
if any(item.iter_markers(name="need_ssl")) and not info.has_module("mod_ssl"):
missing.append(("mod_ssl", "mod_ssl not available", where))
# need_lwp is always satisfied (httpx is always present); no check.
return missing
# Runs before pyhttpd.depends' hook: the skips added here are what "does not
# apply" means to it in strict mode.
@pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(config, items):
gated = [item for item in items
if any(item.get_closest_marker(name) for name in GATE_MARKERS)]
if not gated:
return
info = resolve_build(config)
if info is None:
# no httpd to evaluate against: leave the tests to fail or skip
# naturally at fixture setup
return
_augment(info)
strict = strict_optional_enabled(config)
php_fpm = bool(config.getoption("--php-fpm", default=None))
for item in gated:
_applies(item, info)
apply_gate(item, _missing(item, info, php_fpm), strict)