458 lines
16 KiB
Python
458 lines
16 KiB
Python
|
|
"""Migration script: move state from .a0/ files to patterns.db.
|
|||
|
|
|
|||
|
|
Liest alle bestehenden .a0/ und .a0proj/ State-Files (in einem
|
|||
|
|
Projekt-Wurzelverzeichnis) und schreibt sie in die neue
|
|||
|
|
DB-Struktur (orch_* Tabellen in patterns.db).
|
|||
|
|
|
|||
|
|
Anschließend werden die Quelldateien nach .a0/.backup-<ts>/ verschoben
|
|||
|
|
(NICHT gelöscht), damit man die Migration verifizieren oder rückgängig
|
|||
|
|
machen kann.
|
|||
|
|
|
|||
|
|
Usage:
|
|||
|
|
python3 utils/migrate_a0_to_db.py <project_root> [--dry-run] [--skip-backup]
|
|||
|
|
|
|||
|
|
Beispiel:
|
|||
|
|
cd /pfad/zu/mein-projekt
|
|||
|
|
python3 /a0/usr/plugins/a0_software_orchestrator/utils/migrate_a0_to_db.py .
|
|||
|
|
|
|||
|
|
ODER für ein registriertes Projekt:
|
|||
|
|
python3 /a0/usr/plugins/a0_software_orchestrator/utils/migrate_a0_to_db.py crm-system
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import re
|
|||
|
|
import shutil
|
|||
|
|
import sys
|
|||
|
|
from datetime import datetime
|
|||
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|||
|
|
|
|||
|
|
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||
|
|
# Run this maintenance script from the Agent Zero framework runtime with /a0 available.
|
|||
|
|
# Example: cd /a0 && /opt/venv-a0/bin/python /a0/usr/plugins/a0_software_orchestrator/utils/migrate_a0_to_db.py <project_root>
|
|||
|
|
|
|||
|
|
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
|||
|
|
register_project, set_kv, append_worklog, set_status,
|
|||
|
|
add_todo, add_next_step, set_block_compact,
|
|||
|
|
create_snapshot, add_score, append_briefing,
|
|||
|
|
now_iso,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Helpers – Datei-Reader
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _read_json(path: str) -> Optional[dict]:
|
|||
|
|
if not os.path.exists(path):
|
|||
|
|
return None
|
|||
|
|
try:
|
|||
|
|
with open(path, "r", encoding="utf-8") as f:
|
|||
|
|
return json.load(f)
|
|||
|
|
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
|
|||
|
|
print(f" WARN: {path} not readable: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _read_text(path: str) -> Optional[str]:
|
|||
|
|
if not os.path.exists(path):
|
|||
|
|
return None
|
|||
|
|
try:
|
|||
|
|
with open(path, "r", encoding="utf-8") as f:
|
|||
|
|
return f.read()
|
|||
|
|
except (OSError, UnicodeDecodeError) as e:
|
|||
|
|
print(f" WARN: {path} not readable: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_worklog_md(text: str) -> List[Dict[str, str]]:
|
|||
|
|
"""Parst worklog.md in Einträge.
|
|||
|
|
|
|||
|
|
Format (etabliert):
|
|||
|
|
## 2026-05-10T... | phase=implementation | block=T012 | agent=foo
|
|||
|
|
- summary text
|
|||
|
|
- finding ...
|
|||
|
|
"""
|
|||
|
|
entries = []
|
|||
|
|
if not text:
|
|||
|
|
return entries
|
|||
|
|
# Sehr tolerant: pro Header-Zeile ein Eintrag
|
|||
|
|
blocks = re.split(r"\n(?=##\s)", text)
|
|||
|
|
for block in blocks:
|
|||
|
|
m = re.match(
|
|||
|
|
r"^##\s+(\S+)\s*\|\s*phase=([^|]+)\s*\|\s*block=([^|]+)\s*\|\s*agent=(\S+)",
|
|||
|
|
block.strip(),
|
|||
|
|
)
|
|||
|
|
if not m:
|
|||
|
|
# Fallback: nur Datum, Rest default
|
|||
|
|
m2 = re.match(r"^##\s+(\S+)", block.strip())
|
|||
|
|
if not m2:
|
|||
|
|
continue
|
|||
|
|
ts, phase, blk, agent = m2.group(1), None, None, None
|
|||
|
|
else:
|
|||
|
|
ts, phase, blk, agent = m.groups()
|
|||
|
|
# Rest des Blocks ist Summary (alles nach erster Zeile)
|
|||
|
|
rest = block.split("\n", 1)[1] if "\n" in block else ""
|
|||
|
|
summary = rest.strip()[:500]
|
|||
|
|
entries.append({
|
|||
|
|
"created_at": ts,
|
|||
|
|
"phase": (phase or "").strip() or None,
|
|||
|
|
"work_block": (blk or "").strip() or None,
|
|||
|
|
"agent": (agent or "").strip() or None,
|
|||
|
|
"summary": summary,
|
|||
|
|
})
|
|||
|
|
return entries
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_todo_md(text: str) -> List[Dict[str, str]]:
|
|||
|
|
"""Parst todo.md in einzelne Todos.
|
|||
|
|
|
|||
|
|
Format: '- [ ] foo' / '- [x] foo' / '- foo'
|
|||
|
|
"""
|
|||
|
|
todos = []
|
|||
|
|
if not text:
|
|||
|
|
return todos
|
|||
|
|
for line in text.splitlines():
|
|||
|
|
m = re.match(r"^\s*-\s*(?:\[(?P<done>[ xX])\])?\s*(?P<content>.+)$", line)
|
|||
|
|
if not m:
|
|||
|
|
continue
|
|||
|
|
done = m.group("done") in ("x", "X")
|
|||
|
|
todos.append({
|
|||
|
|
"content": m.group("content").strip(),
|
|||
|
|
"status": "done" if done else "open",
|
|||
|
|
})
|
|||
|
|
return todos
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_next_steps_md(text: str) -> List[str]:
|
|||
|
|
"""Parst next_steps.md in Step-Liste (alle pending)."""
|
|||
|
|
steps = []
|
|||
|
|
if not text:
|
|||
|
|
return steps
|
|||
|
|
for line in text.splitlines():
|
|||
|
|
m = re.match(r"^\s*-\s*(?:\[\s*\]\s*)?(?P<content>.+)$", line)
|
|||
|
|
if not m:
|
|||
|
|
continue
|
|||
|
|
steps.append(m.group("content").strip())
|
|||
|
|
return steps
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_known_errors_md(text: str) -> List[Dict[str, str]]:
|
|||
|
|
"""Parst known_errors.md in Errors.
|
|||
|
|
|
|||
|
|
Format:
|
|||
|
|
- [high] TypeError in user.py: ...
|
|||
|
|
"""
|
|||
|
|
errors = []
|
|||
|
|
if not text:
|
|||
|
|
return errors
|
|||
|
|
for line in text.splitlines():
|
|||
|
|
m = re.match(
|
|||
|
|
r"^\s*-\s*(?:\[(?P<sev>high|medium|low|critical|info)\])?\s*"
|
|||
|
|
r"(?P<content>.+)$",
|
|||
|
|
line, re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
if not m:
|
|||
|
|
continue
|
|||
|
|
errors.append({
|
|||
|
|
"severity": (m.group("sev") or "medium").lower(),
|
|||
|
|
"message": m.group("content").strip(),
|
|||
|
|
"status": "open",
|
|||
|
|
})
|
|||
|
|
return errors
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_briefing_md(text: str) -> Dict[str, Any]:
|
|||
|
|
"""Parst ein Briefing-MD in ein Briefing-Dict (für orch_briefings)."""
|
|||
|
|
# Best-effort: nur die Section als ganzes
|
|||
|
|
return {
|
|||
|
|
"section_md": text,
|
|||
|
|
"summary": text.split("\n")[0][:200] if text else "",
|
|||
|
|
"decisions": re.findall(r"^- (?:decision|Decision):\s*(.+)$", text, re.M),
|
|||
|
|
"artifacts": re.findall(r"^- (?:artifact|Artifact):\s*(.+)$", text, re.M),
|
|||
|
|
"recent_turns": re.findall(r"^> (?:User|Agent):\s*(.+)$", text, re.M),
|
|||
|
|
"return_condition": "user_request",
|
|||
|
|
"handover_reason": "migrated from .a0proj/handover/",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Migration
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def migrate_a0_to_db(
|
|||
|
|
project_name: str,
|
|||
|
|
project_root: str,
|
|||
|
|
dry_run: bool = False,
|
|||
|
|
skip_backup: bool = False,
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""Migriert .a0/ und .a0proj/ State-Files in die DB.
|
|||
|
|
|
|||
|
|
Returns dict mit Counts (was migriert wurde).
|
|||
|
|
"""
|
|||
|
|
a0_dir = os.path.join(project_root, ".a0")
|
|||
|
|
a0proj_dir = os.path.join(project_root, ".a0proj")
|
|||
|
|
handover_dir = os.path.join(a0proj_dir, "handover")
|
|||
|
|
|
|||
|
|
counts = {
|
|||
|
|
"worklog": 0, "todo": 0, "next_steps": 0, "errors": 0,
|
|||
|
|
"kv_keys": 0, "scorecard": 0, "briefings": 0,
|
|||
|
|
"block_compact": 0, "snapshots": 0,
|
|||
|
|
"files_moved": 0, "files_skipped": 0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if not os.path.isdir(a0_dir) and not os.path.isdir(a0proj_dir):
|
|||
|
|
print(f" No .a0/ or .a0proj/ directory found in {project_root}")
|
|||
|
|
return counts
|
|||
|
|
|
|||
|
|
print(f" Migrating project {project_name!r} from {project_root}")
|
|||
|
|
|
|||
|
|
# Projekt registrieren (oder existierendes behalten) – mit Pattern+Blacklist-Plausi.
|
|||
|
|
# Bug 1/2 Fix: explizit register_project() statt auto-registrierendem resolve_project().
|
|||
|
|
try:
|
|||
|
|
pid = register_project(project_name, git_url="")
|
|||
|
|
except ValueError as e:
|
|||
|
|
print(f"ERROR: invalid project name {project_name!r}: {e}")
|
|||
|
|
print(" Bitte --project=<gültiger Name> verwenden (Pattern: ^[a-z][a-z0-9-]{1,40}$)")
|
|||
|
|
sys.exit(1)
|
|||
|
|
print(f" Project id: {pid}")
|
|||
|
|
|
|||
|
|
# 1. KV-Dateien: project_state.json, orchestrator_mode.json, tool_capabilities.json, …
|
|||
|
|
for json_file in (
|
|||
|
|
"project_state.json",
|
|||
|
|
"orchestrator_mode.json",
|
|||
|
|
"tool_capabilities.json",
|
|||
|
|
"tool_registry.json",
|
|||
|
|
):
|
|||
|
|
path = os.path.join(a0_dir, json_file)
|
|||
|
|
data = _read_json(path)
|
|||
|
|
if not data:
|
|||
|
|
counts["files_skipped"] += 1
|
|||
|
|
continue
|
|||
|
|
key = json_file.replace(".json", "")
|
|||
|
|
if dry_run:
|
|||
|
|
print(f" [DRY] would set_kv({key!r}, …)")
|
|||
|
|
else:
|
|||
|
|
set_kv(pid, key, data)
|
|||
|
|
counts["kv_keys"] += 1
|
|||
|
|
|
|||
|
|
# 2. Markdown-Dateien
|
|||
|
|
# worklog.md
|
|||
|
|
worklog_entries = _parse_worklog_md(_read_text(os.path.join(a0_dir, "worklog.md")) or "")
|
|||
|
|
for e in worklog_entries:
|
|||
|
|
if dry_run:
|
|||
|
|
print(f" [DRY] would append_worklog({e.get('work_block')})")
|
|||
|
|
else:
|
|||
|
|
append_worklog(
|
|||
|
|
pid,
|
|||
|
|
phase=e.get("phase"),
|
|||
|
|
work_block=e.get("work_block"),
|
|||
|
|
agent=e.get("agent"),
|
|||
|
|
summary=e.get("summary"),
|
|||
|
|
)
|
|||
|
|
counts["worklog"] += 1
|
|||
|
|
|
|||
|
|
# todo.md
|
|||
|
|
todos = _parse_todo_md(_read_text(os.path.join(a0_dir, "todo.md")) or "")
|
|||
|
|
for t in todos:
|
|||
|
|
if dry_run:
|
|||
|
|
print(f" [DRY] would add_todo({t['content']!r}, status={t['status']})")
|
|||
|
|
else:
|
|||
|
|
tid = add_todo(pid, t["content"], priority=5)
|
|||
|
|
if t["status"] == "done":
|
|||
|
|
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import update_todo_status
|
|||
|
|
update_todo_status(tid, "done")
|
|||
|
|
counts["todo"] += 1
|
|||
|
|
|
|||
|
|
# current_status.md
|
|||
|
|
status_text = _read_text(os.path.join(a0_dir, "current_status.md"))
|
|||
|
|
if status_text:
|
|||
|
|
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import set_status
|
|||
|
|
if dry_run:
|
|||
|
|
print(f" [DRY] would set_status(…)")
|
|||
|
|
else:
|
|||
|
|
set_status(pid, status_text.strip()[:1000])
|
|||
|
|
|
|||
|
|
# next_steps.md
|
|||
|
|
next_steps = _parse_next_steps_md(_read_text(os.path.join(a0_dir, "next_steps.md")) or "")
|
|||
|
|
for s in next_steps:
|
|||
|
|
if dry_run:
|
|||
|
|
print(f" [DRY] would add_next_step({s!r})")
|
|||
|
|
else:
|
|||
|
|
add_next_step(pid, s)
|
|||
|
|
counts["next_steps"] += 1
|
|||
|
|
|
|||
|
|
# known_errors.md
|
|||
|
|
errors = _parse_known_errors_md(_read_text(os.path.join(a0_dir, "known_errors.md")) or "")
|
|||
|
|
for e in errors:
|
|||
|
|
if dry_run:
|
|||
|
|
print(f" [DRY] would report_error({e['message']!r}, sev={e['severity']})")
|
|||
|
|
else:
|
|||
|
|
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import report_error
|
|||
|
|
report_error(pid, e["message"], severity=e["severity"])
|
|||
|
|
counts["errors"] += 1
|
|||
|
|
|
|||
|
|
# project_scorecard.md
|
|||
|
|
scorecard = _read_text(os.path.join(a0_dir, "project_scorecard.md"))
|
|||
|
|
if scorecard:
|
|||
|
|
for line in scorecard.splitlines():
|
|||
|
|
m = re.match(r"^\|\s*(\S+)\s*\|\s*(\d+)\s*\|\s*(\d+)\s*\|", line)
|
|||
|
|
if m and m.group(1) not in ("category", "id"):
|
|||
|
|
cat, sc, mx = m.group(1), int(m.group(2)), int(m.group(3))
|
|||
|
|
if dry_run:
|
|||
|
|
print(f" [DRY] would add_score({cat!r}, {sc}/{mx})")
|
|||
|
|
else:
|
|||
|
|
add_score(pid, cat, sc, mx)
|
|||
|
|
counts["scorecard"] += 1
|
|||
|
|
|
|||
|
|
# 3. .a0proj/handover/ Briefing-Files
|
|||
|
|
if os.path.isdir(handover_dir):
|
|||
|
|
for fn in sorted(os.listdir(handover_dir)):
|
|||
|
|
if not fn.endswith(".md"):
|
|||
|
|
continue
|
|||
|
|
full = os.path.join(handover_dir, fn)
|
|||
|
|
text = _read_text(full)
|
|||
|
|
if not text:
|
|||
|
|
continue
|
|||
|
|
# specialist und topic aus Dateiname: <specialist>_<topic>.md
|
|||
|
|
base = fn[:-3]
|
|||
|
|
specialist = base.split("_")[0] if "_" in base else base
|
|||
|
|
topic = "_".join(base.split("_")[1:]) or base
|
|||
|
|
parsed = _parse_briefing_md(text)
|
|||
|
|
if dry_run:
|
|||
|
|
print(f" [DRY] would append_briefing({specialist!r}, {topic!r})")
|
|||
|
|
else:
|
|||
|
|
append_briefing(
|
|||
|
|
pid,
|
|||
|
|
specialist=specialist,
|
|||
|
|
topic=topic,
|
|||
|
|
section_md=parsed["section_md"],
|
|||
|
|
summary=parsed["summary"],
|
|||
|
|
decisions=parsed["decisions"],
|
|||
|
|
artifacts=parsed["artifacts"],
|
|||
|
|
recent_turns=parsed["recent_turns"],
|
|||
|
|
return_condition=parsed["return_condition"],
|
|||
|
|
handover_reason=parsed["handover_reason"],
|
|||
|
|
)
|
|||
|
|
counts["briefings"] += 1
|
|||
|
|
|
|||
|
|
# 4. Optional: backup + cleanup
|
|||
|
|
if not skip_backup and not dry_run:
|
|||
|
|
ts = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
|
|||
|
|
backup_root = os.path.join(project_root, ".a0", f".backup-{ts}")
|
|||
|
|
os.makedirs(backup_root, exist_ok=True)
|
|||
|
|
|
|||
|
|
if os.path.isdir(a0_dir):
|
|||
|
|
for fn in os.listdir(a0_dir):
|
|||
|
|
# Skip the backup dir we just created and any previous backups
|
|||
|
|
if fn == os.path.basename(backup_root) or fn.startswith(".backup-"):
|
|||
|
|
continue
|
|||
|
|
full = os.path.join(a0_dir, fn)
|
|||
|
|
if os.path.isfile(full):
|
|||
|
|
shutil.move(full, os.path.join(backup_root, fn))
|
|||
|
|
counts["files_moved"] += 1
|
|||
|
|
|
|||
|
|
if os.path.isdir(handover_dir):
|
|||
|
|
handover_backup = os.path.join(project_root, ".a0proj", f".backup-{ts}")
|
|||
|
|
os.makedirs(handover_backup, exist_ok=True)
|
|||
|
|
for fn in os.listdir(handover_dir):
|
|||
|
|
full = os.path.join(handover_dir, fn)
|
|||
|
|
if os.path.isfile(full):
|
|||
|
|
shutil.move(full, os.path.join(handover_backup, fn))
|
|||
|
|
counts["files_moved"] += 1
|
|||
|
|
# leere handover_dir entfernen
|
|||
|
|
try:
|
|||
|
|
os.rmdir(handover_dir)
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return counts
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# CLI
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
ap = argparse.ArgumentParser(
|
|||
|
|
description="Migrate .a0/ and .a0proj/ state files to patterns.db."
|
|||
|
|
)
|
|||
|
|
ap.add_argument(
|
|||
|
|
"project",
|
|||
|
|
help="Projektname (registriert in projects-Registry) ODER absoluter Pfad.",
|
|||
|
|
)
|
|||
|
|
ap.add_argument(
|
|||
|
|
"--project-root",
|
|||
|
|
default="",
|
|||
|
|
help="Absoluter Pfad zum Projekt-Root (nur wenn project ein Name ist).",
|
|||
|
|
)
|
|||
|
|
ap.add_argument(
|
|||
|
|
"--dry-run", action="store_true",
|
|||
|
|
help="Zeigt nur an, was migriert würde, schreibt nichts.",
|
|||
|
|
)
|
|||
|
|
ap.add_argument(
|
|||
|
|
"--skip-backup", action="store_true",
|
|||
|
|
help="Quelldateien NICHT nach .backup-<ts>/ verschieben.",
|
|||
|
|
)
|
|||
|
|
args = ap.parse_args()
|
|||
|
|
|
|||
|
|
# Bestimme project_name + project_root
|
|||
|
|
# Bug 3 Fix: stiller basename-Fallback entfernt. Wenn --project=<abs path>
|
|||
|
|
# übergeben wird, wird basename genutzt MIT sichtbarem WARN-Hinweis,
|
|||
|
|
# damit der User den Projektnamen explizit setzen kann.
|
|||
|
|
if not args.project:
|
|||
|
|
print("ERROR: --project=<name> ist Pflicht (basename-Fallback entfernt)")
|
|||
|
|
sys.exit(1)
|
|||
|
|
if os.path.isabs(args.project) and os.path.isdir(args.project):
|
|||
|
|
project_root = args.project
|
|||
|
|
project_name = os.path.basename(project_root.rstrip("/"))
|
|||
|
|
print(f"WARN: --project ist absoluter Pfad; leite project_name={project_name!r} aus basename ab.")
|
|||
|
|
print(f" Besser: explizit --project={project_name} --project-root={project_root} setzen.")
|
|||
|
|
elif args.project_root:
|
|||
|
|
project_root = args.project_root
|
|||
|
|
project_name = args.project
|
|||
|
|
else:
|
|||
|
|
project_name = args.project
|
|||
|
|
# Versuche, project_root aus projects-Tabelle zu holen
|
|||
|
|
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import _conn
|
|||
|
|
with _conn() as c:
|
|||
|
|
row = c.execute(
|
|||
|
|
"SELECT git_url FROM projects WHERE name = ?",
|
|||
|
|
(project_name,),
|
|||
|
|
).fetchone()
|
|||
|
|
if row and row["git_url"]:
|
|||
|
|
# Standard-Repo-Pfad: /a0/usr/workdir/dev-projects/<name>
|
|||
|
|
project_root = f"/a0/usr/workdir/dev-projects/{project_name}"
|
|||
|
|
if not os.path.isdir(project_root):
|
|||
|
|
print(f"ERROR: project_root not found at {project_root}")
|
|||
|
|
sys.exit(1)
|
|||
|
|
else:
|
|||
|
|
print(f"ERROR: project {project_name!r} not in DB and no --project-root given")
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
print(f"=== Migration: {project_name} ===")
|
|||
|
|
print(f" project_root: {project_root}")
|
|||
|
|
print(f" dry-run: {args.dry_run}")
|
|||
|
|
print(f" skip-backup: {args.skip_backup}")
|
|||
|
|
print()
|
|||
|
|
|
|||
|
|
counts = migrate_a0_to_db(
|
|||
|
|
project_name=project_name,
|
|||
|
|
project_root=project_root,
|
|||
|
|
dry_run=args.dry_run,
|
|||
|
|
skip_backup=args.skip_backup,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
print()
|
|||
|
|
print("=== Summary ===")
|
|||
|
|
for k, v in counts.items():
|
|||
|
|
if v:
|
|||
|
|
print(f" {k}: {v}")
|
|||
|
|
print("Done.")
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|