Files
oci-deal-accelerator/tools/kb_cli.py
root b48d155c1b CLI improvements: --json output, filters, arch-search, ecal-score, deck fixes
6 CLI improvements from MCP integration testing + deck bug fixes:

- kb_cli.py: --json on search, new arch-search and ecal-score subcommands,
  _load_yaml handles multi-document YAML (fixes health command on 32 files)
- findings_cli.py: --json on search/list/stats, filter flags on search
  (--product, --severity, --tag, --client, --status, --category)
- validate-architecture.py: --profile optional, --output defaults to stdout
- common-objections.yaml + ecal-artefacts-catalog.yaml: merge front matter
- oci_deck_gen.py: fix slide 9 cost_notes char iteration, fix slide 12
  migration field mapping (duration/milestones), dynamic phase spacing
- proposal-spec.yaml: replace real name with fictional Carlos Mendoza

All 14 skill options validated with generated deliverables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:02:40 -03:00

731 lines
27 KiB
Python

#!/usr/bin/env python3
"""
OCI Deal Accelerator — Unified KB Management CLI
Provides a single entry point for KB management tasks:
- Health dashboard
- Contribution stats and leaderboard
- Stale content reporting
- Confidence decay reporting
- Changelog management for service files
- Cross-KB search
- Domain owner listing
Usage:
python kb_cli.py health # Overall KB health dashboard
python kb_cli.py stats contributors # Contribution leaderboard
python kb_cli.py stats stale # Stale content report
python kb_cli.py stats decay # Confidence decay report
python kb_cli.py changelog <file> --name "Name" --team "Team" --change "Description"
python kb_cli.py search "vector search" # Search across ALL KB files
python kb_cli.py owners # Show domain owners
"""
import argparse
import json
import os
import re
import sys
from collections import Counter
from datetime import date, datetime
from typing import Any, Dict, List, Optional
import yaml
# =============================================================================
# Constants
# =============================================================================
PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
KB_ROOT = os.path.join(PROJECT_ROOT, "kb")
GOVERNANCE_PATH = os.path.join(PROJECT_ROOT, "config", "kb-governance.yaml")
OWNERS_PATH = os.path.join(PROJECT_ROOT, "config", "kb-owners.yaml")
TRACKER_PATH = os.path.join(PROJECT_ROOT, "kb", "field-findings", "tracker.yaml")
MATRIX_PATH = os.path.join(PROJECT_ROOT, "kb", "compatibility", "adb-feature-matrix.yaml")
ARCH_CATALOG_PATH = os.path.join(KB_ROOT, "architecture-center", "catalog.yaml")
ECAL_CATALOG_PATH = os.path.join(KB_ROOT, "patterns", "ecal-artefacts-catalog.yaml")
_RESET = "\033[0m"
_GREEN = "\033[32m"
_YELLOW = "\033[33m"
_RED = "\033[31m"
_BOLD = "\033[1m"
_CYAN = "\033[36m"
def _supports_color() -> bool:
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
def _c(text: str, color: str) -> str:
if not _supports_color():
return text
return f"{color}{text}{_RESET}"
# =============================================================================
# Loaders
# =============================================================================
def _load_yaml(path: str) -> Optional[Dict[str, Any]]:
resolved = os.path.realpath(path)
if not os.path.isfile(resolved):
return None
with open(resolved, "r", encoding="utf-8") as fh:
docs = list(yaml.safe_load_all(fh))
# Merge multi-document YAML (front matter + body) into single dict
merged: Dict[str, Any] = {}
for doc in docs:
if isinstance(doc, dict):
merged.update(doc)
return merged or None
def _parse_date(d: Any) -> Optional[date]:
if isinstance(d, date) and not isinstance(d, datetime):
return d
if isinstance(d, datetime):
return d.date()
if isinstance(d, str):
try:
return datetime.strptime(d, "%Y-%m-%d").date()
except ValueError:
pass
# Try YYYY-MM format
try:
return datetime.strptime(d + "-01", "%Y-%m-%d").date()
except ValueError:
return None
return None
def _days_ago(d: Any) -> Optional[int]:
parsed = _parse_date(d)
if parsed is None:
return None
return (date.today() - parsed).days
def _all_kb_files() -> List[str]:
"""Return all YAML files under kb/."""
files = []
for dirpath, _dirnames, filenames in os.walk(KB_ROOT):
for filename in filenames:
if filename.endswith((".yaml", ".yml")):
files.append(os.path.join(dirpath, filename))
return sorted(files)
# =============================================================================
# Health dashboard
# =============================================================================
def cmd_health(args: argparse.Namespace) -> None:
"""Overall KB health dashboard."""
governance = _load_yaml(GOVERNANCE_PATH) or {}
warning_days = governance.get("freshness", {}).get("warning_days", 180)
stale_days = governance.get("freshness", {}).get("stale_days", 365)
decay_config = governance.get("confidence_decay", {})
# File freshness
kb_files = _all_kb_files()
fresh_count = stale_count = expired_count = 0
stale_files = []
for filepath in kb_files:
data = _load_yaml(filepath)
rel = os.path.relpath(filepath, PROJECT_ROOT)
last = None
if data:
last = data.get("last_verified") or data.get("last_updated")
age = _days_ago(last) if last else None
if age is None:
mtime = os.path.getmtime(filepath)
mdate = datetime.fromtimestamp(mtime).date()
age = (date.today() - mdate).days
if age > stale_days:
expired_count += 1
stale_files.append((rel, age, "expired"))
elif age > warning_days:
stale_count += 1
stale_files.append((rel, age, "stale"))
else:
fresh_count += 1
# Findings stats
tracker = _load_yaml(TRACKER_PATH) or {}
findings = tracker.get("findings", [])
status_counts = Counter(f.get("status", "unknown") for f in findings)
# Feature matrix stats
matrix = _load_yaml(MATRIX_PATH) or {}
features = matrix.get("features", [])
cell_counts: Dict[str, int] = Counter()
for feat in features:
for deploy_versions in feat.get("matrix", {}).values():
for ver_data in deploy_versions.values():
if isinstance(ver_data, dict):
cell_counts[ver_data.get("status", "UNTESTED")] += 1
# Contributor stats (last 90 days)
contributor_counts: Dict[str, int] = Counter()
for f in findings:
contrib = f.get("contributor", {})
if isinstance(contrib, dict):
name = contrib.get("name", "Unknown")
cdate = _parse_date(contrib.get("date", f.get("date")))
if cdate and (date.today() - cdate).days <= 90:
contributor_counts[name] += 1
for c in f.get("confirmations", []):
cdate = _parse_date(c.get("date"))
if cdate and (date.today() - cdate).days <= 90:
contributor_counts[c.get("name", "Unknown")] += 1
# Owner check
owners = _load_yaml(OWNERS_PATH) or {}
unassigned = []
for domain in owners.get("domains", []):
if domain.get("owner", {}).get("name") == "TBD":
unassigned.append(domain.get("area", ""))
# Print dashboard
total_files = len(kb_files)
print(_c("KB HEALTH DASHBOARD", _BOLD))
print("=" * 50)
print()
print(f"Files: {total_files} total | {_c(str(fresh_count), _GREEN)} fresh | "
f"{_c(str(stale_count), _YELLOW)} stale | {_c(str(expired_count), _RED)} expired")
print()
print(f"Findings: {len(findings)} total | "
f"{status_counts.get('open', 0)} open | "
f"{status_counts.get('acknowledged', 0)} acknowledged | "
f"{status_counts.get('resolved', 0)} resolved | "
f"{status_counts.get('monitoring', 0)} monitoring")
print()
if cell_counts:
parts = []
for status in ["GA", "GA_CAVEAT", "LIMITED", "NOT_AVAIL", "UNTESTED"]:
if cell_counts.get(status, 0) > 0:
parts.append(f"{status}: {cell_counts[status]}")
print(f"Feature Matrix: {len(features)} features")
print(f" {' | '.join(parts)}")
print()
if contributor_counts:
print("Contributors (last 90 days):")
for name, count in contributor_counts.most_common(10):
print(f" {name:<30} {count} contributions")
print()
needs_attention = []
for filepath, age, status in stale_files:
needs_attention.append(f" {filepath} -- {status} ({age} days old)")
untested = cell_counts.get("UNTESTED", 0)
if untested > 0:
needs_attention.append(f" {untested} UNTESTED cells in feature matrix")
for area in unassigned:
needs_attention.append(f" {area} -- no owner assigned")
if needs_attention:
print("Needs Attention:")
for item in needs_attention:
print(item)
else:
print(_c("All areas healthy.", _GREEN))
# =============================================================================
# Stats commands
# =============================================================================
def cmd_stats_contributors(args: argparse.Namespace) -> None:
"""Show contribution leaderboard."""
tracker = _load_yaml(TRACKER_PATH) or {}
findings = tracker.get("findings", [])
# Count findings per contributor
finding_counts: Dict[str, Dict[str, int]] = {}
for f in findings:
contrib = f.get("contributor", {})
if isinstance(contrib, dict):
name = contrib.get("name", "Unknown")
team = contrib.get("team", "")
else:
name = str(f.get("reported_by", "Unknown"))
team = ""
key = f"{name}|{team}"
if key not in finding_counts:
finding_counts[key] = {"findings": 0, "confirmations": 0, "team": team}
finding_counts[key]["findings"] += 1
for c in f.get("confirmations", []):
cname = c.get("name", "Unknown")
cteam = c.get("team", "")
ckey = f"{cname}|{cteam}"
if ckey not in finding_counts:
finding_counts[ckey] = {"findings": 0, "confirmations": 0, "team": cteam}
finding_counts[ckey]["confirmations"] += 1
if not finding_counts:
print("No contributions found.")
return
print(f"{'Contributor':<25} {'Team':<25} {'Findings':>8} {'Confirms':>8} {'Total':>8}")
print("-" * 80)
for key, counts in sorted(finding_counts.items(), key=lambda x: -(x[1]["findings"] + x[1]["confirmations"])):
name = key.split("|")[0]
total = counts["findings"] + counts["confirmations"]
print(f"{name:<25} {counts['team']:<25} {counts['findings']:>8} {counts['confirmations']:>8} {total:>8}")
def cmd_stats_stale(args: argparse.Namespace) -> None:
"""Show stale content report."""
governance = _load_yaml(GOVERNANCE_PATH) or {}
warning_days = governance.get("freshness", {}).get("warning_days", 180)
stale_days = governance.get("freshness", {}).get("stale_days", 365)
kb_files = _all_kb_files()
stale = []
for filepath in kb_files:
data = _load_yaml(filepath)
rel = os.path.relpath(filepath, PROJECT_ROOT)
last = None
if data:
last = data.get("last_verified") or data.get("last_updated")
age = _days_ago(last) if last else None
if age is None:
mtime = os.path.getmtime(filepath)
mdate = datetime.fromtimestamp(mtime).date()
age = (date.today() - mdate).days
if age > warning_days:
status = "STALE" if age > stale_days else "WARNING"
stale.append((rel, age, status))
if not stale:
print(_c("No stale content found.", _GREEN))
return
print(f"{'File':<50} {'Age':>8} {'Status':>10}")
print("-" * 70)
for filepath, age, status in sorted(stale, key=lambda x: -x[1]):
sc = _RED if status == "STALE" else _YELLOW
print(f"{filepath:<50} {age:>5}d {_c(status, sc)}")
def cmd_stats_decay(args: argparse.Namespace) -> None:
"""Show confidence decay report."""
governance = _load_yaml(GOVERNANCE_PATH) or {}
decay_config = governance.get("confidence_decay", {})
tracker = _load_yaml(TRACKER_PATH) or {}
findings = tracker.get("findings", [])
if not findings:
print("No findings found.")
return
print(f"{'ID':<18} {'Status':<10} {'Summary':<45} {'Confidence':<12} {'Age'}")
print("-" * 100)
for f in findings:
fid = f.get("id", "???")
contributor = f.get("contributor", {})
confidence = contributor.get("confidence", "validated") if isinstance(contributor, dict) else "validated"
finding_date = contributor.get("date", f.get("date")) if isinstance(contributor, dict) else f.get("date")
age = _days_ago(finding_date)
summary = str(f.get("summary", ""))[:43]
if age is None:
status = "UNKNOWN"
else:
thresholds = decay_config.get(confidence, {"fresh": 180, "stale": 365, "expired": 730})
if age <= thresholds.get("fresh", 180):
status = "FRESH"
elif age <= thresholds.get("stale", 365):
status = "STALE"
else:
status = "EXPIRED"
color = _GREEN if status == "FRESH" else (_YELLOW if status == "STALE" else _RED)
age_str = f"{age}d" if age is not None else "?"
print(f"{fid:<18} {_c(status, color):<10} {summary:<45} {confidence:<12} {age_str}")
# =============================================================================
# Changelog command
# =============================================================================
def cmd_changelog(args: argparse.Namespace) -> None:
"""Add a changelog entry to a KB file."""
filepath = os.path.realpath(args.file)
if not os.path.isfile(filepath):
print(f"Error: File not found: {filepath}", file=sys.stderr)
sys.exit(1)
with open(filepath, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if data is None:
data = {}
if "changelog" not in data:
data["changelog"] = []
entry = {
"date": date.today().isoformat(),
"contributor": {"name": args.name, "team": args.team},
"change": args.change,
}
data["changelog"].append(entry)
with open(filepath, "w", encoding="utf-8") as fh:
yaml.dump(data, fh, default_flow_style=False, sort_keys=False, allow_unicode=True, width=120)
rel = os.path.relpath(filepath, PROJECT_ROOT)
print(f"Added changelog entry to {rel}:")
print(f" date: {entry['date']}")
print(f" contributor: {args.name} ({args.team})")
print(f" change: {args.change}")
# =============================================================================
# Search command
# =============================================================================
def cmd_search(args: argparse.Namespace) -> None:
"""Search across ALL KB files."""
query = args.query.lower()
kb_files = _all_kb_files()
results = []
for filepath in kb_files:
with open(filepath, "r", encoding="utf-8") as fh:
content = fh.read()
if query in content.lower():
rel = os.path.relpath(filepath, PROJECT_ROOT)
# Find matching lines
matches = []
for i, line in enumerate(content.splitlines(), 1):
if query in line.lower():
matches.append((i, line.strip()[:80]))
results.append((rel, matches))
if getattr(args, "json_output", False):
json_results = [
{"file": fp, "matches": [{"line": ln, "text": txt} for ln, txt in ms]}
for fp, ms in results
]
print(json.dumps(json_results, indent=2))
return
if not results:
print(f"No results for '{args.query}'.")
return
print(f"Found matches in {len(results)} file(s) for '{args.query}':\n")
for filepath, matches in results:
print(f" {_c(filepath, _CYAN)}")
for lineno, text in matches[:3]:
print(f" L{lineno}: {text}")
if len(matches) > 3:
print(f" ... and {len(matches) - 3} more match(es)")
print()
# =============================================================================
# Owners command
# =============================================================================
def cmd_owners(args: argparse.Namespace) -> None:
"""Show domain owners."""
owners = _load_yaml(OWNERS_PATH)
if not owners or "domains" not in owners:
print("No owners configuration found.")
return
print(f"{'Area':<30} {'Owner':<25} {'Team':<25} {'Review Cadence'}")
print("-" * 100)
for domain in owners["domains"]:
area = domain.get("area", "")
owner = domain.get("owner", {})
name = owner.get("name", "TBD")
team = owner.get("team", "")
cadence = domain.get("review_cadence", "")
name_display = _c(name, _RED) if name == "TBD" else name
print(f"{area:<30} {name_display:<25} {team:<25} {cadence}")
# =============================================================================
# Architecture Center search command
# =============================================================================
def cmd_arch_search(args: argparse.Namespace) -> None:
"""Search Architecture Center catalog by services and/or tags."""
data = _load_yaml(ARCH_CATALOG_PATH)
if not data or "entries" not in data:
print("Error: Architecture catalog not found or empty.", file=sys.stderr)
sys.exit(1)
results = data["entries"]
# Filter by services (OR match, case-insensitive)
if args.services:
svc_set = {s.lower() for s in args.services}
results = [
e for e in results
if svc_set & {s.lower() for s in e.get("services", [])}
]
# Filter by tags (OR match, case-insensitive)
if args.tags:
tag_set = {t.lower() for t in args.tags}
results = [
e for e in results
if tag_set & {t.lower() for t in e.get("tags", [])}
]
# Optional text search across title + summary
if args.query:
q = args.query.lower()
results = [
e for e in results
if q in e.get("title", "").lower() or q in e.get("summary", "").lower()
]
if getattr(args, "json_output", False):
print(json.dumps(results, indent=2, default=str))
return
if not results:
print("No matching architecture references found.")
return
print(f"Found {len(results)} architecture reference(s):\n")
for e in results:
svcs = ", ".join(e.get("services", []))
tags = ", ".join(e.get("tags", []))
print(f" {_c(e.get('title', ''), _CYAN)}")
print(f" Services: {svcs}")
print(f" Tags: {tags}")
print(f" URL: {e.get('url', 'N/A')}")
if e.get("terraform"):
print(f" Terraform: {e['terraform']}")
print()
# =============================================================================
# ECAL score command
# =============================================================================
def _load_ecal_artefacts() -> List[Dict[str, Any]]:
"""Load and flatten all ECAL artefacts from the catalog."""
data = _load_yaml(ECAL_CATALOG_PATH)
if not data:
return []
artefacts = []
for phase in ["define", "design", "deliver"]:
phase_data = data.get(phase, {})
if not isinstance(phase_data, dict):
continue
for step, items in phase_data.items():
if not isinstance(items, list):
continue
for item in items:
if isinstance(item, dict) and "id" in item:
item["_phase"] = phase
item["_step"] = step
artefacts.append(item)
return artefacts
def cmd_ecal_score(args: argparse.Namespace) -> None:
"""Evaluate engagement readiness against ECAL 3.1 artefacts."""
artefacts = _load_ecal_artefacts()
if not artefacts:
print("Error: Could not load ECAL artefacts catalog.", file=sys.stderr)
sys.exit(1)
summary_lower = args.summary.lower()
summary_words = set(summary_lower.split())
stopwords = {
"the", "a", "an", "and", "or", "for", "to", "in", "of", "is",
"with", "on", "at", "by", "as", "it", "be", "this", "that", "from",
}
# Filter by phase if specified
if args.phase:
artefacts = [a for a in artefacts if a.get("_phase") == args.phase]
scored = []
for art in artefacts:
name_lower = art.get("name", "").lower()
what_lower = art.get("what", "").lower()
id_lower = art.get("id", "").lower()
# Check if artefact name or ID appears in summary
name_match = name_lower in summary_lower
id_match = id_lower in summary_lower
# Keyword overlap with the "what" field
what_words = set(what_lower.split()) - stopwords
meaningful_overlap = summary_words & what_words
if name_match or id_match:
relevance = "mentioned"
elif len(meaningful_overlap) >= 3:
relevance = "likely_relevant"
elif len(meaningful_overlap) >= 1:
relevance = "possibly_relevant"
else:
relevance = "none"
scored.append({
"id": art.get("id"),
"name": art.get("name"),
"phase": art.get("_phase"),
"step": art.get("_step"),
"skill_support": art.get("skill_support"),
"customer_facing": art.get("customer_facing"),
"relevance": relevance,
})
# Compute summary stats
mentioned = [s for s in scored if s["relevance"] == "mentioned"]
likely = [s for s in scored if s["relevance"] == "likely_relevant"]
possibly = [s for s in scored if s["relevance"] == "possibly_relevant"]
not_covered = [s for s in scored if s["relevance"] == "none"]
relevant = [s for s in scored if s["relevance"] != "none"]
auto_gen = [s for s in relevant if s["skill_support"] == "auto_generate"]
assist = [s for s in relevant if s["skill_support"] == "assist"]
if getattr(args, "json_output", False):
output = {
"summary": {
"total_artefacts": len(scored),
"mentioned": len(mentioned),
"likely_relevant": len(likely),
"possibly_relevant": len(possibly),
"not_covered": len(not_covered),
"skill_auto_generate": len(auto_gen),
"skill_assist": len(assist),
},
"artefacts": scored,
}
print(json.dumps(output, indent=2, default=str))
return
# Text output
print(f"\n{_c('ECAL 3.1 READINESS ASSESSMENT', _BOLD)}")
print("=" * 50)
print(f"\nArtefacts assessed: {len(scored)}")
print(f" Mentioned in summary: {_c(str(len(mentioned)), _GREEN)}")
print(f" Likely relevant: {_c(str(len(likely)), _YELLOW)}")
print(f" Possibly relevant: {str(len(possibly))}")
print(f" Not covered: {_c(str(len(not_covered)), _RED)}")
print(f"\nSkill can auto-generate: {len(auto_gen)} relevant artefact(s)")
print(f"Skill can assist: {len(assist)} relevant artefact(s)")
if mentioned or likely:
print(f"\n{'ID':<10} {'Phase':<8} {'Name':<35} {'Support':<15} Relevance")
print("-" * 85)
for s in mentioned + likely:
rel_color = _GREEN if s["relevance"] == "mentioned" else _YELLOW
print(
f"{s['id']:<10} {s['phase']:<8} "
f"{str(s['name']):<35} {str(s['skill_support']):<15} "
f"{_c(s['relevance'], rel_color)}"
)
if not_covered:
print(f"\nNot covered ({len(not_covered)} artefact(s)) — consider adding to engagement:")
for s in not_covered[:10]:
cf = " [customer-facing]" if s.get("customer_facing") else ""
print(f" {s['id']} {s['name']}{cf}")
if len(not_covered) > 10:
print(f" ... and {len(not_covered) - 10} more (use --json for full list)")
# =============================================================================
# CLI entry point
# =============================================================================
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="kb_cli",
description="Unified KB management CLI.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# --- health ---
subparsers.add_parser("health", help="Overall KB health dashboard.")
# --- stats ---
p_stats = subparsers.add_parser("stats", help="KB statistics.")
stats_sub = p_stats.add_subparsers(dest="stats_type", required=True)
stats_sub.add_parser("contributors", help="Contribution leaderboard.")
stats_sub.add_parser("stale", help="Stale content report.")
stats_sub.add_parser("decay", help="Confidence decay report.")
# --- changelog ---
p_changelog = subparsers.add_parser("changelog", help="Add changelog entry to a KB file.")
p_changelog.add_argument("file", help="Path to the KB YAML file.")
p_changelog.add_argument("--name", required=True, help="Contributor name.")
p_changelog.add_argument("--team", required=True, help="Contributor team.")
p_changelog.add_argument("--change", required=True, help="Description of the change.")
# --- search ---
p_search = subparsers.add_parser("search", help="Search across all KB files.")
p_search.add_argument("query", help="Search term.")
p_search.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON.")
# --- owners ---
subparsers.add_parser("owners", help="Show domain owners.")
# --- arch-search ---
p_arch = subparsers.add_parser("arch-search", help="Search Architecture Center catalog.")
p_arch.add_argument("query", nargs="?", default=None, help="Optional text search term.")
p_arch.add_argument("--services", nargs="+", help="Filter by service names (e.g., adb-s exacs).")
p_arch.add_argument("--tags", nargs="+", help="Filter by tags (e.g., ha-dr multicloud).")
p_arch.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON.")
# --- ecal-score ---
p_ecal = subparsers.add_parser("ecal-score", help="ECAL engagement readiness assessment.")
p_ecal.add_argument("--summary", required=True, help="Text describing the engagement.")
p_ecal.add_argument("--phase", choices=["define", "design", "deliver"], help="Limit to a specific ECAL phase.")
p_ecal.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON.")
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
if args.command == "health":
cmd_health(args)
elif args.command == "stats":
dispatch = {
"contributors": cmd_stats_contributors,
"stale": cmd_stats_stale,
"decay": cmd_stats_decay,
}
dispatch[args.stats_type](args)
elif args.command == "changelog":
cmd_changelog(args)
elif args.command == "search":
cmd_search(args)
elif args.command == "owners":
cmd_owners(args)
elif args.command == "arch-search":
cmd_arch_search(args)
elif args.command == "ecal-score":
cmd_ecal_score(args)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()