Added more folders and restructured classes inside them.
This commit is contained in:
257
src/ptdiag/experiments/evidence.py
Normal file
257
src/ptdiag/experiments/evidence.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""Evidence collection, serialization, and experiment report generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ..config import AppConfig, project_root
|
||||
from ..filesystem import sha256_file, verify_known_game_hashes
|
||||
from ..logs import LogFileState
|
||||
from ..registry import read_registry_string_values
|
||||
from ..result import ResultDirectory, write_json
|
||||
from ..testcase import Testcase
|
||||
|
||||
|
||||
class HashVerification(Protocol):
|
||||
def to_dict(self) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
HashVerifier = Callable[[Path], Sequence[HashVerification]]
|
||||
HashFile = Callable[[Path], str]
|
||||
RegistryReader = Callable[[Path], Mapping[str, Mapping[str, str]]]
|
||||
JsonWriter = Callable[[Path, Any], None]
|
||||
TextWriter = Callable[[Path, str], None]
|
||||
ReportRenderer = Callable[[Mapping[str, Any]], str]
|
||||
|
||||
|
||||
def collect_hashes(
|
||||
config: AppConfig,
|
||||
testcase: Testcase,
|
||||
*,
|
||||
repository_root: Path | None = None,
|
||||
verifier: HashVerifier = verify_known_game_hashes,
|
||||
hash_file: HashFile = sha256_file,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Collect the immutable inputs needed to interpret one experiment."""
|
||||
|
||||
root = project_root() if repository_root is None else repository_root
|
||||
items: list[dict[str, Any]] = []
|
||||
for verification in verifier(config.paths.live_game):
|
||||
item = verification.to_dict()
|
||||
item["required"] = True
|
||||
item["role"] = "known-game-baseline"
|
||||
items.append(item)
|
||||
extra = {
|
||||
config.proton7.proton: "proton-launcher",
|
||||
config.proton7.prefix
|
||||
/ "drive_c/windows/syswow64/d3dx9_43.dll": "native-d3dx9-43",
|
||||
config.proton7.prefix
|
||||
/ "drive_c/windows/syswow64/d3dcompiler_43.dll": "native-d3dcompiler-43",
|
||||
config.proton7.prefix / "drive_c/windows/syswow64/d3d9.dll": "dxvk-d3d9",
|
||||
root / "ptlinux/proton_compat.py": "proton-python314-shim",
|
||||
config.source: "local-config",
|
||||
testcase.source: "testcase",
|
||||
}
|
||||
dxvk_config = testcase.env.get("DXVK_CONFIG_FILE")
|
||||
if dxvk_config:
|
||||
dxvk_config_path = Path(dxvk_config).expanduser()
|
||||
if not dxvk_config_path.is_absolute():
|
||||
dxvk_config_path = (root / dxvk_config_path).absolute()
|
||||
extra[dxvk_config_path] = "dxvk-config"
|
||||
for path, role in extra.items():
|
||||
item: dict[str, Any] = {
|
||||
"path": str(path),
|
||||
"role": role,
|
||||
"required": role not in {"local-config", "testcase"},
|
||||
"expected_sha256": None,
|
||||
"actual_sha256": None,
|
||||
"ok": False,
|
||||
"error": None,
|
||||
}
|
||||
try:
|
||||
item["actual_sha256"] = hash_file(path)
|
||||
item["ok"] = True
|
||||
except OSError as error:
|
||||
item["error"] = str(error)
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def collect_dll_overrides(
|
||||
prefix: Path,
|
||||
*,
|
||||
reader: RegistryReader = read_registry_string_values,
|
||||
) -> dict[str, str]:
|
||||
sections = reader(prefix / "user.reg")
|
||||
return dict(sorted(sections.get(r"Software\Wine\DllOverrides", {}).items()))
|
||||
|
||||
|
||||
def serialize_log_states(states: Mapping[str, LogFileState]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
serialize_log_state(state)
|
||||
for state in sorted(states.values(), key=lambda item: str(item.path))
|
||||
]
|
||||
|
||||
|
||||
def serialize_log_state(state: LogFileState) -> dict[str, Any]:
|
||||
return {
|
||||
"path": str(state.path),
|
||||
"mtime_ns": state.mtime_ns,
|
||||
"size": state.size,
|
||||
"inode": state.inode,
|
||||
}
|
||||
|
||||
|
||||
def desktop_name(result_directory: Path) -> str:
|
||||
digits = "".join(character for character in result_directory.name if character.isdigit())
|
||||
return f"PTDiag{digits[-12:]}"
|
||||
|
||||
|
||||
def setup_failure_summary(
|
||||
testcase: Testcase,
|
||||
result: ResultDirectory,
|
||||
dry_run: bool,
|
||||
errors: Sequence[str],
|
||||
modification: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"status": "preflight-failed" if errors else "setup-error",
|
||||
"dry_run": dry_run,
|
||||
"test": testcase.name,
|
||||
"result_directory": str(result.path),
|
||||
"preflight": {"ok": False, "errors": list(errors)},
|
||||
"modification": dict(modification),
|
||||
"native_log": {"source": None, "current_run_bytes": 0},
|
||||
"registry": {"restored": None},
|
||||
}
|
||||
|
||||
|
||||
def finalize_result(
|
||||
result: ResultDirectory,
|
||||
summary: dict[str, Any],
|
||||
*,
|
||||
json_writer: JsonWriter = write_json,
|
||||
text_writer: TextWriter,
|
||||
report_renderer: ReportRenderer,
|
||||
) -> None:
|
||||
json_writer(result.path / "summary.json", summary)
|
||||
text_writer(result.path / "report.md", report_renderer(summary))
|
||||
|
||||
|
||||
def render_report(summary: Mapping[str, Any]) -> str:
|
||||
preflight = summary.get("preflight", {})
|
||||
launch = summary.get("launch", {})
|
||||
registry = summary.get("registry", {})
|
||||
native_log = summary.get("native_log", {})
|
||||
modification = summary.get("modification")
|
||||
state_cache = summary.get("dxvk_state_cache")
|
||||
runtime = summary.get("runtime")
|
||||
lines = [
|
||||
f"# {summary.get('test', 'experiment')}",
|
||||
"",
|
||||
f"Status: `{summary.get('status')}`",
|
||||
f"Result directory: `{summary.get('result_directory')}`",
|
||||
f"Dry run: `{summary.get('dry_run')}`",
|
||||
"",
|
||||
"## Preflight",
|
||||
"",
|
||||
(
|
||||
f"Passed: `{preflight.get('ok')}`"
|
||||
if isinstance(preflight, Mapping)
|
||||
else "Passed: `False`"
|
||||
),
|
||||
]
|
||||
if isinstance(preflight, Mapping):
|
||||
for error in preflight.get("errors", []):
|
||||
lines.append(f"- {error}")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Launch",
|
||||
"",
|
||||
f"CWD: `{launch.get('cwd')}`" if isinstance(launch, Mapping) else "",
|
||||
(
|
||||
f"Timeout: `{launch.get('timeout_seconds')}` seconds"
|
||||
if isinstance(launch, Mapping)
|
||||
else ""
|
||||
),
|
||||
"",
|
||||
"## Current-run native log",
|
||||
"",
|
||||
(
|
||||
f"Source: `{native_log.get('source')}`"
|
||||
if isinstance(native_log, Mapping)
|
||||
else ""
|
||||
),
|
||||
(
|
||||
f"Captured bytes: `{native_log.get('current_run_bytes')}`"
|
||||
if isinstance(native_log, Mapping)
|
||||
else ""
|
||||
),
|
||||
"",
|
||||
"## Registry rollback",
|
||||
"",
|
||||
(
|
||||
f"Restored: `{registry.get('restored')}`"
|
||||
if isinstance(registry, Mapping)
|
||||
else ""
|
||||
),
|
||||
]
|
||||
)
|
||||
if summary.get("review_gate"):
|
||||
lines.extend(["", "## Review gate", "", str(summary["review_gate"])])
|
||||
if isinstance(runtime, Mapping) and isinstance(runtime.get("performance"), Mapping):
|
||||
performance = runtime["performance"]
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Performance capture",
|
||||
"",
|
||||
f"Samples: `{performance.get('sample_count')}`",
|
||||
f"Interval: `{performance.get('interval_seconds')}` seconds",
|
||||
f"CSV: `{performance.get('output')}`",
|
||||
]
|
||||
)
|
||||
if isinstance(state_cache, Mapping):
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## DXVK state cache",
|
||||
"",
|
||||
f"Directory: `{state_cache.get('directory')}`",
|
||||
f"Seeded: `{state_cache.get('seeded')}`",
|
||||
f"Before: `{state_cache.get('before')}`",
|
||||
f"After: `{state_cache.get('after')}`",
|
||||
]
|
||||
)
|
||||
if isinstance(modification, Mapping):
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Disposable modification",
|
||||
"",
|
||||
f"Kind: `{modification.get('kind')}`",
|
||||
f"Source: `{modification.get('source')}`",
|
||||
f"Destination: `{modification.get('destination')}`",
|
||||
f"Prepared: `{modification.get('prepared')}`",
|
||||
f"Prefix link restored: `{modification.get('prefix_link_restored')}`",
|
||||
f"Disposable copy removed: `{modification.get('disposable_removed')}`",
|
||||
]
|
||||
)
|
||||
if summary.get("error"):
|
||||
lines.extend(["", "## Error", "", str(summary["error"])])
|
||||
return "\n".join(line for line in lines if line is not None).rstrip() + "\n"
|
||||
|
||||
|
||||
def write_text(path: Path, value: str, *, pid: int | None = None) -> None:
|
||||
"""Atomically write normalized UTF-8 text evidence."""
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
process_id = os.getpid() if pid is None else pid
|
||||
temporary = path.with_name(f".{path.name}.{process_id}.tmp")
|
||||
temporary.write_text(value.rstrip("\n") + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
Reference in New Issue
Block a user