Auto-generated API doc: doxygen → custom Python emitter → doc/api/.
`cmake --build build --target doc` runs Doxygen to produce XML, then `doc/gen_api_md.py` (~330 lines, stdlib-only) emits a Markdown tree under `doc/api/` that gitea renders directly in its file browser. - 24 class/struct pages + 51 source-file pages + indices, with source links of the form `../../../../src/...#L42` that gitea turns into clickable line-anchored links. - Doxyfile.in templated by CMake (XML-only output to build/doc/xml/). - Pure Python emitter, zero external deps — no doxybook2 (not packaged on Arch) and no moxygen (avoids Node). - Target gracefully disabled if Doxygen or Python 3 is missing at configure time; regular build target unaffected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
533
doc/gen_api_md.py
Normal file
533
doc/gen_api_md.py
Normal file
@@ -0,0 +1,533 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Convert the Doxygen XML output to a Markdown tree readable directly by
|
||||
gitea's web interface.
|
||||
|
||||
Usage:
|
||||
python3 doc/gen_api_md.py <xml_dir> <out_dir>
|
||||
|
||||
Layout produced (rooted at <out_dir>):
|
||||
|
||||
index.md — landing page (classes + files TOC)
|
||||
classes/
|
||||
index.md — table of classes
|
||||
<Name>.md — one per class/struct (members, briefs, source links)
|
||||
files/
|
||||
index.md — table of source files
|
||||
<name>.md — one per file (compounds defined + free funcs)
|
||||
|
||||
Source links use relative paths so gitea turns them into clickable links
|
||||
straight to the right line of the source file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Inline rendering helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
CLASS_REFID_RE = re.compile(r"^(class|struct)([A-Za-z_].*)$")
|
||||
|
||||
|
||||
def ref_to_md(refid: str, label: str) -> str:
|
||||
"""Turn a Doxygen refid into a Markdown link target if it points to a
|
||||
known class/struct, otherwise just return the label.
|
||||
|
||||
Links are emitted relative to the `doc/api/` root (so they resolve
|
||||
identically from `classes/` and `files/` pages thanks to the
|
||||
intervening `..`)."""
|
||||
if not refid:
|
||||
return label
|
||||
m = CLASS_REFID_RE.match(refid)
|
||||
if m and "_1" not in refid:
|
||||
return f"[{label}](../classes/{m.group(2)}.md)"
|
||||
return f"`{label}`" if label else refid
|
||||
|
||||
|
||||
def plain_text(elem: ET.Element | None) -> str:
|
||||
"""Render an element as plain text — no Markdown, no links. Used inside
|
||||
code spans where Markdown syntax wouldn't be rendered anyway."""
|
||||
if elem is None:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
if elem.text:
|
||||
parts.append(elem.text)
|
||||
for c in elem:
|
||||
parts.append(plain_text(c))
|
||||
if c.tail:
|
||||
parts.append(c.tail)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def inline_md(elem: ET.Element | None) -> str:
|
||||
"""Render a Doxygen inline-text element as Markdown."""
|
||||
if elem is None:
|
||||
return ""
|
||||
out: list[str] = []
|
||||
if elem.text:
|
||||
out.append(elem.text)
|
||||
for c in elem:
|
||||
tag = c.tag
|
||||
if tag == "ref":
|
||||
out.append(ref_to_md(c.attrib.get("refid", ""), (c.text or "").strip()))
|
||||
elif tag == "computeroutput":
|
||||
out.append("`" + inline_md(c).strip() + "`")
|
||||
elif tag == "emphasis":
|
||||
out.append("*" + inline_md(c) + "*")
|
||||
elif tag == "bold":
|
||||
out.append("**" + inline_md(c) + "**")
|
||||
elif tag == "linebreak":
|
||||
out.append(" \n")
|
||||
elif tag == "ulink":
|
||||
href = c.attrib.get("url", "")
|
||||
out.append(f"[{(c.text or href).strip()}]({href})")
|
||||
elif tag == "verbatim":
|
||||
out.append("\n\n```\n" + (c.text or "") + "\n```\n\n")
|
||||
else:
|
||||
# Unknown / generic container: recurse, preserves nested text.
|
||||
out.append(inline_md(c))
|
||||
if c.tail:
|
||||
out.append(c.tail)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def render_para(para: ET.Element) -> tuple[str, list[tuple[str, str]],
|
||||
list[str], list[tuple[str, str]]]:
|
||||
"""Render one <para> element. Returns (prose, params, returns, throws).
|
||||
|
||||
Parameter / return / throws annotations from the para are pulled out
|
||||
so the caller can lay them out separately.
|
||||
"""
|
||||
params: list[tuple[str, str]] = []
|
||||
returns: list[str] = []
|
||||
throws: list[tuple[str, str]] = []
|
||||
|
||||
out: list[str] = []
|
||||
if para.text:
|
||||
out.append(para.text)
|
||||
for c in para:
|
||||
tag = c.tag
|
||||
if tag == "parameterlist":
|
||||
kind = c.attrib.get("kind", "")
|
||||
for pi in c.findall("parameteritem"):
|
||||
name_el = pi.find("parameternamelist/parametername")
|
||||
name = (name_el.text or "").strip() if name_el is not None else ""
|
||||
desc = inline_md(pi.find("parameterdescription")).strip()
|
||||
if kind == "param":
|
||||
params.append((name, desc))
|
||||
elif kind == "exception":
|
||||
throws.append((name, desc))
|
||||
elif tag == "simplesect":
|
||||
kind = c.attrib.get("kind", "")
|
||||
if kind == "return":
|
||||
returns.append(inline_md(c).strip())
|
||||
elif kind in ("note", "warning"):
|
||||
out.append(f"\n\n> **{kind.capitalize()}:** {inline_md(c).strip()}\n\n")
|
||||
elif tag == "programlisting":
|
||||
out.append("\n\n```cpp\n")
|
||||
for codeline in c.findall("codeline"):
|
||||
out.append("".join(codeline.itertext()) + "\n")
|
||||
out.append("```\n\n")
|
||||
elif tag == "itemizedlist":
|
||||
out.append("\n\n")
|
||||
for li in c.findall("listitem"):
|
||||
out.append("- " + "\n ".join(
|
||||
render_para(p)[0].strip() for p in li.findall("para")
|
||||
) + "\n")
|
||||
out.append("\n")
|
||||
elif tag in ("ref", "computeroutput", "emphasis", "bold",
|
||||
"linebreak", "ulink", "verbatim"):
|
||||
# Reuse inline_md for one-element rendering.
|
||||
wrap = ET.Element("_w")
|
||||
wrap.append(c)
|
||||
out.append(inline_md(wrap).rstrip())
|
||||
else:
|
||||
out.append(inline_md(c))
|
||||
if c.tail:
|
||||
out.append(c.tail)
|
||||
|
||||
return "".join(out).strip(), params, returns, throws
|
||||
|
||||
|
||||
def render_description(elem: ET.Element | None) -> tuple[str, list[tuple[str, str]],
|
||||
list[str], list[tuple[str, str]]]:
|
||||
"""Render <briefdescription> or <detaileddescription> as Markdown +
|
||||
extract @param / @return / @throws lists."""
|
||||
if elem is None:
|
||||
return "", [], [], []
|
||||
paras: list[str] = []
|
||||
params: list[tuple[str, str]] = []
|
||||
returns: list[str] = []
|
||||
throws: list[tuple[str, str]] = []
|
||||
for p in elem.findall("para"):
|
||||
prose, ps, rs, ts = render_para(p)
|
||||
if prose:
|
||||
paras.append(prose)
|
||||
params.extend(ps)
|
||||
returns.extend(rs)
|
||||
throws.extend(ts)
|
||||
return "\n\n".join(paras), params, returns, throws
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Source link helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def src_link(loc: ET.Element | None, depth_to_root: int = 2) -> str:
|
||||
"""Build a relative Markdown link to the source location.
|
||||
|
||||
depth_to_root = number of `..` segments to escape from the .md page
|
||||
back to the repo root. Class/file pages live at `doc/api/<sub>/<x>.md`,
|
||||
so depth 2 (to `doc/api/`) + an extra 2 to get back to the repo root.
|
||||
"""
|
||||
if loc is None:
|
||||
return ""
|
||||
file_ = loc.attrib.get("file", "")
|
||||
line = loc.attrib.get("line", "")
|
||||
if not file_:
|
||||
return ""
|
||||
prefix = "../" * (depth_to_root + 2) # ../../../../ from doc/api/<sub>/<x>.md
|
||||
target = f"{prefix}{file_}"
|
||||
if line:
|
||||
target += f"#L{line}"
|
||||
label = Path(file_).name + (f":{line}" if line else "")
|
||||
return f"[{label}]({target})"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Compound rendering
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def member_signature(m: ET.Element) -> str:
|
||||
"""Reconstruct a member's C++ declaration as it should appear in the doc.
|
||||
|
||||
Uses plain text for the type (no Markdown links) since the signature
|
||||
sits inside a code span where Markdown isn't rendered anyway. Whitespace
|
||||
is normalised so that `Signals *` and `Signals*` look the same.
|
||||
"""
|
||||
kind = m.attrib.get("kind", "")
|
||||
type_ = plain_text(m.find("type")).strip()
|
||||
type_ = re.sub(r"\s+", " ", type_)
|
||||
name = (m.findtext("name") or "").strip()
|
||||
if kind == "function":
|
||||
argstr = (m.findtext("argsstring") or "").strip()
|
||||
return f"{type_} {name}{argstr}".strip() if type_ else f"{name}{argstr}"
|
||||
if kind == "variable":
|
||||
return f"{type_} {name}".strip()
|
||||
if kind == "enum":
|
||||
return f"enum {name}"
|
||||
if kind == "typedef":
|
||||
defn = (m.findtext("definition") or "").strip()
|
||||
return defn or f"typedef {type_} {name}"
|
||||
return name
|
||||
|
||||
|
||||
def emit_class(xml_dir: Path, out_dir: Path, refid: str) -> None:
|
||||
tree = ET.parse(xml_dir / f"{refid}.xml")
|
||||
root = tree.find("compounddef")
|
||||
if root is None:
|
||||
return
|
||||
kind = root.attrib.get("kind", "class")
|
||||
name = (root.findtext("compoundname") or "").strip()
|
||||
|
||||
out_path = out_dir / "classes" / f"{refid[5 if kind == 'class' else 6:]}.md"
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append(f"# {name}")
|
||||
lines.append("")
|
||||
header_bits = [f"`{kind} {name}`"]
|
||||
bases = []
|
||||
for b in root.findall("basecompoundref"):
|
||||
label = (b.text or "").strip()
|
||||
bases.append(ref_to_md(b.attrib.get("refid", ""), label))
|
||||
if bases:
|
||||
header_bits.append("— inherits " + ", ".join(bases))
|
||||
lines.append(" ".join(header_bits))
|
||||
lines.append("")
|
||||
loc = root.find("location")
|
||||
if loc is not None and loc.attrib.get("file"):
|
||||
lines.append(f"Defined in {src_link(loc)}")
|
||||
lines.append("")
|
||||
|
||||
brief, _, _, _ = render_description(root.find("briefdescription"))
|
||||
if brief:
|
||||
lines.append(brief)
|
||||
lines.append("")
|
||||
detailed, _, _, _ = render_description(root.find("detaileddescription"))
|
||||
if detailed:
|
||||
lines.append(detailed)
|
||||
lines.append("")
|
||||
|
||||
section_titles = {
|
||||
"public-type": "Public Types",
|
||||
"public-attrib": "Public Attributes",
|
||||
"public-static-attrib": "Public Static Attributes",
|
||||
"public-func": "Public Functions",
|
||||
"public-static-func": "Public Static Functions",
|
||||
"protected-attrib": "Protected Attributes",
|
||||
"protected-func": "Protected Functions",
|
||||
"private-attrib": "Private Attributes",
|
||||
"private-func": "Private Functions",
|
||||
"user-defined": "User-defined",
|
||||
}
|
||||
for sec in root.findall("sectiondef"):
|
||||
sk = sec.attrib.get("kind", "")
|
||||
title = section_titles.get(sk, sk.replace("-", " ").capitalize())
|
||||
members = sec.findall("memberdef")
|
||||
if not members:
|
||||
continue
|
||||
lines.append(f"## {title}")
|
||||
lines.append("")
|
||||
for m in members:
|
||||
sig = member_signature(m)
|
||||
mloc = m.find("location")
|
||||
link = src_link(mloc) if mloc is not None else ""
|
||||
lines.append(f"### `{sig}`")
|
||||
if link:
|
||||
lines.append("")
|
||||
lines.append(f"📍 {link}")
|
||||
lines.append("")
|
||||
mbrief, _, _, _ = render_description(m.find("briefdescription"))
|
||||
mdetail, params, returns, throws = render_description(m.find("detaileddescription"))
|
||||
if mbrief:
|
||||
lines.append(mbrief)
|
||||
lines.append("")
|
||||
if mdetail:
|
||||
lines.append(mdetail)
|
||||
lines.append("")
|
||||
if params:
|
||||
lines.append("**Parameters**")
|
||||
lines.append("")
|
||||
for pname, pdesc in params:
|
||||
lines.append(f"- `{pname}` — {pdesc}" if pdesc else f"- `{pname}`")
|
||||
lines.append("")
|
||||
if returns:
|
||||
lines.append("**Returns** " + " ".join(returns))
|
||||
lines.append("")
|
||||
if throws:
|
||||
lines.append("**Throws**")
|
||||
lines.append("")
|
||||
for tname, tdesc in throws:
|
||||
lines.append(f"- `{tname}` — {tdesc}" if tdesc else f"- `{tname}`")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("← [Back to classes](index.md) · [Top](../index.md)")
|
||||
out_path.write_text("\n".join(lines).rstrip() + "\n")
|
||||
|
||||
|
||||
def emit_file(xml_dir: Path, out_dir: Path, refid: str) -> None:
|
||||
tree = ET.parse(xml_dir / f"{refid}.xml")
|
||||
root = tree.find("compounddef")
|
||||
if root is None:
|
||||
return
|
||||
name = (root.findtext("compoundname") or "").strip()
|
||||
safe = refid # already a flat identifier like system_2modules_8hpp
|
||||
out_path = out_dir / "files" / f"{safe}.md"
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append(f"# {name}")
|
||||
lines.append("")
|
||||
loc = root.find("location")
|
||||
if loc is not None and loc.attrib.get("file"):
|
||||
lines.append(f"Source: {src_link(loc)}")
|
||||
lines.append("")
|
||||
|
||||
brief, _, _, _ = render_description(root.find("briefdescription"))
|
||||
if brief:
|
||||
lines.append(brief)
|
||||
lines.append("")
|
||||
detailed, _, _, _ = render_description(root.find("detaileddescription"))
|
||||
if detailed:
|
||||
lines.append(detailed)
|
||||
lines.append("")
|
||||
|
||||
# Inner compounds (classes/structs/namespaces defined in this file).
|
||||
inner = [(c.attrib.get("refid", ""), (c.text or "").strip())
|
||||
for c in root.findall("innerclass")]
|
||||
if inner:
|
||||
lines.append("## Defines")
|
||||
lines.append("")
|
||||
for rid, label in inner:
|
||||
lines.append(f"- {ref_to_md(rid, label)}")
|
||||
lines.append("")
|
||||
|
||||
# Free functions, variables, typedefs at file scope.
|
||||
for sec in root.findall("sectiondef"):
|
||||
sk = sec.attrib.get("kind", "")
|
||||
title_map = {
|
||||
"func": "Free Functions",
|
||||
"var": "Variables",
|
||||
"typedef": "Typedefs",
|
||||
"enum": "Enums",
|
||||
"define": "Macros",
|
||||
}
|
||||
title = title_map.get(sk)
|
||||
if not title:
|
||||
continue
|
||||
members = sec.findall("memberdef")
|
||||
if not members:
|
||||
continue
|
||||
lines.append(f"## {title}")
|
||||
lines.append("")
|
||||
for m in members:
|
||||
sig = member_signature(m)
|
||||
mloc = m.find("location")
|
||||
link = src_link(mloc) if mloc is not None else ""
|
||||
lines.append(f"### `{sig}`")
|
||||
if link:
|
||||
lines.append("")
|
||||
lines.append(f"📍 {link}")
|
||||
lines.append("")
|
||||
mbrief, _, _, _ = render_description(m.find("briefdescription"))
|
||||
mdetail, params, returns, throws = render_description(m.find("detaileddescription"))
|
||||
if mbrief:
|
||||
lines.append(mbrief); lines.append("")
|
||||
if mdetail:
|
||||
lines.append(mdetail); lines.append("")
|
||||
if params:
|
||||
lines.append("**Parameters**"); lines.append("")
|
||||
for pname, pdesc in params:
|
||||
lines.append(f"- `{pname}` — {pdesc}" if pdesc else f"- `{pname}`")
|
||||
lines.append("")
|
||||
if returns:
|
||||
lines.append("**Returns** " + " ".join(returns)); lines.append("")
|
||||
if throws:
|
||||
lines.append("**Throws**"); lines.append("")
|
||||
for tname, tdesc in throws:
|
||||
lines.append(f"- `{tname}` — {tdesc}" if tdesc else f"- `{tname}`")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("← [Back to files](index.md) · [Top](../index.md)")
|
||||
out_path.write_text("\n".join(lines).rstrip() + "\n")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Index pages
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def emit_classes_index(out_dir: Path,
|
||||
entries: list[tuple[str, str, str]]) -> None:
|
||||
"""entries = list of (refid, name, brief)."""
|
||||
lines: list[str] = []
|
||||
lines.append("# Classes & Structs")
|
||||
lines.append("")
|
||||
lines.append("| Name | Brief |")
|
||||
lines.append("|---|---|")
|
||||
for refid, name, brief in sorted(entries, key=lambda e: e[1].lower()):
|
||||
m = CLASS_REFID_RE.match(refid)
|
||||
path = f"{m.group(2)}.md" if m else refid
|
||||
lines.append(f"| [`{name}`]({path}) | {brief.replace('|', '\\|').strip() or '—'} |")
|
||||
lines.append("")
|
||||
lines.append("← [Top](../index.md)")
|
||||
(out_dir / "classes" / "index.md").write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def emit_files_index(out_dir: Path,
|
||||
entries: list[tuple[str, str, str]]) -> None:
|
||||
lines: list[str] = []
|
||||
lines.append("# Source Files")
|
||||
lines.append("")
|
||||
lines.append("| Path | Brief |")
|
||||
lines.append("|---|---|")
|
||||
for refid, name, brief in sorted(entries, key=lambda e: e[1].lower()):
|
||||
lines.append(f"| [`{name}`]({refid}.md) | {brief.replace('|', '\\|').strip() or '—'} |")
|
||||
lines.append("")
|
||||
lines.append("← [Top](../index.md)")
|
||||
(out_dir / "files" / "index.md").write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def emit_top_index(out_dir: Path, n_classes: int, n_files: int) -> None:
|
||||
lines: list[str] = [
|
||||
"# essim API reference",
|
||||
"",
|
||||
"Auto-generated from Doxygen comments in `src/`. Regenerate with",
|
||||
"`cmake --build build --target doc`. See [doc/README.md](../README.md)",
|
||||
"for the toolchain.",
|
||||
"",
|
||||
f"- [Classes & Structs](classes/index.md) — {n_classes} entries",
|
||||
f"- [Source Files](files/index.md) — {n_files} entries",
|
||||
"",
|
||||
"## Curated reading order",
|
||||
"",
|
||||
"Start with the domain model, then importers, then the TUI:",
|
||||
"",
|
||||
"1. [`System`](classes/System.md) — owns Modules + Connections.",
|
||||
"2. [`Module`](classes/Module.md) → [`Part`](classes/Part.md) → "
|
||||
"[`Pin`](classes/Pin.md) → [`Signal`](classes/Signal.md) — ownership chain.",
|
||||
"3. [`Connection`](classes/Connection.md) — cross-module wiring with `pin_map`.",
|
||||
"4. [`Transform`](classes/Transform.md) / "
|
||||
"[`IdentityTransform`](classes/IdentityTransform.md) — connector-pair → pin pairs.",
|
||||
"5. [`ImportBase`](classes/ImportBase.md) → "
|
||||
"[`ImportMentor`](classes/ImportMentor.md), "
|
||||
"[`ImportAltium`](classes/ImportAltium.md), "
|
||||
"[`ImportOds`](classes/ImportOds.md) — netlist parsers.",
|
||||
"6. [`Tui`](classes/Tui.md) — interactive shell + screen orchestration.",
|
||||
"",
|
||||
]
|
||||
(out_dir / "index.md").write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Main
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 3:
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
xml_dir = Path(sys.argv[1])
|
||||
out_dir = Path(sys.argv[2])
|
||||
if not xml_dir.is_dir():
|
||||
print(f"xml_dir not found: {xml_dir}", file=sys.stderr)
|
||||
return 1
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / "classes").mkdir(exist_ok=True)
|
||||
(out_dir / "files").mkdir(exist_ok=True)
|
||||
|
||||
index = ET.parse(xml_dir / "index.xml").getroot()
|
||||
|
||||
classes: list[tuple[str, str, str]] = [] # (refid, name, brief)
|
||||
files: list[tuple[str, str, str]] = []
|
||||
for c in index.findall("compound"):
|
||||
kind = c.attrib.get("kind", "")
|
||||
refid = c.attrib.get("refid", "")
|
||||
name = (c.findtext("name") or "").strip()
|
||||
if kind in ("class", "struct"):
|
||||
# Read brief from the per-compound XML.
|
||||
try:
|
||||
tree = ET.parse(xml_dir / f"{refid}.xml").getroot().find("compounddef")
|
||||
brief, _, _, _ = render_description(tree.find("briefdescription")) if tree is not None else ("", [], [], [])
|
||||
except (FileNotFoundError, ET.ParseError):
|
||||
brief = ""
|
||||
classes.append((refid, name, brief))
|
||||
emit_class(xml_dir, out_dir, refid)
|
||||
elif kind == "file":
|
||||
try:
|
||||
tree = ET.parse(xml_dir / f"{refid}.xml").getroot().find("compounddef")
|
||||
brief, _, _, _ = render_description(tree.find("briefdescription")) if tree is not None else ("", [], [], [])
|
||||
except (FileNotFoundError, ET.ParseError):
|
||||
brief = ""
|
||||
files.append((refid, name, brief))
|
||||
emit_file(xml_dir, out_dir, refid)
|
||||
|
||||
emit_classes_index(out_dir, classes)
|
||||
emit_files_index(out_dir, files)
|
||||
emit_top_index(out_dir, len(classes), len(files))
|
||||
|
||||
print(f"Wrote {len(classes)} class page(s) and {len(files)} file page(s) "
|
||||
f"to {out_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user