| """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 |