Source code for science_tests.reports

"""Publish per-test reports and the release summary of science tests."""

import json
import logging
import re
import shutil
from pathlib import Path

from simtools.science_tests.definitions import dependency_order

REPORTS_DIRECTORY = "reports"
SUMMARY_FILE = "release-summary"
ACCEPTANCE_FILE = "acceptance.md"
_REPORT = "report"
_STATUS = "status"
_EXECUTION = "execution"
_REASON = "reason"
_ACCEPTANCE_REPORT = "acceptance_report"
_PREVIOUS_REPORT = "previous_report"
_NOT_RUN = "not_run"
_JSON_SUFFIX = ".json"
_RELEASE = "release"
_BASELINE = "baseline"
_SIGNATURE = "campaign_sha256"
_QUALIFIED = "qualified"
_RESULTS = "results"
_HISTORY = "production_history"
_UNKNOWN = "unknown"
_FAILED = {"fail", "incomplete", "blocked"}
_ENCODING = "utf-8"
# Work-directory files kept with the execution record but not published.
_UNPUBLISHED_FILES = {
    "execution.log",
    "workflow.yml",
    "effective-settings.json",
    "inventory.json",
}
_PUBLICATION_WARNING_FILES = 30
_PUBLICATION_WARNING_BYTES = 20 * 1024 * 1024

logger = logging.getLogger(__name__)


[docs] def write_test_report(work, report_root, release_dir, result, publish=True): """Publish the products of a successful test to the release report directory. Parameters ---------- work : pathlib.Path Work directory with the test products. report_root : pathlib.Path Report directory of this site/test. release_dir : pathlib.Path Release directory; report paths are recorded relative to it. result : dict Test result, updated with report paths and publication size. publish : bool Publish products; False removes any previous report. """ warnings = result.pop("acceptance_warnings", []) result[_REPORT] = None if not publish: _remove(report_root) return report = report_root.relative_to(release_dir).as_posix() if result[_STATUS] in _FAILED: if report_root.exists(): result[_PREVIOUS_REPORT] = report return staging = report_root.with_name(f".{report_root.name}.new") _remove(staging) if work.is_dir(): shutil.copytree(work, staging, ignore=shutil.ignore_patterns(*_UNPUBLISHED_FILES)) if warnings: staging.mkdir(parents=True, exist_ok=True) _write_acceptance_report(staging, warnings) result[_ACCEPTANCE_REPORT] = f"{report}/{ACCEPTANCE_FILE}" _remove(report_root) if not staging.is_dir() or not any(path.is_file() for path in staging.rglob("*")): _remove(staging) return staging.rename(report_root) result[_REPORT] = report result["publication"] = _publication_size(report_root)
[docs] def release_summary(bundle, results, dry_run, signature): """Merge new results into the previous summary of the same campaign definition. Parameters ---------- bundle : ReleaseDefinition Release definitions. results : list of dict Results of this runner call. dry_run : bool Whether the results are planned only. signature : str Campaign digest; previous results are kept only for the same digest. Returns ------- dict Release summary with results for all required site/test pairs. """ catalogue = bundle.catalogue path = bundle.release_dir / REPORTS_DIRECTORY / f"{SUMMARY_FILE}{_JSON_SUFFIX}" previous = {} if path.is_file() and not dry_run: previous = json.loads(path.read_text(encoding=_ENCODING)) records = {} if previous.get(_SIGNATURE) == signature: records = {record["id"]: record for record in previous.get(_RESULTS, [])} _invalidate_dependents(records, results, catalogue) records.update({result["id"]: result for result in results}) required = [] for site, suite in bundle.suites.items(): for test_id in suite["required_tests"]: key = f"{test_id}.{site}" required.append(key) records.setdefault( key, { "id": key, "test": test_id, "site": site, _REPORT: None, _STATUS: _NOT_RUN, _EXECUTION: _NOT_RUN, _REASON: "Not selected or not executed.", }, ) history = {record["id"]: record for record in previous.get(_HISTORY, [])} history.update( {result["id"]: result for result in results if _affects_production(catalogue, result)} ) return { _RELEASE: bundle.release["release_label"], _BASELINE: bundle.release.get(_BASELINE), "required_sites": list(bundle.suites), "dry_run": dry_run, _SIGNATURE: signature, _QUALIFIED: all(records[key][_STATUS] == "pass" for key in required), _RESULTS: sorted(records.values(), key=_site_test), _HISTORY: sorted(history.values(), key=_site_test), }
[docs] def write_summary(report_dir, summary): """Write the release summary as JSON and Markdown. Parameters ---------- report_dir : pathlib.Path Release report directory. summary : dict Release summary returned by :func:`release_summary`. """ report_dir.mkdir(parents=True, exist_ok=True) (report_dir / f"{SUMMARY_FILE}{_JSON_SUFFIX}").write_text( json.dumps(summary, indent=2) + "\n", encoding=_ENCODING ) lines = [ f"# Science-test summary: {summary[_RELEASE]}", "", f"Qualified: {summary[_QUALIFIED]}", "", "| Site | Test | Execution | Status | Reason | Report |", "| --- | --- | --- | --- | --- | --- |", ] for result in summary[_RESULTS]: reason = result.get("error") or result.get(_REASON) or "-" links = [ f"[{label}]({_relative_link(result[key])})" for key, label in ((_REPORT, _REPORT), (_ACCEPTANCE_REPORT, "deviations")) if result.get(key) ] lines.append( f"| {result['site']} | {result['test']} | {result[_EXECUTION]} | " f"{result[_STATUS]} | {reason} | {'; '.join(links) or '-'} |" ) if summary[_HISTORY]: lines.extend(["", "## Production history", ""]) lines.extend( f"- {record['id']}: {record[_EXECUTION]} ({record[_STATUS]}); " f"run ID `{record.get('run_id', _UNKNOWN)}`." for record in summary[_HISTORY] ) (report_dir / f"{SUMMARY_FILE}.md").write_text("\n".join(lines) + "\n", encoding=_ENCODING)
def _invalidate_dependents(records, results, catalogue): """Mark kept results as not run when upstream evidence of the same site was rerun.""" for record in records.values(): upstream = set(dependency_order([record["test"]], catalogue)) for result in results: if ( result["site"] == record["site"] and result["id"] != record["id"] and (result["test"] in upstream or _affects_production(catalogue, result)) ): record.update( { _STATUS: _NOT_RUN, _EXECUTION: _NOT_RUN, _REASON: "Upstream evidence changed; rerun required.", } ) def _affects_production(catalogue, result): definition = catalogue[result["test"]] return bool(definition.get("produces_production") or definition.get("affects_production")) def _write_acceptance_report(directory, warnings): """Write acceptance deviations, linking PDF page references to the report's PDF.""" pdfs = sorted(directory.glob("*.pdf")) lines = ["# Acceptance deviations", ""] for warning in warnings: if len(pdfs) == 1: warning = re.sub(r"PDF page (\d+)", rf"[PDF page \1]({pdfs[0].name}#page=\1)", warning) lines.append(f"- {warning}") (directory / ACCEPTANCE_FILE).write_text("\n".join(lines) + "\n", encoding=_ENCODING) def _publication_size(report_root): """Return the published file count and size, warning for unusually large reports.""" files = [path for path in report_root.rglob("*") if path.is_file()] byte_count = sum(path.stat().st_size for path in files) size = {"file_count": len(files), "byte_count": byte_count} if len(files) > _PUBLICATION_WARNING_FILES or byte_count > _PUBLICATION_WARNING_BYTES: size["warning"] = "Publication is larger than the compact-report guidance." logger.warning("Science-test publication %s is large: %s", report_root, size) return size def _relative_link(path): return Path(path).relative_to(REPORTS_DIRECTORY).as_posix() def _site_test(record): return record["site"], record["test"] def _remove(path): if path.exists(): shutil.rmtree(path)