554 lines
24 KiB
Python
554 lines
24 KiB
Python
from __future__ import annotations
|
||
|
||
import argparse
|
||
import importlib.util
|
||
import json
|
||
import shutil
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from auto_test_init import clear_environment, initialize_environment
|
||
from auto_test_lib import (
|
||
load_yaml_config,
|
||
now_slug,
|
||
recreate_dir,
|
||
tail_text_file,
|
||
)
|
||
|
||
|
||
def load_domain_module(config: dict[str, Any], domain_name: str):
|
||
script_dir = Path(config["domains"]["script_dir"])
|
||
script_path = script_dir / f"{domain_name}_case.py"
|
||
module_name = f"auto_test_domain_{domain_name}"
|
||
spec = importlib.util.spec_from_file_location(module_name, script_path)
|
||
if spec is None or spec.loader is None:
|
||
raise ImportError(f"无法加载 domain 脚本: {script_path}")
|
||
module = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
def run_domain(config: dict[str, Any], domain_name: str, report_root: Path) -> dict[str, Any]:
|
||
module = load_domain_module(config, domain_name)
|
||
if not hasattr(module, "run_case"):
|
||
raise AttributeError(f"domain 脚本缺少 run_case(): {domain_name}")
|
||
domain_report = module.run_case(config, report_root)
|
||
report_path = report_root / f"{domain_name}.json"
|
||
report_path.write_text(json.dumps(domain_report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
return domain_report
|
||
|
||
|
||
def archive_run_logs(report_root: Path, init_result: dict[str, Any], domain_reports: list[dict[str, Any]]) -> None:
|
||
logs_dir = report_root / "logs"
|
||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
manifest: dict[str, Any] = {
|
||
"backend_log": None,
|
||
"pipeline_logs": [],
|
||
}
|
||
|
||
backend_log_file = init_result.get("backend_log_file")
|
||
if backend_log_file:
|
||
src = Path(backend_log_file)
|
||
if src.exists():
|
||
target = logs_dir / "backend.log"
|
||
shutil.copy2(src, target)
|
||
init_result["archived_backend_log_file"] = str(target)
|
||
manifest["backend_log"] = str(target)
|
||
|
||
for domain in domain_reports:
|
||
for round_report in domain.get("rounds", []):
|
||
latest_log_file = round_report.get("latest_log_file")
|
||
if not latest_log_file:
|
||
continue
|
||
src = Path(latest_log_file)
|
||
if not src.exists():
|
||
continue
|
||
target = logs_dir / f"{domain['domain']}__{round_report['name']}.log"
|
||
shutil.copy2(src, target)
|
||
round_report["archived_log_file"] = str(target)
|
||
manifest["pipeline_logs"].append(
|
||
{
|
||
"domain": domain["domain"],
|
||
"round": round_report["name"],
|
||
"source": str(src),
|
||
"archived": str(target),
|
||
}
|
||
)
|
||
|
||
(logs_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
|
||
def build_test_summary(domain_reports: list[dict[str, Any]]) -> dict[str, Any]:
|
||
cases: list[dict[str, Any]] = []
|
||
passed_domains = 0
|
||
failed_domains = 0
|
||
|
||
for domain in domain_reports:
|
||
domain_passed = bool(domain.get("passed"))
|
||
if domain_passed:
|
||
passed_domains += 1
|
||
else:
|
||
failed_domains += 1
|
||
|
||
for round_report in domain.get("rounds", []):
|
||
cases.append(
|
||
{
|
||
"nodeid": f"{domain['domain']}::{round_report['name']}",
|
||
"domain": domain["domain"],
|
||
"case": round_report["name"],
|
||
"purpose": round_report.get("purpose", ""),
|
||
"passed": bool(round_report.get("passed")),
|
||
"result": "PASSED" if round_report.get("passed") else "FAILED",
|
||
"returncode": round_report.get("returncode"),
|
||
"issue_count": len(round_report.get("issues", [])),
|
||
"issues": round_report.get("issues", []),
|
||
}
|
||
)
|
||
|
||
passed_cases = sum(1 for item in cases if item["passed"])
|
||
failed_cases = len(cases) - passed_cases
|
||
|
||
return {
|
||
"total_domains": len(domain_reports),
|
||
"passed_domains": passed_domains,
|
||
"failed_domains": failed_domains,
|
||
"total_cases": len(cases),
|
||
"passed_cases": passed_cases,
|
||
"failed_cases": failed_cases,
|
||
"cases": cases,
|
||
}
|
||
|
||
|
||
def build_markdown_report(run_id: str, init_result: dict[str, Any], domain_reports: list[dict[str, Any]]) -> str:
|
||
summary = build_test_summary(domain_reports)
|
||
if init_result.get("skipped"):
|
||
init_lines = ["- 本次跳过初始化,沿用现有测试环境"]
|
||
else:
|
||
init_lines = [
|
||
f"- 后端进程 PID: {init_result.get('backend_pid')}",
|
||
f"- 后端日志: {init_result.get('backend_log_file')}",
|
||
f"- 状态库: {init_result.get('persistence_db')}",
|
||
f"- 临时数据目录: {init_result.get('temp_data_dir')}",
|
||
]
|
||
lines = [
|
||
f"# 自动测试报告 {run_id}",
|
||
"",
|
||
"## 初始化结果",
|
||
"",
|
||
*init_lines,
|
||
"",
|
||
]
|
||
if domain_reports:
|
||
lines.extend([
|
||
"## Pytest 风格汇总",
|
||
"",
|
||
f"collected {summary['total_cases']} cases across {summary['total_domains']} domains",
|
||
"",
|
||
])
|
||
for case in summary["cases"]:
|
||
lines.append(f"- {case['nodeid']} {case['result']}")
|
||
lines.extend([
|
||
"",
|
||
f"== {summary['passed_cases']} passed, {summary['failed_cases']} failed, {summary['passed_domains']} passed domains, {summary['failed_domains']} failed domains ==",
|
||
"",
|
||
])
|
||
lines.extend([
|
||
"## Domain 汇总",
|
||
"",
|
||
"| Domain | 说明 | 结果 | 轮次数 | 问题数 |",
|
||
"| --- | --- | --- | --- | --- |",
|
||
])
|
||
for domain in domain_reports:
|
||
lines.append(
|
||
f"| {domain['domain']} | {domain.get('description', '')} | {'通过' if domain.get('passed') else '失败'} | {len(domain.get('rounds', []))} | {len(domain.get('issues', []))} |"
|
||
)
|
||
lines.append("")
|
||
for domain in domain_reports:
|
||
lines.append(f"## Domain: {domain['domain']}")
|
||
if domain.get("description"):
|
||
lines.append("")
|
||
lines.append(domain["description"])
|
||
lines.append("")
|
||
lines.append(f"- 结果: {'通过' if domain.get('passed') else '失败'}")
|
||
if domain.get("issues"):
|
||
lines.append("- 问题:")
|
||
for issue in domain["issues"]:
|
||
lines.append(f" - {issue}")
|
||
lines.append("")
|
||
for round_report in domain.get("rounds", []):
|
||
lines.extend([
|
||
f"### {round_report['name']}",
|
||
f"- 目的: {round_report['purpose']}",
|
||
f"- 返回码: {round_report['returncode']}",
|
||
f"- 结果: {'通过' if round_report.get('passed') else '失败'}",
|
||
f"- 数据目录: {round_report['dataset_dir']}",
|
||
f"- 最新日志: {round_report['latest_log_file']}",
|
||
])
|
||
coverage = round_report.get("coverage") or {}
|
||
if coverage:
|
||
if coverage.get("keys"):
|
||
lines.append(f"- 覆盖 key: {', '.join(coverage['keys'])}")
|
||
if coverage.get("api"):
|
||
lines.append(f"- 覆盖接口: {', '.join(coverage['api'])}")
|
||
if coverage.get("notes"):
|
||
lines.append(f"- 覆盖说明: {coverage['notes']}")
|
||
if round_report.get("archived_log_file"):
|
||
lines.append(f"- 归档日志: {round_report['archived_log_file']}")
|
||
if round_report.get("issues"):
|
||
lines.append("- 本轮问题:")
|
||
for issue in round_report["issues"]:
|
||
lines.append(f" - {issue}")
|
||
dataset_diffs = round_report.get("dataset_diffs") or {}
|
||
if dataset_diffs:
|
||
lines.append("- 本轮数据变更:")
|
||
for node_type, record_map in dataset_diffs.items():
|
||
for record_id, fields in record_map.items():
|
||
lines.append(f" - {node_type}.{record_id}: {', '.join(fields)}")
|
||
bindings = round_report.get("required_bindings", {})
|
||
if bindings:
|
||
lines.append("- 绑定检查:")
|
||
for node_type, items in bindings.items():
|
||
for item in items:
|
||
lines.append(
|
||
f" - {node_type} {item['local_data_id']}: bound={item['bound']} local_node_id={item['local_node_id']} remote_data_id={item['remote_data_id']}"
|
||
)
|
||
verified_bound_fields = round_report.get("verified_bound_fields") or []
|
||
if verified_bound_fields:
|
||
lines.append("- 字段校验:")
|
||
for item in verified_bound_fields:
|
||
status = "matched" if item.get("matched") else "mismatched"
|
||
lines.append(
|
||
f" - {item['collection_id']}.{item['node_type']} local={item['local_data_id']} target={item['target_data_id']} status={status}"
|
||
)
|
||
for field_path, actual_value in (item.get("actual_fields") or {}).items():
|
||
expected_value = (item.get("expected_fields") or {}).get(field_path)
|
||
lines.append(
|
||
f" - {field_path}: actual={actual_value!r} expected={expected_value!r}"
|
||
)
|
||
problem_nodes = round_report.get("problem_nodes", [])
|
||
if problem_nodes:
|
||
lines.append("- 问题节点:")
|
||
for item in problem_nodes:
|
||
lines.append(
|
||
f" - {item['collection_id']}.{item['node_type']} data_id={item.get('data_id')} action={item.get('action')} status={item.get('status')} binding_status={item.get('binding_status')} error={item.get('error') or '<empty>'}"
|
||
)
|
||
lines.append("")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def sanitize_config_for_record(config: dict[str, Any]) -> dict[str, Any]:
|
||
payload = json.loads(json.dumps(config, ensure_ascii=False))
|
||
remote = payload.get("remote_datasource", {})
|
||
if remote.get("api_secret"):
|
||
remote["api_secret"] = "***"
|
||
backend = payload.get("backend", {})
|
||
backend_env = backend.get("env", {})
|
||
for key in ["COUCHDB_PASSWORD"]:
|
||
if backend_env.get(key):
|
||
backend_env[key] = "***"
|
||
couchdb = payload.get("couchdb", {})
|
||
if couchdb.get("password"):
|
||
couchdb["password"] = "***"
|
||
return payload
|
||
|
||
|
||
def collect_failed_node_details(domain_reports: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
failed_nodes: list[dict[str, Any]] = []
|
||
for domain in domain_reports:
|
||
for round_report in domain.get("rounds", []):
|
||
for item in round_report.get("problem_nodes", []):
|
||
failed_nodes.append(
|
||
{
|
||
"domain": domain["domain"],
|
||
"round": round_report["name"],
|
||
"node_type": item.get("node_type"),
|
||
"collection_id": item.get("collection_id"),
|
||
"local_data_id": item.get("data_id"),
|
||
"local_node_id": item.get("node_id"),
|
||
"remote_node_id": None,
|
||
"remote_data_id": item.get("bind_data_id"),
|
||
"issues": round_report.get("issues", []),
|
||
"node_status": round_report.get("persistence_summary", {}).get("node_status", []),
|
||
"status": item.get("status"),
|
||
"binding_status": item.get("binding_status"),
|
||
"error": item.get("error"),
|
||
"sync_log_tail": item.get("sync_log_tail", ""),
|
||
}
|
||
)
|
||
for node_type, items in round_report.get("required_bindings", {}).items():
|
||
for item in items:
|
||
if item.get("bound"):
|
||
continue
|
||
failed_nodes.append(
|
||
{
|
||
"domain": domain["domain"],
|
||
"round": round_report["name"],
|
||
"node_type": node_type,
|
||
"local_data_id": item.get("local_data_id"),
|
||
"local_node_id": item.get("local_node_id"),
|
||
"remote_node_id": item.get("remote_node_id"),
|
||
"remote_data_id": item.get("remote_data_id"),
|
||
"issues": round_report.get("issues", []),
|
||
"node_status": round_report.get("persistence_summary", {}).get("node_status", []),
|
||
"status": None,
|
||
"binding_status": None,
|
||
"error": None,
|
||
"sync_log_tail": "",
|
||
}
|
||
)
|
||
return failed_nodes
|
||
|
||
|
||
def build_detailed_record(
|
||
run_id: str,
|
||
config: dict[str, Any],
|
||
init_result: dict[str, Any],
|
||
domain_reports: list[dict[str, Any]],
|
||
) -> dict[str, Any]:
|
||
backend_log_tail = ""
|
||
if init_result.get("backend_log_file"):
|
||
backend_log_tail = tail_text_file(init_result["backend_log_file"], 8000)
|
||
|
||
rounds: list[dict[str, Any]] = []
|
||
for domain in domain_reports:
|
||
for round_report in domain.get("rounds", []):
|
||
rounds.append(
|
||
{
|
||
"domain": domain["domain"],
|
||
"round": round_report["name"],
|
||
"purpose": round_report.get("purpose"),
|
||
"passed": round_report.get("passed"),
|
||
"returncode": round_report.get("returncode"),
|
||
"dataset_dir": round_report.get("dataset_dir"),
|
||
"latest_log_file": round_report.get("latest_log_file"),
|
||
"archived_log_file": round_report.get("archived_log_file"),
|
||
"issues": round_report.get("issues", []),
|
||
"dataset_diff": round_report.get("dataset_diff", {}),
|
||
"dataset_diffs": round_report.get("dataset_diffs", {}),
|
||
"required_bindings": round_report.get("required_bindings", {}),
|
||
"verified_bound_fields": round_report.get("verified_bound_fields", []),
|
||
"problem_nodes": round_report.get("problem_nodes", []),
|
||
"persistence_summary": round_report.get("persistence_summary", {}),
|
||
"stdout_tail": round_report.get("stdout_tail", ""),
|
||
"stderr_tail": round_report.get("stderr_tail", ""),
|
||
"pipeline_log_tail": tail_text_file(round_report.get("latest_log_file") or "", 8000),
|
||
"coverage": round_report.get("coverage", {}),
|
||
}
|
||
)
|
||
|
||
return {
|
||
"run_id": run_id,
|
||
"config_snapshot": sanitize_config_for_record(config),
|
||
"init": init_result,
|
||
"backend_log_tail": backend_log_tail,
|
||
"failed_nodes": collect_failed_node_details(domain_reports),
|
||
"domains": domain_reports,
|
||
"round_details": rounds,
|
||
}
|
||
|
||
|
||
def build_detailed_markdown(record: dict[str, Any]) -> str:
|
||
init_result = record.get("init", {})
|
||
lines = [
|
||
f"# 自动测试详细记录 {record['run_id']}",
|
||
"",
|
||
"## 环境初始化",
|
||
"",
|
||
]
|
||
for key in [
|
||
"backend_root_dir",
|
||
"backend_ready_url",
|
||
"backend_log_file",
|
||
"archived_backend_log_file",
|
||
"persistence_db",
|
||
"temp_data_dir",
|
||
"pipeline_log_dir",
|
||
"backend_pid",
|
||
]:
|
||
if init_result.get(key) is not None:
|
||
lines.append(f"- {key}: {init_result.get(key)}")
|
||
if init_result.get("backend_start_command"):
|
||
lines.append(f"- backend_start_command: {' '.join(init_result['backend_start_command'])}")
|
||
if init_result.get("backend_restore_command"):
|
||
lines.append(f"- backend_restore_command: {' '.join(init_result['backend_restore_command'])}")
|
||
if init_result.get("backend_env"):
|
||
lines.append("- backend_env:")
|
||
for key, value in init_result["backend_env"].items():
|
||
lines.append(f" - {key}: {value}")
|
||
if init_result.get("restore_stdout_tail"):
|
||
lines.extend(["", "### restore stdout tail", "", "```text", init_result["restore_stdout_tail"], "```"])
|
||
if init_result.get("restore_stderr_tail"):
|
||
lines.extend(["", "### restore stderr tail", "", "```text", init_result["restore_stderr_tail"], "```"])
|
||
|
||
failed_nodes = record.get("failed_nodes", [])
|
||
lines.extend(["", "## 失败节点", ""])
|
||
if failed_nodes:
|
||
lines.extend([
|
||
"| Domain | Round | Node Type | local_data_id | local_node_id | 问题 |",
|
||
"| --- | --- | --- | --- | --- | --- |",
|
||
])
|
||
for item in failed_nodes:
|
||
issue_text = ";".join(item.get("issues", []))
|
||
lines.append(
|
||
f"| {item['domain']} | {item['round']} | {item.get('collection_id', '')}.{item['node_type']} | {item['local_data_id']} | {item['local_node_id']} | {issue_text or item.get('error', '')} |"
|
||
)
|
||
lines.append("")
|
||
else:
|
||
lines.append("- 无失败节点")
|
||
|
||
lines.extend(["", "## Backend 日志尾部", "", "```text", record.get("backend_log_tail", ""), "```", ""])
|
||
|
||
for round_detail in record.get("round_details", []):
|
||
lines.extend([
|
||
f"## {round_detail['domain']} / {round_detail['round']}",
|
||
"",
|
||
f"- purpose: {round_detail.get('purpose')}",
|
||
f"- passed: {round_detail.get('passed')}",
|
||
f"- returncode: {round_detail.get('returncode')}",
|
||
f"- dataset_dir: {round_detail.get('dataset_dir')}",
|
||
f"- latest_log_file: {round_detail.get('latest_log_file')}",
|
||
])
|
||
coverage = round_detail.get("coverage") or {}
|
||
if coverage.get("keys"):
|
||
lines.append(f"- coverage.keys: {', '.join(coverage['keys'])}")
|
||
if coverage.get("api"):
|
||
lines.append(f"- coverage.api: {', '.join(coverage['api'])}")
|
||
if coverage.get("notes"):
|
||
lines.append(f"- coverage.notes: {coverage['notes']}")
|
||
if round_detail.get("archived_log_file"):
|
||
lines.append(f"- archived_log_file: {round_detail.get('archived_log_file')}")
|
||
if round_detail.get("issues"):
|
||
lines.append("- issues:")
|
||
for issue in round_detail["issues"]:
|
||
lines.append(f" - {issue}")
|
||
if round_detail.get("dataset_diffs"):
|
||
lines.append("- dataset_diffs:")
|
||
for node_type, record_map in round_detail["dataset_diffs"].items():
|
||
for record_id, fields in record_map.items():
|
||
lines.append(f" - {node_type}.{record_id}: {', '.join(fields)}")
|
||
if round_detail.get("verified_bound_fields"):
|
||
lines.append("- verified_bound_fields:")
|
||
for item in round_detail["verified_bound_fields"]:
|
||
lines.append(
|
||
f" - {item.get('collection_id')}.{item.get('node_type')} local={item.get('local_data_id')} target={item.get('target_data_id')} matched={item.get('matched')}"
|
||
)
|
||
for field_path, actual_value in (item.get("actual_fields") or {}).items():
|
||
expected_value = (item.get("expected_fields") or {}).get(field_path)
|
||
lines.append(f" - {field_path}: actual={actual_value!r} expected={expected_value!r}")
|
||
if round_detail.get("problem_nodes"):
|
||
lines.append("- problem_nodes:")
|
||
for item in round_detail["problem_nodes"]:
|
||
lines.append(
|
||
f" - {item.get('collection_id')}.{item.get('node_type')} data_id={item.get('data_id')} action={item.get('action')} status={item.get('status')} binding_status={item.get('binding_status')} error={item.get('error') or '<empty>'}"
|
||
)
|
||
if item.get("sync_log_tail"):
|
||
lines.append(" sync_log_tail:")
|
||
for log_line in str(item["sync_log_tail"]).splitlines():
|
||
lines.append(f" {log_line}")
|
||
if round_detail.get("persistence_summary", {}).get("node_status"):
|
||
lines.append("- node_status:")
|
||
for item in round_detail["persistence_summary"]["node_status"]:
|
||
lines.append(
|
||
f" - collection={item.get('collection_id')} action={item.get('action')} status={item.get('status')} binding_status={item.get('binding_status')} count={item.get('count')}"
|
||
)
|
||
lines.extend([
|
||
"",
|
||
"### pipeline log tail",
|
||
"",
|
||
"```text",
|
||
round_detail.get("pipeline_log_tail", ""),
|
||
"```",
|
||
"",
|
||
"### stdout tail",
|
||
"",
|
||
"```text",
|
||
round_detail.get("stdout_tail", ""),
|
||
"```",
|
||
"",
|
||
"### stderr tail",
|
||
"",
|
||
"```text",
|
||
round_detail.get("stderr_tail", ""),
|
||
"```",
|
||
"",
|
||
])
|
||
return "\n".join(lines)
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="运行脚本式自动测试")
|
||
parser.add_argument(
|
||
"--config",
|
||
default="tests/fixtures/auto_sync/auto_test_config.yaml",
|
||
help="自动测试配置文件路径",
|
||
)
|
||
parser.add_argument(
|
||
"--domain",
|
||
action="append",
|
||
default=None,
|
||
help="只跑指定 domain,可重复传入",
|
||
)
|
||
parser.add_argument(
|
||
"--skip-init",
|
||
action="store_true",
|
||
help="跳过初始化阶段",
|
||
)
|
||
parser.add_argument(
|
||
"--clear-only",
|
||
action="store_true",
|
||
help="只清空环境,不执行 domain 用例",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
config = load_yaml_config(args.config)
|
||
report_root = Path(config["paths"]["report_dir"]) / now_slug()
|
||
recreate_dir(report_root)
|
||
|
||
init_result = {"skipped": True}
|
||
if args.clear_only:
|
||
init_result = clear_environment(args.config)
|
||
archive_run_logs(report_root, init_result, [])
|
||
(report_root / "report.md").write_text(build_markdown_report(report_root.name, init_result, []), encoding="utf-8")
|
||
(report_root / "summary.json").write_text(
|
||
json.dumps({"init": init_result, "summary": build_test_summary([]), "domains": []}, ensure_ascii=False, indent=2),
|
||
encoding="utf-8",
|
||
)
|
||
detail_record = build_detailed_record(report_root.name, config, init_result, [])
|
||
(report_root / "detail.json").write_text(json.dumps(detail_record, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
(report_root / "detail.md").write_text(build_detailed_markdown(detail_record), encoding="utf-8")
|
||
print(f"报告已生成: {report_root / 'report.md'}")
|
||
return 0
|
||
if not args.skip_init:
|
||
init_result = initialize_environment(args.config)
|
||
|
||
selected_domains = args.domain or config["domains"].get("enabled", [])
|
||
domain_reports = []
|
||
for domain_name in selected_domains:
|
||
domain_reports.append(run_domain(config, domain_name, report_root))
|
||
|
||
overall_passed = all(item.get("passed", False) for item in domain_reports)
|
||
archive_run_logs(report_root, init_result, domain_reports)
|
||
|
||
markdown = build_markdown_report(report_root.name, init_result, domain_reports)
|
||
(report_root / "report.md").write_text(markdown, encoding="utf-8")
|
||
summary = build_test_summary(domain_reports)
|
||
(report_root / "summary.json").write_text(
|
||
json.dumps(
|
||
{"init": init_result, "summary": summary, "domains": domain_reports, "passed": overall_passed},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
detail_record = build_detailed_record(report_root.name, config, init_result, domain_reports)
|
||
(report_root / "detail.json").write_text(json.dumps(detail_record, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
(report_root / "detail.md").write_text(build_detailed_markdown(detail_record), encoding="utf-8")
|
||
|
||
print(f"报告已生成: {report_root / 'report.md'}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|