mirror of
https://github.com/SikongJueluo/Mini-Nav.git
synced 2026-07-13 04:25:32 +08:00
Split the monolithic test_cam_basic.py into separate test suites organized by noise configuration (no_noise, write_noise, read_noise), with shared utilities extracted to tests/top/utils.py. - Remove test_cam_basic.py; add no_noise/, write_noise/, read_noise/ test suites with Makefiles that set noise parameters statically - Extract helpers (reset_dut, write_rows, query_once, collect_topk, etc.) into tests/top/utils.py - Update hw/sim/Makefile with per-config test targets and a test-top-all meta-target - Update scripts/run_cam_correctness.py to build per-directory instead of centrally, removing inline parameter overrides - Add __init__.py for result_serializer and topk_tracker module tests - Expand docstrings in test_ref_model_noise.py with architectural context and test rationale
641 lines
20 KiB
Python
641 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""CAM correctness runner — data model, scenario matrix, and command builders."""
|
|
|
|
import csv
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import date as date_type
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Annotated, Literal
|
|
|
|
import typer
|
|
|
|
# Timeout for each external command invocation (30 minutes).
|
|
DEFAULT_COMMAND_TIMEOUT_SEC = 1800
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
ScenarioKind = Literal["cocotb", "pytest"]
|
|
Status = Literal["pass", "fail"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data model
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Scenario:
|
|
"""A single correctness-check scenario."""
|
|
|
|
name: str
|
|
kind: ScenarioKind
|
|
module: str | None
|
|
testcase: str | None
|
|
params: dict[str, str]
|
|
noise_mode: str
|
|
expected: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScenarioRun:
|
|
"""Result of executing a single scenario."""
|
|
|
|
scenario: Scenario
|
|
status: Status
|
|
returncode: int
|
|
duration_sec: float
|
|
command: list[str]
|
|
log_path: Path
|
|
results_xml: str
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Default scenario matrix
|
|
# ---------------------------------------------------------------------------
|
|
|
|
default_scenarios: list[Scenario] = [
|
|
Scenario(
|
|
name="cam_top_basic",
|
|
kind="cocotb",
|
|
module="tests.top.no_noise.test_no_noise",
|
|
testcase=None,
|
|
params={},
|
|
noise_mode="none",
|
|
expected="match",
|
|
),
|
|
Scenario(
|
|
name="cam_top_write_noise",
|
|
kind="cocotb",
|
|
module="tests.top.write_noise.test_write_noise",
|
|
testcase=None,
|
|
params={},
|
|
noise_mode="write_noise",
|
|
expected="bit_flip_injected",
|
|
),
|
|
Scenario(
|
|
name="cam_top_read_noise",
|
|
kind="cocotb",
|
|
module="tests.top.read_noise.test_read_noise",
|
|
testcase=None,
|
|
params={},
|
|
noise_mode="read_noise",
|
|
expected="read_perturbed",
|
|
),
|
|
Scenario(
|
|
name="cam_core_banked",
|
|
kind="cocotb",
|
|
module="tests.test_cam_core_banked",
|
|
testcase=None,
|
|
params={},
|
|
noise_mode="none",
|
|
expected="read_write_aligned",
|
|
),
|
|
Scenario(
|
|
name="match_engine_pipeline",
|
|
kind="cocotb",
|
|
module="tests.test_match_engine_pipeline",
|
|
testcase=None,
|
|
params={},
|
|
noise_mode="none",
|
|
expected="top1_hamming_correct",
|
|
),
|
|
Scenario(
|
|
name="cam_write_noise",
|
|
kind="cocotb",
|
|
module="tests.test_cam_write_noise",
|
|
testcase=None,
|
|
params={},
|
|
noise_mode="write_noise",
|
|
expected="bit_flip_injected",
|
|
),
|
|
Scenario(
|
|
name="cam_read_noise",
|
|
kind="cocotb",
|
|
module="tests.test_cam_read_noise",
|
|
testcase=None,
|
|
params={},
|
|
noise_mode="read_noise",
|
|
expected="read_perturbed",
|
|
),
|
|
Scenario(
|
|
name="ref_model_noise",
|
|
kind="pytest",
|
|
module=None,
|
|
testcase=None,
|
|
params={},
|
|
noise_mode="none",
|
|
expected="reference_noise_model_pass",
|
|
),
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command builders
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Map scenario names to their Makefile directory under hw/sim/tests/.
|
|
# Top-level cam_top tests are now split by noise configuration.
|
|
_MAKE_DIR: dict[str, str] = {
|
|
"cam_top_basic": "top/no_noise",
|
|
"cam_top_write_noise": "top/write_noise",
|
|
"cam_top_read_noise": "top/read_noise",
|
|
"cam_core_banked": "modules/cam_core_banked",
|
|
"match_engine_pipeline": "modules/match_engine_pipeline",
|
|
"cam_write_noise": "modules/cam_write_noise",
|
|
"cam_read_noise": "modules/cam_read_noise",
|
|
}
|
|
|
|
|
|
def _make_dir_for(scenario: Scenario) -> str:
|
|
"""Return the relative Makefile directory under hw/sim/tests/ for *scenario*."""
|
|
if scenario.name in _MAKE_DIR:
|
|
return _MAKE_DIR[scenario.name]
|
|
# Fallback: guess from module path (e.g. tests.top.foo → top/foo)
|
|
parts = scenario.module.split(".") if scenario.module else []
|
|
if len(parts) >= 4 and parts[0] == "tests":
|
|
return "/".join(parts[1:-1]) # tests.top.no_noise → top/no_noise
|
|
return "."
|
|
|
|
|
|
def build_scenario_command(
|
|
scenario: Scenario,
|
|
*,
|
|
num_rows: int = 512,
|
|
hash_bits: int = 512,
|
|
lanes: int = 8,
|
|
) -> list[str]:
|
|
"""Build the shell command to execute *scenario*."""
|
|
if scenario.kind == "pytest":
|
|
return [
|
|
"uv",
|
|
"run",
|
|
"pytest",
|
|
"hw/sim/tests/model/test_ref_model_noise.py",
|
|
"-q",
|
|
]
|
|
|
|
# cocotb — run make in the specific test subdirectory
|
|
make_dir = f"hw/sim/tests/{_make_dir_for(scenario)}"
|
|
cmd = [
|
|
"make",
|
|
"-C",
|
|
make_dir,
|
|
f"NUM_ROWS={num_rows}",
|
|
f"HASH_BITS={hash_bits}",
|
|
f"LANES={lanes}",
|
|
f"COCOTB_TEST_MODULES={scenario.module}",
|
|
]
|
|
if scenario.testcase is not None:
|
|
cmd.append(f"COCOTB_TESTCASE={scenario.testcase}")
|
|
for key, value in scenario.params.items():
|
|
cmd.append(f"{key}={value}")
|
|
return cmd
|
|
|
|
|
|
def build_clean_command(scenario: Scenario) -> list[str] | None:
|
|
"""Build a clean command or None if cleanup is not needed."""
|
|
if scenario.kind == "pytest":
|
|
return None
|
|
make_dir = f"hw/sim/tests/{_make_dir_for(scenario)}"
|
|
return ["make", "-C", make_dir, "clean"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Execution helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _run_subprocess(
|
|
cmd: list[str],
|
|
*,
|
|
cwd: Path,
|
|
env: dict[str, str],
|
|
timeout: int,
|
|
) -> tuple[subprocess.CompletedProcess | None, float, str | None]:
|
|
"""Execute *cmd*, capture output, enforce *timeout*.
|
|
|
|
Returns ``(result, duration_sec, error_msg)``.
|
|
On success *result* is a ``CompletedProcess`` and *error_msg* is ``None``.
|
|
On failure (timeout / OSError / SubprocessError) *result* is ``None``
|
|
and *error_msg* describes what happened.
|
|
"""
|
|
start = time.monotonic()
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=cwd,
|
|
env=env,
|
|
text=True,
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
end = time.monotonic()
|
|
return None, round(end - start, 3), f"timed out after {timeout}s"
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
end = time.monotonic()
|
|
return None, round(end - start, 3), str(exc)
|
|
end = time.monotonic()
|
|
return result, round(end - start, 3), None
|
|
|
|
|
|
def run_one_scenario(
|
|
scenario: Scenario,
|
|
*,
|
|
num_rows: int = 512,
|
|
hash_bits: int = 512,
|
|
lanes: int = 8,
|
|
output_dir: Path,
|
|
repo_root: Path,
|
|
) -> ScenarioRun:
|
|
"""Execute a single scenario and return the result."""
|
|
logs_dir = output_dir / "logs"
|
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
log_path = logs_dir / f"{scenario.name}.log"
|
|
|
|
command = build_scenario_command(
|
|
scenario, num_rows=num_rows, hash_bits=hash_bits, lanes=lanes
|
|
)
|
|
clean_command = build_clean_command(scenario)
|
|
|
|
env = os.environ.copy()
|
|
if scenario.kind == "pytest":
|
|
hw_sim = str(repo_root / "hw" / "sim")
|
|
existing = env.get("PYTHONPATH", "")
|
|
env["PYTHONPATH"] = (
|
|
hw_sim + (os.pathsep + existing) if existing else hw_sim
|
|
)
|
|
|
|
log_chunks: list[str] = []
|
|
|
|
# Run clean step if applicable
|
|
if clean_command is not None:
|
|
log_chunks.append(f"# Command: {' '.join(clean_command)}\n")
|
|
clean_result, clean_duration, clean_err = _run_subprocess(
|
|
clean_command,
|
|
cwd=repo_root,
|
|
env=env,
|
|
timeout=DEFAULT_COMMAND_TIMEOUT_SEC,
|
|
)
|
|
if clean_result is not None:
|
|
log_chunks.append(clean_result.stdout)
|
|
log_chunks.append(clean_result.stderr)
|
|
else:
|
|
log_chunks.append(f"# ERROR: {clean_err}\n")
|
|
|
|
if clean_err is not None or (
|
|
clean_result is not None and clean_result.returncode != 0
|
|
):
|
|
returncode = clean_result.returncode if clean_result is not None else -1
|
|
log_path.write_text("".join(log_chunks))
|
|
return ScenarioRun(
|
|
scenario=scenario,
|
|
status="fail",
|
|
returncode=returncode,
|
|
duration_sec=clean_duration,
|
|
command=clean_command,
|
|
log_path=Path("logs") / f"{scenario.name}.log",
|
|
results_xml="",
|
|
)
|
|
|
|
# Run main command
|
|
log_chunks.append(f"# Command: {' '.join(command)}\n")
|
|
main_result, duration, main_err = _run_subprocess(
|
|
command,
|
|
cwd=repo_root,
|
|
env=env,
|
|
timeout=DEFAULT_COMMAND_TIMEOUT_SEC,
|
|
)
|
|
|
|
if main_result is not None:
|
|
log_chunks.append(main_result.stdout)
|
|
log_chunks.append(main_result.stderr)
|
|
else:
|
|
log_chunks.append(f"# ERROR: {main_err}\n")
|
|
|
|
if main_err is not None:
|
|
status: Status = "fail"
|
|
returncode = -1
|
|
else:
|
|
status = "pass" if main_result.returncode == 0 else "fail"
|
|
returncode = main_result.returncode
|
|
|
|
log_path.write_text("".join(log_chunks))
|
|
|
|
# Stabilise cocotb results.xml when the file was produced
|
|
if scenario.kind == "cocotb":
|
|
src_xml = repo_root / "hw" / "sim" / "results.xml"
|
|
if src_xml.exists():
|
|
dst_xml = logs_dir / f"{scenario.name}.results.xml"
|
|
shutil.copy2(str(src_xml), str(dst_xml))
|
|
results_xml = f"logs/{scenario.name}.results.xml"
|
|
else:
|
|
results_xml = ""
|
|
else:
|
|
results_xml = ""
|
|
|
|
return ScenarioRun(
|
|
scenario=scenario,
|
|
status=status,
|
|
returncode=returncode,
|
|
duration_sec=duration,
|
|
command=command,
|
|
log_path=Path("logs") / f"{scenario.name}.log",
|
|
results_xml=results_xml,
|
|
)
|
|
|
|
|
|
def result_rows(
|
|
runs: list[ScenarioRun],
|
|
*,
|
|
num_rows: int = 512,
|
|
hash_bits: int = 512,
|
|
lanes: int = 8,
|
|
) -> list[dict[str, str]]:
|
|
"""Convert runs to CSV-ready dict rows, inserting derived rows."""
|
|
rows: list[dict[str, str]] = []
|
|
for run in runs:
|
|
actual = run.scenario.expected if run.status == "pass" else "failed"
|
|
row: dict[str, str] = {
|
|
"test_name": run.scenario.name,
|
|
"status": run.status,
|
|
"num_rows": str(num_rows),
|
|
"hash_bits": str(hash_bits),
|
|
"lanes": str(lanes),
|
|
"noise_mode": run.scenario.noise_mode,
|
|
"expected": run.scenario.expected,
|
|
"actual": actual,
|
|
"log_path": str(run.log_path),
|
|
}
|
|
rows.append(row)
|
|
|
|
# Insert derived popcount_pipeline_indirect row after match_engine_pipeline
|
|
if run.scenario.name == "match_engine_pipeline":
|
|
rows.append({
|
|
"test_name": "popcount_pipeline_indirect",
|
|
"status": run.status,
|
|
"num_rows": str(num_rows),
|
|
"hash_bits": str(hash_bits),
|
|
"lanes": str(lanes),
|
|
"noise_mode": "none",
|
|
"expected": "hamming_score_correct",
|
|
"actual": "hamming_score_correct" if run.status == "pass" else "failed",
|
|
"log_path": str(run.log_path),
|
|
})
|
|
return rows
|
|
|
|
|
|
def write_results_csv(output_dir: Path, rows: list[dict[str, str]]) -> None:
|
|
"""Write results CSV with fixed header."""
|
|
fieldnames = [
|
|
"test_name",
|
|
"status",
|
|
"num_rows",
|
|
"hash_bits",
|
|
"lanes",
|
|
"noise_mode",
|
|
"expected",
|
|
"actual",
|
|
"log_path",
|
|
]
|
|
path = output_dir / "results.csv"
|
|
with open(path, "w", newline="") as f:
|
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
writer.writeheader()
|
|
writer.writerows(rows)
|
|
|
|
|
|
def write_raw_results_csv(output_dir: Path, runs: list[ScenarioRun]) -> None:
|
|
"""Write raw results CSV with fixed header."""
|
|
fieldnames = [
|
|
"scenario",
|
|
"status",
|
|
"returncode",
|
|
"duration_sec",
|
|
"command",
|
|
"log_path",
|
|
"results_xml",
|
|
]
|
|
path = output_dir / "raw_results.csv"
|
|
with open(path, "w", newline="") as f:
|
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
writer.writeheader()
|
|
for run in runs:
|
|
writer.writerow({
|
|
"scenario": run.scenario.name,
|
|
"status": run.status,
|
|
"returncode": run.returncode,
|
|
"duration_sec": run.duration_sec,
|
|
"command": " ".join(run.command),
|
|
"log_path": str(run.log_path),
|
|
"results_xml": run.results_xml,
|
|
})
|
|
|
|
|
|
def write_summary_md(
|
|
output_dir: Path,
|
|
rows: list[dict[str, str]],
|
|
*,
|
|
run_datetime: str,
|
|
num_rows: int = 512,
|
|
hash_bits: int = 512,
|
|
lanes: int = 8,
|
|
) -> None:
|
|
"""Write a Chinese summary markdown file with scenario table, failure list,
|
|
coverage notes, and a paper-friendly conclusion draft."""
|
|
total = len(rows)
|
|
failed = sum(1 for r in rows if r["status"] == "fail")
|
|
passed = total - failed
|
|
failed_rows = [r for r in rows if r["status"] == "fail"]
|
|
|
|
lines: list[str] = []
|
|
lines.append("# CAM 功能正确性验证摘要")
|
|
lines.append("")
|
|
lines.append(f"**运行日期:** {run_datetime}")
|
|
lines.append("")
|
|
lines.append("**参数:**")
|
|
lines.append(f"- NUM_ROWS={num_rows}")
|
|
lines.append(f"- HASH_BITS={hash_bits}")
|
|
lines.append(f"- LANES={lanes}")
|
|
lines.append("")
|
|
lines.append(f"**输出目录:** {output_dir}")
|
|
lines.append("")
|
|
lines.append(f"**总场景数:** {total}")
|
|
lines.append(f"**通过数:** {passed}")
|
|
lines.append(f"**失败数:** {failed}")
|
|
lines.append("")
|
|
lines.append("## 场景结果")
|
|
lines.append("")
|
|
lines.append("| 场景 | 状态 | 噪声模式 | 预期 | 实际 | 日志 |")
|
|
lines.append("|------|------|----------|------|------|------|")
|
|
for r in rows:
|
|
lines.append(
|
|
f"| {r['test_name']} | {r['status']} | {r['noise_mode']} "
|
|
f"| {r['expected']} | {r['actual']} | {r['log_path']} |"
|
|
)
|
|
lines.append("")
|
|
lines.append("## 失败场景")
|
|
lines.append("")
|
|
if failed_rows:
|
|
for r in failed_rows:
|
|
lines.append(f"- **{r['test_name']}**: 日志路径 `{r['log_path']}`")
|
|
else:
|
|
lines.append("- 无")
|
|
lines.append("")
|
|
lines.append("## 覆盖说明")
|
|
lines.append("")
|
|
lines.append(
|
|
"- **cam_top**: 通过 `cam_top_basic`、`cam_top_write_noise`、"
|
|
"`cam_top_read_noise` 三个场景覆盖顶层 CAM 模块的基本功能、"
|
|
"写入噪声注入和读取噪声注入路径"
|
|
)
|
|
lines.append(
|
|
"- **cam_core_banked**: 通过 `cam_core_banked` 场景覆盖"
|
|
"分块 CAM 核心的读写对齐验证"
|
|
)
|
|
lines.append(
|
|
"- **match_engine_pipeline**: 通过 `match_engine_pipeline`"
|
|
" 场景覆盖流水线匹配引擎的 Top-1 汉明距离计算"
|
|
)
|
|
lines.append(
|
|
"- **popcount_pipeline**: 通过 `match_engine_pipeline`"
|
|
" 的汉明匹配路径间接覆盖 popcount 流水线"
|
|
)
|
|
lines.append(
|
|
"- **cam_write_noise**: 通过 `cam_write_noise` 场景独立覆盖"
|
|
"写入噪声注入路径"
|
|
)
|
|
lines.append(
|
|
"- **cam_read_noise**: 通过 `cam_read_noise` 场景独立覆盖"
|
|
"读取噪声注入路径"
|
|
)
|
|
lines.append("")
|
|
lines.append("## 论文可引用结论草稿")
|
|
lines.append("")
|
|
lines.append(
|
|
f"本测试套件在 {run_datetime} 对 CAM 设计进行了全面的功能正确性验证。"
|
|
f"测试参数为 NUM_ROWS={num_rows}、HASH_BITS={hash_bits}、"
|
|
f"LANES={lanes}。共执行 {total} 个测试场景,通过 {passed} 个,"
|
|
f"失败 {failed} 个。详细结果见 `results.csv` 和 `raw_results.csv`。"
|
|
f"日志文件位于 `logs/` 目录下,可用于深入调试与复现。"
|
|
)
|
|
lines.append("")
|
|
|
|
path = output_dir / "summary.md"
|
|
path.write_text("\n".join(lines))
|
|
|
|
|
|
def run_all(
|
|
*,
|
|
scenarios: list[Scenario],
|
|
num_rows: int = 512,
|
|
hash_bits: int = 512,
|
|
lanes: int = 8,
|
|
output_root: str | Path = Path("outputs/cam_correctness"),
|
|
date: str | None = None,
|
|
repo_root: Path | None = None,
|
|
) -> int:
|
|
"""Run all scenarios and produce all output files.
|
|
|
|
Output is written under *output_root* / *date*.
|
|
Returns 0 if all scenario *runs* pass, otherwise 1.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = Path(__file__).resolve().parent.parent
|
|
output_root = Path(output_root)
|
|
if date is None:
|
|
date = datetime.now().strftime("%Y-%m-%d")
|
|
output_dir = output_root / date
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
runs: list[ScenarioRun] = []
|
|
for scenario in scenarios:
|
|
run = run_one_scenario(
|
|
scenario,
|
|
num_rows=num_rows,
|
|
hash_bits=hash_bits,
|
|
lanes=lanes,
|
|
output_dir=output_dir,
|
|
repo_root=repo_root,
|
|
)
|
|
runs.append(run)
|
|
|
|
rows = result_rows(
|
|
runs,
|
|
num_rows=num_rows,
|
|
hash_bits=hash_bits,
|
|
lanes=lanes,
|
|
)
|
|
|
|
write_results_csv(output_dir, rows)
|
|
write_raw_results_csv(output_dir, runs)
|
|
|
|
run_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
write_summary_md(
|
|
output_dir,
|
|
rows,
|
|
run_datetime=run_datetime,
|
|
num_rows=num_rows,
|
|
hash_bits=hash_bits,
|
|
lanes=lanes,
|
|
)
|
|
|
|
all_pass = all(run.status == "pass" for run in runs)
|
|
return 0 if all_pass else 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def script_repo_root() -> Path:
|
|
"""Return the repository root (two levels up from scripts/run_cam_correctness.py)."""
|
|
return Path(__file__).resolve().parents[1]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
app = typer.Typer(
|
|
add_completion=False,
|
|
help=(
|
|
"Run CAM Cocotb/Verilator correctness checks "
|
|
"and collect paper-ready outputs."
|
|
),
|
|
)
|
|
|
|
|
|
@app.command()
|
|
def main(
|
|
num_rows: Annotated[int, typer.Option('--num-rows', help='CAM row count passed to NUM_ROWS.', min=1)] = 512,
|
|
hash_bits: Annotated[int, typer.Option('--hash-bits', help='Hash width passed to HASH_BITS.', min=1)] = 512,
|
|
lanes: Annotated[int, typer.Option('--lanes', help='CAM lane count passed to LANES.', min=1)] = 8,
|
|
output_root: Annotated[Path, typer.Option('--output-root', help='Root directory for dated correctness outputs.')] = Path('outputs/cam_correctness'),
|
|
date: Annotated[str | None, typer.Option('--date', help='Output date directory. Defaults to today.')] = None,
|
|
) -> None:
|
|
"""Run the full CAM correctness suite."""
|
|
run_date = date or date_type.today().isoformat()
|
|
exit_code = run_all(
|
|
scenarios=default_scenarios,
|
|
num_rows=num_rows,
|
|
hash_bits=hash_bits,
|
|
lanes=lanes,
|
|
output_root=output_root,
|
|
date=run_date,
|
|
repo_root=script_repo_root(),
|
|
)
|
|
raise typer.Exit(exit_code)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app()
|