blob: ba059697ebe323c1fe0763ed6a772091efea7c26 [file]
"""Self-test for the optional-dependency gating in pyhttpd/depends.py.
Runs a synthetic test file in a pytester subprocess, with and without
--strict-optional, and asserts skipped-vs-failed outcomes. The subprocess
loads the real plugin with '-p pyhttpd.depends' and nothing else, so this
needs no built httpd, no config.ini and no network.
"""
import os
import pytest
from pyhttpd.env import HttpdTestEnv
TEST_DIR = os.path.dirname(os.path.abspath(__file__))
pytest_plugins = ["pytester"]
class _StubErrorLog:
"""Enough of HttpdErrorLog for the autouse _package_scope fixture."""
def clear_ignored_matches(self):
pass
def clear_ignored_lognos(self):
pass
def add_ignored_lognos(self, lognos):
pass
class _StubEnv:
"""Satisfies the autouse fixtures in test/conftest.py.
Those are written for a test that drives a real httpd; this module
drives a pytester subprocess instead, so every one of them is a no-op
here. apache_stop() must report success, _package_scope asserts on it.
"""
httpd_error_log = _StubErrorLog()
def set_current_test_name(self, name):
pass
def check_error_log(self):
pass
def apache_stop(self):
return 0
@pytest.fixture(scope="package")
def env():
return _StubEnv()
SYNTHETIC = """
import pytest
from pyhttpd.depends import needs_dependency
# dependency missing: skip by default, fail when strict
@needs_dependency("frobnicator", False,
reason="frobnicator >= 2.0 not installed",
detected="frobnicator 1.0 at /usr/bin/frobnicator")
def test_dep_missing():
assert False, "must never run"
# dependency present: always runs
@needs_dependency("widget", True, reason="widget not installed")
def test_dep_present():
assert True
# not applicable: skips in both modes
@pytest.mark.skipif(True, reason="httpd too old")
def test_not_applicable():
assert False, "must never run"
# both kinds of gate on one test: "not applicable" wins, even when strict
@pytest.mark.skipif(True, reason="STRESS_TEST not set in env")
@needs_dependency("frobnicator", False, reason="frobnicator not installed")
def test_not_applicable_beats_missing_dep():
assert False, "must never run"
# an inactive skipif must not shield a missing dependency
@pytest.mark.skipif(False, reason="httpd too old")
@needs_dependency("frobnicator", False, reason="frobnicator not installed")
def test_inactive_skipif_does_not_shield():
assert False, "must never run"
# two dependencies missing at once, both should be reported
@needs_dependency("OCSP responder", False, reason="no OCSP responder")
@needs_dependency("mod_ssl", False, reason="no mod_ssl available")
class TestStacked:
def test_stacked(self):
assert False, "must never run"
# a class-level marker must reach the method
@needs_dependency("a2md", False, reason="no a2md available")
class TestClassLevel:
def test_class_level(self):
assert False, "must never run"
"""
MODULE_LEVEL = """
import pytest
from pyhttpd.depends import needs_dependency
# module-level pytestmark, which a per-decorator scan would not see at all
pytestmark = [needs_dependency("nghttp", False, reason="nghttp not available")]
def test_module_gated():
assert False, "must never run"
"""
LAZY = """
from pyhttpd.depends import needs_dependency
CALLS = []
def probe():
CALLS.append(1)
return False
@needs_dependency("lazy", probe, reason="lazy dep missing")
def test_lazy():
assert False, "must never run"
def test_probe_ran_once():
assert CALLS == [1]
"""
# test_dep_present is the only one that runs; of the rest, test_dep_missing,
# TestStacked and TestClassLevel are dependency gates and the other three
# are "not applicable" or shielded.
ALL_SKIPPED = dict(passed=1, skipped=6)
STRICT_SPLIT = dict(passed=1, skipped=2, errors=4)
@pytest.fixture
def synth(pytester, monkeypatch):
# never inherit strict mode from the outer run
monkeypatch.delenv("PYHTTPD_STRICT_OPTIONAL", raising=False)
# let the subprocess import pyhttpd.depends
monkeypatch.setenv("PYTHONPATH", os.pathsep.join(
[TEST_DIR, os.environ.get("PYTHONPATH", "")]).rstrip(os.pathsep))
pytester.makepyfile(test_synthetic=SYNTHETIC, test_modlevel=MODULE_LEVEL,
test_lazy=LAZY)
return pytester
def _run(pytester, *args):
return pytester.runpytest_subprocess("-p", "pyhttpd.depends",
"test_synthetic.py", *args)
def test_flag_is_advertised(synth):
r = synth.runpytest_subprocess("-p", "pyhttpd.depends", "--help")
r.stdout.fnmatch_lines(["*--strict-optional*"])
def test_default_is_off_and_missing_dep_skips(synth):
r = _run(synth)
r.assert_outcomes(**ALL_SKIPPED)
assert r.ret == 0
def test_default_skip_keeps_the_original_reason(synth):
r = _run(synth, "-rs")
r.stdout.fnmatch_lines(["*frobnicator >= 2.0 not installed*"])
r.stdout.fnmatch_lines(["*no a2md available*"])
def test_strict_flag_fails_on_missing_dep(synth):
r = _run(synth, "--strict-optional")
r.assert_outcomes(**STRICT_SPLIT)
assert r.ret != 0
r.stdout.fnmatch_lines([
"*[[]strict-optional[]] missing optional dependency: frobnicator*",
"*reason:*frobnicator >= 2.0 not installed*",
"*detected: frobnicator 1.0 at /usr/bin/frobnicator*",
"*drop*--strict-optional*",
])
def test_strict_reports_every_missing_dep(synth):
r = _run(synth, "--strict-optional")
r.stdout.fnmatch_lines(["*missing optional dependency: OCSP responder*"])
r.stdout.fnmatch_lines(["*missing optional dependency: mod_ssl*"])
def test_not_applicable_still_skips_when_strict(synth):
r = _run(synth, "--strict-optional", "-rs")
# a plain skipif carrying no dependency marker at all
r.stdout.fnmatch_lines(["*httpd too old*"])
# and one stacked on a missing dependency: the skipif wins, and the
# reason reported is its own, not the dependency's
r.stdout.fnmatch_lines(["*STRESS_TEST not set in env*"])
def test_env_var_enables_strict(synth, monkeypatch):
monkeypatch.setenv("PYHTTPD_STRICT_OPTIONAL", "1")
r = _run(synth)
r.assert_outcomes(**STRICT_SPLIT)
assert r.ret != 0
@pytest.mark.parametrize("val", ["", "0", "false", "no", "off"])
def test_env_var_falsey_values_keep_strict_off(synth, monkeypatch, val):
monkeypatch.setenv("PYHTTPD_STRICT_OPTIONAL", val)
r = _run(synth)
r.assert_outcomes(**ALL_SKIPPED)
assert r.ret == 0
def test_module_level_pytestmark_is_honoured(synth):
r = synth.runpytest_subprocess("-p", "pyhttpd.depends", "test_modlevel.py")
r.assert_outcomes(skipped=1)
r = synth.runpytest_subprocess("-p", "pyhttpd.depends", "test_modlevel.py",
"--strict-optional")
r.assert_outcomes(errors=1)
def test_callable_available_is_evaluated_once_at_collection(synth):
r = synth.runpytest_subprocess("-p", "pyhttpd.depends", "test_lazy.py")
r.assert_outcomes(passed=1, skipped=1)
# --- env.require(), the in-test counterpart of the decorator ---------------
#
# Exercised against a stand-in rather than a real HttpdTestEnv, which would
# need a built httpd and a running server just to reach the one branch that
# matters here.
class _RequireEnv:
require = HttpdTestEnv.require
def __init__(self, strict):
self._strict_optional = strict
self.current_test_name = "unit::require"
def test_require_is_a_noop_when_the_dependency_is_present():
_RequireEnv(strict=False).require(True, "need curl")
_RequireEnv(strict=True).require(True, "need curl")
def test_require_skips_when_not_strict():
with pytest.raises(BaseException) as exc:
_RequireEnv(strict=False).require(
False, "need at least curl v8.0.0 for this",
dep="curl", detected="curl 7.88.1")
assert exc.value.__class__.__name__ == "Skipped"
assert "need at least curl v8.0.0 for this" in str(exc.value.args[0])
def test_require_reports_the_dependency_name_not_the_reason():
"""dep is what is missing; reason is why the test wanted it."""
with pytest.raises(BaseException) as exc:
_RequireEnv(strict=True).require(False, "no mod_ssl available")
# no dep= given, so the reason stands in for the name
assert "missing optional dependency: no mod_ssl available" in str(exc.value.args[0])
# --- the tool probes -------------------------------------------------------
#
# config.ini always names curl/nghttp/h2load, so the name alone says nothing
# about whether the tool is installed. Probing used to raise FileNotFoundError
# out of collection; it must report "absent" instead.
class _ProbeEnv:
"""Drives the curl probe without a config.ini or a built httpd."""
_run_version = staticmethod(HttpdTestEnv._run_version)
_versiontuple = HttpdTestEnv._versiontuple
_probe_curl = HttpdTestEnv._probe_curl
has_curl = HttpdTestEnv.has_curl
curl_version_str = HttpdTestEnv.curl_version_str
curl_is_at_least = HttpdTestEnv.curl_is_at_least
curl_is_less_than = HttpdTestEnv.curl_is_less_than
curl_is_8_1_x = HttpdTestEnv.curl_is_8_1_x
def __init__(self, curl):
self._curl = curl
self._curl_version = None
@pytest.mark.parametrize("curl", ["/nonexistent/curl", "curl-does-not-exist", ""])
def test_absent_curl_probes_as_missing_rather_than_raising(curl):
env = _ProbeEnv(curl)
assert env.has_curl() is False
assert env.curl_version_str() is None
# every comparison answers False rather than blowing up collection
assert env.curl_is_at_least('8.0.0') is False
assert env.curl_is_less_than('8.0.0') is False
assert env.curl_is_8_1_x() is False
def test_require_fails_when_strict_and_names_the_dependency():
with pytest.raises(BaseException) as exc:
_RequireEnv(strict=True).require(
False, "need at least curl v8.0.0 for this",
dep="curl", detected="curl 7.88.1")
assert exc.value.__class__.__name__ == "Failed"
msg = str(exc.value.args[0])
assert "[strict-optional] missing optional dependency: curl" in msg
assert "reason: need at least curl v8.0.0 for this" in msg
assert "detected: curl 7.88.1" in msg
assert "unit::require" in msg