Research-Stack/scripts/probe_science_toolbelt.py
2026-05-12 05:12:46 -05:00

345 lines
10 KiB
Python
Executable file

#!/usr/bin/env python3
"""Probe optional science tools without requiring them.
The goal is not to make these tools part of the default stack. The goal is to
let agents and CI-adjacent smoke checks discover which reference solvers,
format adapters, and verification backends are available on the current host.
Exit code:
0 probe completed; missing optional tools are reported in the payload.
2 invalid CLI arguments or unable to write output.
"""
from __future__ import annotations
import argparse
import importlib.metadata
import json
import os
import shutil
import subprocess
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class PythonTool:
key: str
domain: str
module: str
purpose: str
@dataclass(frozen=True)
class CommandTool:
key: str
domain: str
command: str
purpose: str
version_args: tuple[str, ...] = ("--version",)
PYTHON_TOOLS: tuple[PythonTool, ...] = (
PythonTool(
"biopython",
"genetics",
"Bio",
"FASTA/FASTQ/GenBank parsing and sequence manipulation",
),
PythonTool("pysam", "genetics", "pysam", "SAM/BAM/CRAM/VCF access from Python"),
PythonTool(
"rdkit",
"chemistry",
"rdkit",
"SMILES parsing, canonicalization, descriptors, fingerprints",
),
PythonTool("z3-solver", "formal-methods", "z3", "SMT checks before Lean proof work"),
PythonTool("cvc5", "formal-methods", "cvc5", "SMT checks and model finding"),
PythonTool(
"pycryptodome",
"cryptography",
"Crypto",
"hash/signature/cipher reference primitives",
),
PythonTool("galois", "cryptography", "galois", "finite-field arithmetic"),
PythonTool(
"networkx",
"graph-theory",
"networkx",
"graph certificates and topology receipts",
),
PythonTool(
"pywavelets",
"signal-processing",
"pywt",
"wavelet decompositions for spectral receipts",
),
PythonTool(
"zstandard",
"compression",
"zstandard",
"zstd baseline and dictionary-training adapter",
),
PythonTool("dedalus", "cfd-pde", "dedalus", "spectral PDE reference solver"),
PythonTool(
"liboqs-python",
"cryptography",
"oqs",
"Open Quantum Safe KEM/signature reference adapter",
),
)
COMMAND_TOOLS: tuple[CommandTool, ...] = (
CommandTool(
"samtools",
"genetics",
"samtools",
"SAM/BAM/CRAM command-line verification",
),
CommandTool("bcftools", "genetics", "bcftools", "VCF/BCF command-line verification"),
CommandTool("minimap2", "genetics", "minimap2", "sequence alignment smoke checks"),
CommandTool(
"sage",
"algebra-crypto",
"sage",
"lattice, group, and symbolic mathematics",
("--version",),
),
CommandTool("gap", "algebra", "gap", "computational group-theory checks", ("--version",)),
CommandTool("z3", "formal-methods", "z3", "SMT solver CLI", ("--version",)),
CommandTool("cvc5", "formal-methods", "cvc5", "SMT solver CLI", ("--version",)),
CommandTool("zstd", "compression", "zstd", "compression baseline CLI", ("--version",)),
CommandTool("dot", "graph-theory", "dot", "Graphviz graph rendering for witness diagrams", ("-V",)),
CommandTool("obabel", "chemistry", "obabel", "Open Babel format conversion", ("-V",)),
CommandTool("paraview", "cfd-pde", "paraview", "CFD/PDE visualization frontend", ("--version",)),
)
_DISTRIBUTION_NAMES: dict[str, str] = {
"pywavelets": "PyWavelets",
}
def _distribution_version(tool: PythonTool) -> str | None:
name = _DISTRIBUTION_NAMES.get(tool.key, tool.key)
try:
return importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
return None
def _first_line(text: str) -> str:
for line in text.splitlines():
stripped = line.strip()
if stripped:
return stripped
return text.strip()
def _liboqs_env() -> dict[str, str]:
env = dict(os.environ)
if "OQS_INSTALL_PATH" in env:
return env
home_install = Path.home() / "_oqs"
if (home_install / "lib" / "liboqs.so").exists() or (
home_install / "lib64" / "liboqs.so"
).exists():
env["OQS_INSTALL_PATH"] = str(home_install)
return env
def _has_native_liboqs() -> bool:
candidates = [
Path(os.environ["OQS_INSTALL_PATH"])
for key in ("OQS_INSTALL_PATH",)
if key in os.environ
]
candidates.append(Path.home() / "_oqs")
for root in candidates:
if (root / "lib" / "liboqs.so").exists() or (
root / "lib64" / "liboqs.so"
).exists():
return True
for pattern in ("/usr/lib/liboqs.so*", "/usr/local/lib/liboqs.so*"):
if list(Path("/").glob(pattern.lstrip("/"))):
return True
return False
def _probe_python(tool: PythonTool) -> dict[str, Any]:
installed_version = _distribution_version(tool)
if installed_version is None:
return {
**asdict(tool),
"available": False,
"error": "package is not installed in this Python environment",
}
if tool.key == "liboqs-python" and not _has_native_liboqs():
return {
**asdict(tool),
"available": False,
"installed_version": installed_version,
"error": "Python package is installed, but native liboqs.so is missing",
}
marker = "__SCIENCE_TOOLBELT_PROBE__"
code = f"""
import importlib
import json
module = importlib.import_module({tool.module!r})
version = None
for attr in ("__version__", "VERSION", "version"):
value = getattr(module, attr, None)
if value is None:
continue
if callable(value):
try:
value = value()
except Exception:
continue
version = str(value)
break
print({marker!r} + json.dumps({{"version": version, "path": getattr(module, "__file__", None)}}))
"""
env = _liboqs_env() if tool.key == "liboqs-python" else dict(os.environ)
try:
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
timeout=20,
check=False,
env=env,
)
except Exception as exc:
return {
**asdict(tool),
"available": False,
"installed_version": installed_version,
"error": f"{type(exc).__name__}: {exc}",
}
payload = None
for line in result.stdout.splitlines():
if line.startswith(marker):
payload = json.loads(line[len(marker) :])
if result.returncode != 0 or payload is None:
error = _first_line(result.stderr) or _first_line(result.stdout)
return {
**asdict(tool),
"available": False,
"installed_version": installed_version,
"error": error or f"import subprocess exited {result.returncode}",
}
return {
**asdict(tool),
"available": True,
"version": installed_version,
**(
{"module_version": payload.get("version")}
if payload.get("version") and payload.get("version") != installed_version
else {}
),
"path": payload.get("path"),
}
def _run_version(path: str, args: tuple[str, ...]) -> str | None:
try:
result = subprocess.run(
[path, *args],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except Exception:
return None
text = (result.stdout or result.stderr).strip()
if not text:
return None
return text.splitlines()[0]
def _probe_command(tool: CommandTool) -> dict[str, Any]:
path = shutil.which(tool.command)
if path is None:
return {
**asdict(tool),
"available": False,
}
return {
**asdict(tool),
"available": True,
"path": path,
"version": _run_version(path, tool.version_args),
}
def build_report() -> dict[str, Any]:
python_results = [_probe_python(tool) for tool in PYTHON_TOOLS]
command_results = [_probe_command(tool) for tool in COMMAND_TOOLS]
available = sum(1 for item in python_results + command_results if item["available"])
total = len(python_results) + len(command_results)
by_domain: dict[str, dict[str, int]] = {}
for item in python_results + command_results:
bucket = by_domain.setdefault(item["domain"], {"available": 0, "total": 0})
bucket["total"] += 1
if item["available"]:
bucket["available"] += 1
return {
"schema": "research_stack_optional_science_toolbelt_probe_v1",
"summary": {
"available": available,
"total": total,
"by_domain": by_domain,
},
"python": python_results,
"commands": command_results,
}
def _print_text(report: dict[str, Any]) -> None:
summary = report["summary"]
print(f"optional science toolbelt: {summary['available']}/{summary['total']} available")
for section in ("python", "commands"):
print(f"\n{section}:")
for item in report[section]:
mark = "OK" if item["available"] else "--"
version = item.get("version")
suffix = f" ({version})" if version else ""
print(f" {mark} {item['key']}: {item['purpose']}{suffix}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json", action="store_true", help="emit JSON instead of text")
parser.add_argument("--out", type=Path, help="write the JSON report to this path")
args = parser.parse_args(argv)
report = build_report()
if args.out:
try:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(report, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
except OSError as exc:
print(f"error: failed to write {args.out}: {exc}", file=sys.stderr)
return 2
if args.json:
print(json.dumps(report, indent=2, sort_keys=True))
else:
_print_text(report)
return 0
if __name__ == "__main__":
raise SystemExit(main())