blob: 9385633a6ea078daa2322576936a52a169f15f51 [file]
"""Build the C modules the tests load, with apxs.
Both suites carry small modules that are not part of httpd: mod_aptest,
mod_h1test and mod_h2test of the modules/ suite, and the c-modules/ of
pytest_suite. They are built in place, ``mod_x.c`` in a directory of its own
giving ``.libs/mod_x.so``, and built once: next to the result goes a stamp
recording what the build depended on, and a module is rebuilt when
* the .so is missing,
* a source or header of the module changed,
* the httpd it is built against changed: another apxs, or the same apxs after
the httpd it belongs to was rebuilt with another module magic number or
version (a module built for 2.4 is refused by 2.5 with "expected signature
41503234 but saw 41503235"), or
* the defines or include directories differ.
``--clean-modules`` removes the results, see clean_module_dir().
"""
import hashlib
import json
import logging
import os
import shutil
import subprocess
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Union
log = logging.getLogger(__name__)
LIB_SUBDIR = ".libs" # where apxs/libtool leave the result
_include_fingerprints: Dict[str, str] = {}
def _sha256_files(paths: Iterable[Path]) -> str:
h = hashlib.sha256()
for p in sorted(paths):
h.update(p.name.encode())
try:
h.update(p.read_bytes())
except OSError:
h.update(b"<missing>")
return h.hexdigest()
def _apxs_command(apxs: Union[str, Path]) -> List[str]:
# apxs is a script; run it directly when it can be, as its shebang names
# the perl it was built for, else through the perl on the PATH.
apxs = str(apxs)
return [apxs] if os.access(apxs, os.X_OK) else ["perl", apxs]
def apxs_query(apxs: Union[str, Path], var: str) -> str:
proc = subprocess.run([*_apxs_command(apxs), "-q", var],
capture_output=True, text=True, check=True)
return proc.stdout.strip()
def httpd_fingerprint(apxs: Union[str, Path]) -> str:
"""Identifies the httpd that modules built with *apxs* are made for."""
key = os.path.realpath(str(apxs))
if key not in _include_fingerprints:
try:
include_dir = Path(apxs_query(apxs, "INCLUDEDIR"))
sha = _sha256_files([include_dir / "ap_mmn.h",
include_dir / "ap_release.h"])
except (OSError, subprocess.CalledProcessError):
sha = "unknown"
_include_fingerprints[key] = f"{key}:{sha}"
return _include_fingerprints[key]
def module_so(src_dir: Union[str, Path], name: str) -> Path:
return Path(src_dir) / LIB_SUBDIR / f"mod_{name}.so"
def _stamp_data(src_dir: Path, apxs, defines: Sequence[str],
include_dirs: Sequence[Path], extra_inputs: Sequence[Path]) -> dict:
sources = [p for p in src_dir.iterdir()
if p.is_file() and p.suffix in (".c", ".h")]
return {
"httpd": httpd_fingerprint(apxs),
"sources": _sha256_files(sources),
"extra": _sha256_files(extra_inputs),
"defines": list(defines),
"includes": [str(p) for p in include_dirs],
}
def ensure_built(src_dir: Union[str, Path], name: str, apxs: Union[str, Path], *,
defines: Sequence[str] = (),
include_dirs: Sequence[Union[str, Path]] = (),
extra_inputs: Sequence[Union[str, Path]] = (),
force: bool = False) -> Path:
"""Build ``mod_<name>.c`` in *src_dir* unless the result is up to date.
:param defines: ``-D`` flags for the compile
:param include_dirs: ``-I`` directories
:param extra_inputs: further files the result depends on, e.g. a generated
header in one of *include_dirs*
:returns: the path of the .so
:raises RuntimeError: if apxs fails, with its output
"""
src_dir = Path(src_dir)
so = module_so(src_dir, name)
stamp_file = so.with_suffix(".stamp")
include_dirs = [Path(p) for p in include_dirs]
stamp = _stamp_data(src_dir, apxs, defines, include_dirs,
[Path(p) for p in extra_inputs])
if not force and so.is_file() and stamp_file.is_file():
try:
if json.loads(stamp_file.read_text()) == stamp:
return so
except (OSError, ValueError):
pass
cmd = _apxs_command(apxs)
for d in defines:
cmd += ["-D", d]
for d in include_dirs:
cmd += ["-I", str(d)]
cmd += ["-c", f"mod_{name}.c"]
log.debug("building mod_%s: %s", name, cmd)
stamp_file.unlink(missing_ok=True)
proc = subprocess.run(cmd, cwd=src_dir, capture_output=True, text=True,
check=False)
if proc.returncode != 0 or not so.is_file():
raise RuntimeError(
f"apxs failed building mod_{name} (exit {proc.returncode}):\n"
f"$ {' '.join(cmd)}\n{proc.stdout}\n{proc.stderr}")
stamp_file.write_text(json.dumps(stamp, indent=1))
return so
def clean_module_dir(src_dir: Union[str, Path]) -> None:
"""Remove the build results of the module in *src_dir*, like ``make clean``:
the ``.libs`` directory and libtool's intermediates."""
src_dir = Path(src_dir)
libs = src_dir / LIB_SUBDIR
if libs.is_dir():
shutil.rmtree(libs)
for pattern in ("*.o", "*.lo", "*.slo", "*.la"):
for f in src_dir.glob(pattern):
f.unlink(missing_ok=True)
def clean_test_modules(test_dir: Union[str, Path]) -> None:
"""Clean every module of the modules/ suite under test/: mod_aptest of the
framework, and the mod_* directories next to the tests."""
test_dir = Path(test_dir)
for pattern in ("pyhttpd/mod_*", "modules/*/mod_*"):
for d in test_dir.glob(pattern):
if d.is_dir() and any(d.glob("mod_*.c")):
clean_module_dir(d)