forked from diegoecab/oci-deal-accelerator
650 lines
22 KiB
Python
650 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
OCI Deal Accelerator — ADB Feature Compatibility Matrix CLI
|
|
|
|
Query, compare, and update the ADB feature compatibility matrix from
|
|
the command line.
|
|
|
|
Usage:
|
|
python feature_matrix_cli.py check "auto scaling" adb_s 23ai
|
|
python feature_matrix_cli.py compare adb_s exacs 23ai
|
|
python feature_matrix_cli.py gaps adb_s 23ai
|
|
python feature_matrix_cli.py update "Auto Scaling (OCPU)" adb_s 23ai --status GA --notes "Updated"
|
|
python feature_matrix_cli.py export --format markdown
|
|
python feature_matrix_cli.py export --format csv
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import io
|
|
import os
|
|
import sys
|
|
from datetime import date
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
VALID_CONFIDENCE = {"validated", "observed", "reported", "inferred"}
|
|
|
|
import yaml
|
|
|
|
|
|
# =============================================================================
|
|
# Constants
|
|
# =============================================================================
|
|
|
|
MATRIX_PATH = os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)),
|
|
"..", "kb", "compatibility", "adb-feature-matrix.yaml",
|
|
)
|
|
|
|
VALID_STATUSES = {"GA", "GA_CAVEAT", "PREVIEW", "LIMITED", "NOT_AVAIL", "BROKEN", "UNTESTED"}
|
|
|
|
# ANSI color codes for terminal output
|
|
_COLORS = {
|
|
"GA": "\033[32m", # green
|
|
"GA_CAVEAT": "\033[33m", # yellow
|
|
"PREVIEW": "\033[36m", # cyan
|
|
"LIMITED": "\033[93m", # bright yellow
|
|
"NOT_AVAIL": "\033[31m", # red
|
|
"BROKEN": "\033[1;31m", # red bold
|
|
"UNTESTED": "\033[90m", # gray
|
|
}
|
|
_RESET = "\033[0m"
|
|
|
|
|
|
# =============================================================================
|
|
# Helpers
|
|
# =============================================================================
|
|
|
|
def _color(status: str) -> str:
|
|
"""Wrap a status string with the appropriate ANSI color."""
|
|
code = _COLORS.get(status, "")
|
|
return f"{code}{status}{_RESET}" if code else status
|
|
|
|
|
|
def _supports_color() -> bool:
|
|
"""Return True when stdout is a tty that likely supports ANSI escapes."""
|
|
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
|
|
|
|
|
def _plain(status: str) -> str:
|
|
"""Return the status without color codes."""
|
|
return status
|
|
|
|
|
|
def _get_color_fn():
|
|
"""Return the appropriate formatter (colored or plain)."""
|
|
return _color if _supports_color() else _plain
|
|
|
|
|
|
def load_matrix(path: str) -> Dict[str, Any]:
|
|
"""Load the YAML matrix file and return its contents as a dict."""
|
|
resolved = os.path.realpath(path)
|
|
if not os.path.isfile(resolved):
|
|
print(f"Error: Matrix file not found at {resolved}", file=sys.stderr)
|
|
sys.exit(1)
|
|
with open(resolved, "r", encoding="utf-8") as fh:
|
|
try:
|
|
data = yaml.safe_load(fh)
|
|
except yaml.YAMLError as exc:
|
|
print(f"Error: Failed to parse YAML — {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
if not data or "features" not in data:
|
|
print("Error: Matrix file is missing 'features' key.", file=sys.stderr)
|
|
sys.exit(1)
|
|
return data
|
|
|
|
|
|
def save_matrix(path: str, data: Dict[str, Any]) -> None:
|
|
"""Write the matrix dict back to YAML, preserving readability."""
|
|
resolved = os.path.realpath(path)
|
|
with open(resolved, "w", encoding="utf-8") as fh:
|
|
yaml.dump(
|
|
data,
|
|
fh,
|
|
default_flow_style=False,
|
|
sort_keys=False,
|
|
allow_unicode=True,
|
|
width=120,
|
|
)
|
|
|
|
|
|
def get_version_ids(data: Dict[str, Any]) -> List[str]:
|
|
"""Return ordered list of version ids from the matrix."""
|
|
return [v["id"] for v in data.get("versions", [])]
|
|
|
|
|
|
def get_deployment_ids(data: Dict[str, Any]) -> List[str]:
|
|
"""Return ordered list of deployment type ids from the matrix."""
|
|
return [d["id"] for d in data.get("deployment_types", [])]
|
|
|
|
|
|
def get_deployment_name(data: Dict[str, Any], deploy_id: str) -> str:
|
|
"""Look up the human-readable name for a deployment id."""
|
|
for d in data.get("deployment_types", []):
|
|
if d["id"] == deploy_id:
|
|
return d.get("name", deploy_id)
|
|
return deploy_id
|
|
|
|
|
|
def validate_deployment(data: Dict[str, Any], deploy_id: str) -> None:
|
|
"""Exit with an error if the deployment id is not in the matrix."""
|
|
valid = get_deployment_ids(data)
|
|
if deploy_id not in valid:
|
|
print(
|
|
f"Error: Unknown deployment type '{deploy_id}'. "
|
|
f"Valid types: {', '.join(valid)}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def validate_version(data: Dict[str, Any], version: str) -> None:
|
|
"""Exit with an error if the version is not in the matrix."""
|
|
valid = get_version_ids(data)
|
|
if version not in valid:
|
|
print(
|
|
f"Error: Unknown version '{version}'. Valid versions: {', '.join(valid)}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def find_features(data: Dict[str, Any], query: str) -> List[Dict[str, Any]]:
|
|
"""
|
|
Find features whose name matches *query* (case-insensitive, partial).
|
|
Returns a list of matching feature dicts.
|
|
"""
|
|
query_lower = query.lower()
|
|
return [
|
|
f for f in data["features"]
|
|
if query_lower in f["name"].lower()
|
|
]
|
|
|
|
|
|
def get_cell(feature: Dict[str, Any], deploy: str, version: str) -> Optional[Dict[str, Any]]:
|
|
"""Return the matrix cell dict for a feature/deploy/version, or None."""
|
|
return feature.get("matrix", {}).get(deploy, {}).get(version)
|
|
|
|
|
|
# =============================================================================
|
|
# Library API — importable functions for programmatic use and testing
|
|
# =============================================================================
|
|
|
|
def check(feature_name: str, deployment: str, version: str, matrix_path: str = MATRIX_PATH) -> Dict[str, Any]:
|
|
"""Look up a feature/deployment/version and return {status, notes, feature_name}."""
|
|
data = load_matrix(matrix_path)
|
|
matches = find_features(data, feature_name)
|
|
if not matches:
|
|
raise LookupError(f"No features matching '{feature_name}'")
|
|
if len(matches) > 1:
|
|
raise LookupError(
|
|
f"Ambiguous feature name '{feature_name}'. Matches: "
|
|
+ ", ".join(m["name"] for m in matches)
|
|
)
|
|
feature = matches[0]
|
|
cell = get_cell(feature, deployment, version)
|
|
if cell is None:
|
|
return {"status": "UNTESTED", "notes": "no entry in matrix", "feature_name": feature["name"]}
|
|
return {
|
|
"status": cell.get("status", "UNTESTED"),
|
|
"notes": cell.get("notes", ""),
|
|
"feature_name": feature["name"],
|
|
}
|
|
|
|
|
|
def compare(deploy1: str, deploy2: str, version: str, matrix_path: str = MATRIX_PATH) -> List[Dict[str, Any]]:
|
|
"""Compare two deployment types. Returns list of dicts with name, status1, status2."""
|
|
data = load_matrix(matrix_path)
|
|
results = []
|
|
for feature in data["features"]:
|
|
cell1 = get_cell(feature, deploy1, version)
|
|
cell2 = get_cell(feature, deploy2, version)
|
|
results.append({
|
|
"name": feature["name"],
|
|
"status1": cell1["status"] if cell1 else "UNTESTED",
|
|
"status2": cell2["status"] if cell2 else "UNTESTED",
|
|
})
|
|
return results
|
|
|
|
|
|
def gaps(deployment: str, version: str, matrix_path: str = MATRIX_PATH) -> List[Dict[str, Any]]:
|
|
"""List features that are NOT_AVAIL, BROKEN, or LIMITED."""
|
|
data = load_matrix(matrix_path)
|
|
gap_statuses = {"NOT_AVAIL", "BROKEN", "LIMITED"}
|
|
results = []
|
|
for feature in data["features"]:
|
|
cell = get_cell(feature, deployment, version)
|
|
if cell is None:
|
|
continue
|
|
status = cell.get("status", "UNTESTED")
|
|
if status in gap_statuses:
|
|
results.append({
|
|
"name": feature["name"],
|
|
"status": status,
|
|
"notes": cell.get("notes", ""),
|
|
})
|
|
return results
|
|
|
|
|
|
def export_markdown(output_path: str, matrix_path: str = MATRIX_PATH) -> None:
|
|
"""Export the full matrix as a markdown table to a file."""
|
|
data = load_matrix(matrix_path)
|
|
versions = get_version_ids(data)
|
|
deployments = get_deployment_ids(data)
|
|
columns = [(d, v) for d in deployments for v in versions]
|
|
col_labels = [f"{d}/{v}" for d, v in columns]
|
|
features = data["features"]
|
|
|
|
feat_width = max((len(f["name"]) for f in features), default=7)
|
|
feat_width = max(feat_width, len("Feature"))
|
|
col_widths = [max(len(lbl), 10) for lbl in col_labels]
|
|
|
|
lines = []
|
|
header = f"| {'Feature':<{feat_width}} |"
|
|
for lbl, w in zip(col_labels, col_widths):
|
|
header += f" {lbl:<{w}} |"
|
|
lines.append(header)
|
|
|
|
sep = f"|{'-' * (feat_width + 2)}|"
|
|
for w in col_widths:
|
|
sep += f"{'-' * (w + 2)}|"
|
|
lines.append(sep)
|
|
|
|
for feature in features:
|
|
row = f"| {feature['name']:<{feat_width}} |"
|
|
for (d, v), w in zip(columns, col_widths):
|
|
cell = get_cell(feature, d, v)
|
|
status = cell["status"] if cell else "-"
|
|
row += f" {status:<{w}} |"
|
|
lines.append(row)
|
|
|
|
with open(output_path, "w", encoding="utf-8") as fh:
|
|
fh.write("\n".join(lines) + "\n")
|
|
|
|
|
|
def export_csv(output_path: str, matrix_path: str = MATRIX_PATH) -> None:
|
|
"""Export the full matrix as CSV to a file."""
|
|
data = load_matrix(matrix_path)
|
|
versions = get_version_ids(data)
|
|
deployments = get_deployment_ids(data)
|
|
columns = [(d, v) for d in deployments for v in versions]
|
|
col_labels = [f"{d}/{v}" for d, v in columns]
|
|
|
|
with open(output_path, "w", encoding="utf-8", newline="") as fh:
|
|
writer = csv.writer(fh)
|
|
writer.writerow(["Feature", "Category"] + col_labels)
|
|
for feature in data["features"]:
|
|
row = [feature["name"], feature.get("category", "")]
|
|
for d, v in columns:
|
|
cell = get_cell(feature, d, v)
|
|
row.append(cell["status"] if cell else "")
|
|
writer.writerow(row)
|
|
|
|
|
|
# =============================================================================
|
|
# Subcommands (CLI handlers)
|
|
# =============================================================================
|
|
|
|
def cmd_check(args: argparse.Namespace) -> None:
|
|
"""Look up a specific feature / deployment / version combo."""
|
|
data = load_matrix(MATRIX_PATH)
|
|
validate_deployment(data, args.deployment)
|
|
validate_version(data, args.version)
|
|
|
|
matches = find_features(data, args.feature)
|
|
|
|
if not matches:
|
|
print(f"No features matching '{args.feature}'.")
|
|
sys.exit(1)
|
|
|
|
if len(matches) > 1:
|
|
print(f"Ambiguous feature name '{args.feature}'. Multiple matches:")
|
|
for m in matches:
|
|
print(f" - {m['name']}")
|
|
print("\nPlease be more specific.")
|
|
sys.exit(1)
|
|
|
|
feature = matches[0]
|
|
cell = get_cell(feature, args.deployment, args.version)
|
|
cfn = _get_color_fn()
|
|
deploy_name = get_deployment_name(data, args.deployment)
|
|
|
|
print(f"Feature: {feature['name']}")
|
|
print(f"Deployment: {deploy_name} ({args.deployment})")
|
|
print(f"Version: {args.version}")
|
|
print()
|
|
|
|
if cell is None:
|
|
print(f"Status: {cfn('UNTESTED')} (no entry in matrix)")
|
|
else:
|
|
status = cell.get("status", "UNTESTED")
|
|
print(f"Status: {cfn(status)}")
|
|
notes = cell.get("notes")
|
|
if notes:
|
|
print(f"Notes: {notes}")
|
|
|
|
|
|
def cmd_compare(args: argparse.Namespace) -> None:
|
|
"""Side-by-side comparison of two deployment types for a version."""
|
|
data = load_matrix(MATRIX_PATH)
|
|
validate_deployment(data, args.deploy1)
|
|
validate_deployment(data, args.deploy2)
|
|
validate_version(data, args.version)
|
|
|
|
cfn = _get_color_fn()
|
|
name1 = get_deployment_name(data, args.deploy1)
|
|
name2 = get_deployment_name(data, args.deploy2)
|
|
|
|
# Determine column widths
|
|
feature_names = [f["name"] for f in data["features"]]
|
|
col_feat = max(len(n) for n in feature_names) + 2
|
|
col_status = max(len(name1), len(name2), 12) + 2
|
|
|
|
header = (
|
|
f"{'Feature':<{col_feat}}"
|
|
f" {name1:<{col_status}}"
|
|
f" {name2:<{col_status}}"
|
|
)
|
|
print(f"Comparison: {name1} vs {name2} (version {args.version})")
|
|
print()
|
|
print(header)
|
|
print("-" * len(header))
|
|
|
|
for feature in data["features"]:
|
|
cell1 = get_cell(feature, args.deploy1, args.version)
|
|
cell2 = get_cell(feature, args.deploy2, args.version)
|
|
s1 = cell1["status"] if cell1 else "UNTESTED"
|
|
s2 = cell2["status"] if cell2 else "UNTESTED"
|
|
|
|
# For plain (non-tty) we can just print aligned; for color we need
|
|
# to pad BEFORE wrapping with color codes so alignment is correct.
|
|
s1_padded = f"{s1:<{col_status}}"
|
|
s2_padded = f"{s2:<{col_status}}"
|
|
|
|
if _supports_color():
|
|
s1_display = f"{_COLORS.get(s1, '')}{s1_padded}{_RESET}"
|
|
s2_display = f"{_COLORS.get(s2, '')}{s2_padded}{_RESET}"
|
|
else:
|
|
s1_display = s1_padded
|
|
s2_display = s2_padded
|
|
|
|
print(f"{feature['name']:<{col_feat}} {s1_display} {s2_display}")
|
|
|
|
|
|
def cmd_gaps(args: argparse.Namespace) -> None:
|
|
"""List features that are NOT_AVAIL, BROKEN, or LIMITED for a deploy/version."""
|
|
data = load_matrix(MATRIX_PATH)
|
|
validate_deployment(data, args.deployment)
|
|
validate_version(data, args.version)
|
|
|
|
cfn = _get_color_fn()
|
|
deploy_name = get_deployment_name(data, args.deployment)
|
|
gap_statuses = {"NOT_AVAIL", "BROKEN", "LIMITED"}
|
|
gaps: List[Tuple[str, str, Optional[str]]] = []
|
|
|
|
for feature in data["features"]:
|
|
cell = get_cell(feature, args.deployment, args.version)
|
|
if cell is None:
|
|
# No entry — could be untested or implicitly not available
|
|
continue
|
|
status = cell.get("status", "UNTESTED")
|
|
if status in gap_statuses:
|
|
gaps.append((feature["name"], status, cell.get("notes")))
|
|
|
|
print(f"Gaps for {deploy_name} ({args.deployment}), version {args.version}:")
|
|
print()
|
|
|
|
if not gaps:
|
|
print(" No gaps found — all mapped features are GA, GA_CAVEAT, PREVIEW, or UNTESTED.")
|
|
return
|
|
|
|
name_width = max(len(g[0]) for g in gaps) + 2
|
|
for name, status, notes in gaps:
|
|
status_display = cfn(status)
|
|
line = f" {name:<{name_width}} {status_display}"
|
|
if notes:
|
|
line += f" — {notes}"
|
|
print(line)
|
|
|
|
|
|
def cmd_update(args: argparse.Namespace) -> None:
|
|
"""Modify a specific cell in the matrix."""
|
|
status = args.status.upper()
|
|
if status not in VALID_STATUSES:
|
|
print(
|
|
f"Error: Invalid status '{args.status}'. "
|
|
f"Valid statuses: {', '.join(sorted(VALID_STATUSES))}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
data = load_matrix(MATRIX_PATH)
|
|
validate_deployment(data, args.deployment)
|
|
validate_version(data, args.version)
|
|
|
|
matches = find_features(data, args.feature)
|
|
|
|
if not matches:
|
|
print(f"Error: No features matching '{args.feature}'.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if len(matches) > 1:
|
|
print(f"Ambiguous feature name '{args.feature}'. Multiple matches:")
|
|
for m in matches:
|
|
print(f" - {m['name']}")
|
|
print("\nPlease use the exact feature name.")
|
|
sys.exit(1)
|
|
|
|
feature = matches[0]
|
|
|
|
# Ensure nested dicts exist
|
|
if "matrix" not in feature:
|
|
feature["matrix"] = {}
|
|
if args.deployment not in feature["matrix"]:
|
|
feature["matrix"][args.deployment] = {}
|
|
|
|
cell: Dict[str, Any] = {"status": status}
|
|
if args.notes:
|
|
cell["notes"] = args.notes
|
|
|
|
# Add contributor attribution if provided
|
|
contributor_name = getattr(args, "name", None)
|
|
contributor_team = getattr(args, "team", None)
|
|
if contributor_name and contributor_team:
|
|
contributor = {
|
|
"name": contributor_name,
|
|
"team": contributor_team,
|
|
"date": date.today().isoformat(),
|
|
}
|
|
confidence = getattr(args, "confidence", None)
|
|
if confidence and confidence in VALID_CONFIDENCE:
|
|
contributor["confidence"] = confidence
|
|
cell["contributor"] = contributor
|
|
|
|
# Add field_finding_ref if provided
|
|
field_ref = getattr(args, "field_finding_ref", None)
|
|
if field_ref:
|
|
cell["field_finding_ref"] = field_ref
|
|
|
|
feature["matrix"][args.deployment][args.version] = cell
|
|
|
|
# Update last_verified at root
|
|
data["last_verified"] = date.today().isoformat()
|
|
|
|
save_matrix(MATRIX_PATH, data)
|
|
|
|
cfn = _get_color_fn()
|
|
print(f"Updated '{feature['name']}' / {args.deployment} / {args.version}:")
|
|
print(f" Status: {cfn(status)}")
|
|
if args.notes:
|
|
print(f" Notes: {args.notes}")
|
|
print(f" last_verified set to {data['last_verified']}")
|
|
|
|
|
|
def cmd_export(args: argparse.Namespace) -> None:
|
|
"""Export the full matrix as markdown or CSV."""
|
|
data = load_matrix(MATRIX_PATH)
|
|
versions = get_version_ids(data)
|
|
deployments = get_deployment_ids(data)
|
|
|
|
# Build column headers: each column is deploy + version
|
|
columns: List[Tuple[str, str]] = []
|
|
for d in deployments:
|
|
for v in versions:
|
|
columns.append((d, v))
|
|
|
|
col_labels = [f"{d}/{v}" for d, v in columns]
|
|
|
|
if args.format == "csv":
|
|
_export_csv(data, columns, col_labels)
|
|
else:
|
|
_export_markdown(data, columns, col_labels)
|
|
|
|
|
|
def _export_markdown(
|
|
data: Dict[str, Any],
|
|
columns: List[Tuple[str, str]],
|
|
col_labels: List[str],
|
|
) -> None:
|
|
"""Print a markdown table to stdout."""
|
|
features = data["features"]
|
|
|
|
# Column widths
|
|
feat_width = max(len(f["name"]) for f in features)
|
|
feat_width = max(feat_width, len("Feature"))
|
|
col_widths = [max(len(lbl), 10) for lbl in col_labels]
|
|
|
|
# Header row
|
|
header = f"| {'Feature':<{feat_width}} |"
|
|
for lbl, w in zip(col_labels, col_widths):
|
|
header += f" {lbl:<{w}} |"
|
|
print(header)
|
|
|
|
# Separator
|
|
sep = f"|{'-' * (feat_width + 2)}|"
|
|
for w in col_widths:
|
|
sep += f"{'-' * (w + 2)}|"
|
|
print(sep)
|
|
|
|
# Data rows
|
|
for feature in features:
|
|
row = f"| {feature['name']:<{feat_width}} |"
|
|
for (d, v), w in zip(columns, col_widths):
|
|
cell = get_cell(feature, d, v)
|
|
status = cell["status"] if cell else "-"
|
|
row += f" {status:<{w}} |"
|
|
print(row)
|
|
|
|
|
|
def _export_csv(
|
|
data: Dict[str, Any],
|
|
columns: List[Tuple[str, str]],
|
|
col_labels: List[str],
|
|
) -> None:
|
|
"""Print CSV to stdout."""
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
|
|
# Header
|
|
writer.writerow(["Feature", "Category"] + col_labels)
|
|
|
|
for feature in data["features"]:
|
|
row = [feature["name"], feature.get("category", "")]
|
|
for d, v in columns:
|
|
cell = get_cell(feature, d, v)
|
|
row.append(cell["status"] if cell else "")
|
|
writer.writerow(row)
|
|
|
|
print(output.getvalue(), end="")
|
|
|
|
|
|
# =============================================================================
|
|
# CLI entry point
|
|
# =============================================================================
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
prog="feature_matrix_cli",
|
|
description="Query and update the ADB feature compatibility matrix.",
|
|
)
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
# --- check ---
|
|
p_check = subparsers.add_parser(
|
|
"check",
|
|
help="Look up a specific feature/deployment/version combo.",
|
|
)
|
|
p_check.add_argument("feature", help="Feature name (case-insensitive, partial match).")
|
|
p_check.add_argument("deployment", help="Deployment type id (e.g. adb_s, exacs).")
|
|
p_check.add_argument("version", help="Version id (e.g. 23ai, 26ai).")
|
|
|
|
# --- compare ---
|
|
p_compare = subparsers.add_parser(
|
|
"compare",
|
|
help="Side-by-side comparison of two deployment types for a version.",
|
|
)
|
|
p_compare.add_argument("deploy1", help="First deployment type id.")
|
|
p_compare.add_argument("deploy2", help="Second deployment type id.")
|
|
p_compare.add_argument("version", help="Version id.")
|
|
|
|
# --- gaps ---
|
|
p_gaps = subparsers.add_parser(
|
|
"gaps",
|
|
help="List features that are NOT_AVAIL, BROKEN, or LIMITED.",
|
|
)
|
|
p_gaps.add_argument("deployment", help="Deployment type id.")
|
|
p_gaps.add_argument("version", help="Version id.")
|
|
|
|
# --- update ---
|
|
p_update = subparsers.add_parser(
|
|
"update",
|
|
help="Modify a specific cell in the matrix.",
|
|
)
|
|
p_update.add_argument("feature", help="Feature name (case-insensitive, partial match).")
|
|
p_update.add_argument("deployment", help="Deployment type id.")
|
|
p_update.add_argument("version", help="Version id.")
|
|
p_update.add_argument("--status", required=True, help="New status value.")
|
|
p_update.add_argument("--notes", default=None, help="Optional notes for the cell.")
|
|
p_update.add_argument("--name", default=None, help="Contributor name for attribution.")
|
|
p_update.add_argument("--team", default=None, help="Contributor team for attribution.")
|
|
p_update.add_argument("--confidence", default=None, help="Confidence level: validated/observed/reported/inferred.")
|
|
p_update.add_argument("--field-finding-ref", default=None, help="Reference to field finding ID.")
|
|
|
|
# --- export ---
|
|
p_export = subparsers.add_parser(
|
|
"export",
|
|
help="Export the full matrix as markdown or CSV.",
|
|
)
|
|
p_export.add_argument(
|
|
"--format",
|
|
required=True,
|
|
choices=["markdown", "csv"],
|
|
help="Output format.",
|
|
)
|
|
|
|
return parser
|
|
|
|
|
|
def main() -> None:
|
|
parser = build_parser()
|
|
args = parser.parse_args()
|
|
|
|
dispatch = {
|
|
"check": cmd_check,
|
|
"compare": cmd_compare,
|
|
"gaps": cmd_gaps,
|
|
"update": cmd_update,
|
|
"export": cmd_export,
|
|
}
|
|
|
|
handler = dispatch.get(args.command)
|
|
if handler is None:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
handler(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|