Files

395 lines
15 KiB
Python
Raw Permalink Normal View History

2026-08-03 18:42:47 +08:00
#!/usr/bin/env python3
"""Refine identify members from a curated ID list.
This stage lets users add/remove gene family members after identify. It writes
the pre-final review set used by the subfamily evidence route:
- final/{prefix}.curated_members.id
- final/{prefix}.curated_members.pep.fa
"""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
ID_CANDIDATE_NAMES = (
"{prefix}.curated_members.id",
"{prefix}.final_members.curated.id",
"{prefix}.final_members.edited.id",
"{prefix}.final_members.manual.id",
"curated_members.id",
"final_members.curated.id",
"final_members.edited.id",
)
def sha256(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def read_ids(path: Path) -> list[str]:
ids: list[str] = []
seen: set[str] = set()
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#"):
continue
seq_id = line.split()[0]
if seq_id not in seen:
ids.append(seq_id)
seen.add(seq_id)
return ids
def read_manifest(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def single_target_prefix(prefix: str) -> str:
parts = [p for p in str(prefix or "").split(".") if p]
if len(parts) == 2 and parts[0] != "Target":
return f"Target.{parts[0]}.{parts[1]}"
if len(parts) == 3 and parts[0] == "Target" and parts[1] != "SPL":
return prefix
return ""
def find_id_file(input_dir: Path, workspace: Path, prefix: str, explicit: str = "", target_prefix: str = "") -> Path:
if explicit:
path = Path(explicit)
if not path.is_absolute():
path = input_dir / path
if path.exists():
return path
raise FileNotFoundError(f"Curated ID file not found: {path}")
candidates: list[Path] = []
prefixes = [target_prefix, prefix] if target_prefix and target_prefix != prefix else [prefix]
for root in (input_dir, input_dir / "final", workspace / "final"):
for candidate_prefix in prefixes:
for template in ID_CANDIDATE_NAMES:
candidates.append(root / template.format(prefix=candidate_prefix))
for path in candidates:
if path.exists() and path.is_file():
return path
raise FileNotFoundError(
"No curated ID file found. Upload one of: "
+ ", ".join(t.format(prefix=prefix) for t in ID_CANDIDATE_NAMES)
)
def fasta_record_iter(path: Path):
header = None
seq_lines: list[str] = []
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
for line in fh:
if line.startswith(">"):
if header is not None:
yield header, seq_lines
header = line.rstrip("\n")
seq_lines = []
elif header is not None:
seq_lines.append(line.rstrip("\n"))
if header is not None:
yield header, seq_lines
def fasta_ids(path: Path) -> set[str]:
ids = set()
for header, _ in fasta_record_iter(path):
parts = header[1:].strip().split()
if parts:
ids.add(parts[0])
return ids
def first_column_ids(path: Path) -> set[str]:
ids: set[str] = set()
if not path.exists():
return ids
with path.open("r", encoding="utf-8", errors="ignore") as handle:
header_seen = False
for raw in handle:
line = raw.strip()
if not line:
continue
first = line.split(" ", 1)[0].split()[0]
if not header_seen and first.lower() in {"gene_id", "gene", "id"}:
header_seen = True
continue
header_seen = True
ids.add(first)
return ids
def find_source_fastas(input_dir: Path, workspace: Path, prefix: str, explicit: str = "", target_prefix: str = "") -> list[Path]:
if explicit:
path = Path(explicit)
if not path.is_absolute():
path = input_dir / path
if path.exists():
return [path]
raise FileNotFoundError(f"Source protein FASTA not found: {path}")
manifest = read_manifest(workspace / "final" / f"{prefix}.identify.manifest.json")
paths: list[Path] = []
if target_prefix:
paths.extend([
workspace / "final" / f"{target_prefix}.identified_members.pep.fa",
input_dir / "final" / f"{target_prefix}.identified_members.pep.fa",
workspace / "final" / f"{target_prefix}.final_members.pep.fa",
input_dir / "final" / f"{target_prefix}.final_members.pep.fa",
])
for item in manifest.get("inputs", []) if isinstance(manifest.get("inputs"), list) else []:
full = item.get("full_path")
if full:
paths.append(Path(full))
patterns = (
"*.pep.fasta", "*.pep.fa", "*.protein.fasta", "*.protein.fa",
"*.faa", "*protein*.fasta", "*protein*.fa", "*pep*.fasta", "*pep*.fa",
)
for root in (input_dir, input_dir / "genome_prepare", workspace / "final"):
for pattern in patterns:
paths.extend(root.glob(pattern))
for existing in (
workspace / "final" / f"{prefix}.identified_members.pep.fa",
workspace / "final" / f"{prefix}.final_members.pep.fa",
):
if existing.exists():
paths.append(existing)
unique: list[Path] = []
seen: set[Path] = set()
for path in paths:
try:
resolved = path.resolve()
except Exception:
resolved = path
if resolved in seen or not path.exists() or not path.is_file():
continue
name = path.name.lower()
if any(token in name for token in ("pep", "protein", ".faa", "final_members")):
unique.append(path)
seen.add(resolved)
return unique
def write_subset(ids: list[str], fastas: list[Path], out_fasta: Path) -> tuple[int, list[str], list[Path]]:
wanted = set(ids)
found: dict[str, tuple[str, list[str]]] = {}
used_sources: list[Path] = []
for fasta in fastas:
source_used = False
for header, seq_lines in fasta_record_iter(fasta):
parts = header[1:].strip().split()
if not parts:
continue
seq_id = parts[0]
if seq_id in wanted and seq_id not in found:
found[seq_id] = (header, seq_lines)
source_used = True
if source_used:
used_sources.append(fasta)
out_fasta.parent.mkdir(parents=True, exist_ok=True)
with open(out_fasta, "w", encoding="utf-8") as out:
for seq_id in ids:
if seq_id not in found:
continue
header, seq_lines = found[seq_id]
out.write(header + "\n")
for seq in seq_lines:
out.write(seq + "\n")
missing = [seq_id for seq_id in ids if seq_id not in found]
return len(found), missing, used_sources
def artifact(path: Path, name: str, artifact_type: str) -> dict[str, Any]:
return {
"artifact": name,
"file": path.name,
"full_path": str(path.resolve()),
"relpath_or_hint": f"final/{path.name}",
"type": artifact_type,
"size": path.stat().st_size if path.exists() else 0,
"sha256": sha256(path) if path.exists() else "",
}
def backup(path: Path, tag: str) -> str:
if not path.exists():
return ""
bak = path.with_name(f"{path.name}.bak_{tag}")
shutil.copy2(path, bak)
return str(bak.resolve())
def main() -> int:
parser = argparse.ArgumentParser(description="Rebuild identify final members from a curated ID list")
parser.add_argument("--input-dir", required=True, type=Path)
parser.add_argument("--workspace", required=True, type=Path)
parser.add_argument("--prefix", required=True)
parser.add_argument("--id-file", default="")
parser.add_argument("--source-fasta", default="")
parser.add_argument("--allow-missing", action="store_true")
parser.add_argument("--curated-only", action="store_true", help="Compatibility flag; identify_refine always writes curated_members.*")
args = parser.parse_args()
start = datetime.now()
final_dir = args.workspace / "final"
final_dir.mkdir(parents=True, exist_ok=True)
tag = start.strftime("%Y%m%d_%H%M%S")
target_prefix = single_target_prefix(args.prefix)
id_file = find_id_file(args.input_dir, args.workspace, args.prefix, args.id_file, target_prefix=target_prefix)
ids = read_ids(id_file)
if not ids:
raise RuntimeError(f"Curated ID file is empty: {id_file}")
source_fastas = find_source_fastas(args.input_dir, args.workspace, args.prefix, args.source_fasta, target_prefix=target_prefix)
if not source_fastas:
raise RuntimeError("No source protein FASTA found for curated IDs")
target_source = final_dir / f"{target_prefix}.identified_members.pep.fa" if target_prefix else None
if target_prefix and not target_source.exists():
target_source = final_dir / f"{target_prefix}.final_members.pep.fa"
if target_prefix and not args.source_fasta:
review_ids = first_column_ids(final_dir / f"{target_prefix}.domain_review.tsv")
allowed_ids = review_ids or (fasta_ids(target_source) if target_source and target_source.exists() else set())
foreign_ids = [seq_id for seq_id in ids if allowed_ids and seq_id not in allowed_ids]
if foreign_ids:
raise RuntimeError(
f"Curated IDs do not belong to single target scope {target_prefix}: "
+ ", ".join(foreign_ids[:20])
+ (" ..." if len(foreign_ids) > 20 else "")
)
output_prefix = target_prefix or args.prefix
member_stem = "curated_members"
out_id = final_dir / f"{output_prefix}.{member_stem}.id"
out_pep = final_dir / f"{output_prefix}.{member_stem}.pep.fa"
manifest_path = final_dir / f"{args.prefix}.identify.manifest.json"
old_manifest = read_manifest(manifest_path)
backups = {
"id": backup(out_id, tag),
"pep": backup(out_pep, tag),
"manifest": "",
}
matched, missing, used_sources = write_subset(ids, source_fastas, out_pep)
if missing and not args.allow_missing:
raise RuntimeError(
f"{len(missing)} curated IDs were not found in source FASTA: "
+ ", ".join(missing[:20])
+ (" ..." if len(missing) > 20 else "")
)
out_id.write_text("\n".join([seq_id for seq_id in ids if seq_id not in set(missing)]) + "\n", encoding="utf-8")
report = final_dir / f"{args.prefix}.identify_refine_report.md"
report.write_text(
"\n".join([
"# Identify Member Refinement",
"",
f"- prefix: {args.prefix}",
f"- curated_id_file: {id_file}",
f"- requested_ids: {len(ids)}",
f"- matched_ids: {matched}",
f"- missing_ids: {len(missing)}",
f"- source_fastas: {', '.join(str(p) for p in used_sources)}",
]) + "\n",
encoding="utf-8",
)
manifest = old_manifest if old_manifest else {}
manifest.update({
"manifest_version": manifest.get("manifest_version", "1.0"),
"stage": "identify_refine",
"status": "success",
"prefix": args.prefix,
"output_prefix": output_prefix,
"timestamp": datetime.now().isoformat(),
"end_time": datetime.now().isoformat(),
"elapsed_sec": round((datetime.now() - start).total_seconds(), 2),
"workspace_root": str(args.workspace.resolve()),
"workspace": str(final_dir.resolve()),
"input_dir": str(args.input_dir.resolve()),
"curated_revision": {
"enabled": True,
"stage": "identify_refine",
"curated_only": True,
"curated_id_file": str(id_file.resolve()),
"requested_ids": len(ids),
"matched_ids": matched,
"missing_ids": missing,
"source_fastas": [str(p.resolve()) for p in used_sources],
"backups": backups,
},
})
metrics = manifest.get("metrics", {}) if isinstance(manifest.get("metrics"), dict) else {}
metrics.update({"curated_members": matched, "curated_requested_ids": len(ids), "curated_missing_ids": len(missing)})
manifest["metrics"] = metrics
artifacts = manifest.get("artifacts", {}) if isinstance(manifest.get("artifacts"), dict) else {}
artifacts.update({
"curated_members": artifact(out_pep, "curated_members", "fasta"),
"curated_members_id": artifact(out_id, "curated_members_id", "id_list"),
"identify_refine_report": artifact(report, "identify_refine_report", "markdown"),
})
if target_prefix and target_prefix != args.prefix:
compat_id = final_dir / f"{args.prefix}.{member_stem}.id"
compat_pep = final_dir / f"{args.prefix}.{member_stem}.pep.fa"
shutil.copy2(out_id, compat_id)
shutil.copy2(out_pep, compat_pep)
artifacts["compat_curated_members"] = artifact(compat_pep, "compat_curated_members", "fasta")
artifacts["compat_curated_members_id"] = artifact(compat_id, "compat_curated_members_id", "id_list")
manifest["artifacts"] = artifacts
refine_manifest_path = final_dir / f"{args.prefix}.identify_refine.manifest.json"
refine_manifest = dict(manifest)
refine_manifest["stage"] = "identify_refine"
refine_manifest["derived_stage"] = "identify"
refine_manifest["artifacts"] = dict(artifacts)
if manifest_path.exists():
refine_manifest["artifacts"]["identify_manifest"] = artifact(manifest_path, "identify_manifest", "json")
refine_manifest_path.write_text(json.dumps(refine_manifest, ensure_ascii=False, indent=2), encoding="utf-8")
input_final = args.input_dir / "final"
input_final.mkdir(parents=True, exist_ok=True)
copy_paths = [out_id, out_pep, refine_manifest_path, report]
for path in copy_paths:
try:
shutil.copy2(path, input_final / path.name)
except Exception:
pass
print(f"Identify members refined: {matched}/{len(ids)} IDs")
print("Mode: curated_members")
print(f"Manifest: {refine_manifest_path}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"identify_refine failed: {exc}", file=sys.stderr)
raise SystemExit(1)