Download scripts/parse.py from gesh75/multivendor-cli-configurator: direct link, hf CLI and curl.
- Browser
- Download file 21.7 kB
-
https://huggingface.co/spaces/gesh75/multivendor-cli-configurator/resolve/main/scripts/parse.py
- Command line
-
hf download hf://spaces/gesh75/multivendor-cli-configurator/scripts/parse.py
-
curl -L -o parse.py https://huggingface.co/spaces/gesh75/multivendor-cli-configurator/resolve/main/scripts/parse.py
21.7 kB
| #!/usr/bin/env python3 | |
| """ | |
| Parse community-sourced Cisco markdown cheatsheets into commands.json. | |
| Schema: { os, role, vendor, cat, title, cmd, desc } | |
| """ | |
| import json, re, hashlib, pathlib, sys | |
| SRC_DIR = pathlib.Path(__file__).parent / "sources" | |
| OUT_FILE = pathlib.Path(__file__).parent.parent / "commands.json" | |
| # Heuristic category map. Order matters: first match wins. | |
| CAT_KEYWORDS = [ | |
| ("BGP", ["bgp", "ebgp", "ibgp", "as-path", "route-map.*bgp", "neighbor.*remote-as"]), | |
| ("OSPF", ["ospf", "ospfv3", "router-id", "lsa", "designated router", "passive-interface"]), | |
| ("EIGRP", ["eigrp", "feasible", "successor"]), | |
| ("Static", ["static route", "ip route ", "ipv6 route", "default route", "default gateway"]), | |
| ("VLAN", ["vlan", "trunk", "access vlan", "voice vlan", "dot1q", "vtp ", "switchport"]), | |
| ("Spanning-Tree", ["spanning-tree", "stp ", "rapid-pvst", "mst ", "portfast", "bpduguard", "rstp"]), | |
| ("EtherChannel",["etherchannel", "port-channel", "channel-group", "lacp", "pagp"]), | |
| ("Interfaces", ["interface ", "no shutdown", "shutdown", "description", "mtu ", "speed ", "duplex", "loopback"]), | |
| ("NAT", ["nat ", "pat ", "ip nat ", "overload", "xlate"]), | |
| ("ACL", ["access-list", "access-group", "permit ", "deny ", "ip access-list"]), | |
| ("DHCP", ["dhcp", "ip helper", "dhcp pool"]), | |
| ("HA", ["hsrp", "vrrp", "glbp", "standby ", "failover"]), | |
| ("VPN", ["crypto ", "ipsec", "ike", "isakmp", "vpn", "tunnel"]), | |
| ("AAA", ["aaa ", "tacacs", "radius", "authentication", "authorization"]), | |
| ("SNMP", ["snmp-server", "snmp "]), | |
| ("NTP", ["ntp ", "clock"]), | |
| ("Logging", ["logging ", "syslog"]), | |
| ("QoS", ["class-map", "policy-map", "service-policy", "qos", "dscp", "cos "]), | |
| ("Multicast", ["multicast", "igmp", "pim ", "msdp"]), | |
| ("MPLS", ["mpls", "ldp", "vrf ", "rd ", "route-target"]), | |
| ("Security", ["port-security", "dhcp snooping", "arp inspection", "storm-control"]), | |
| ("Troubleshooting", ["ping ", "traceroute", "debug ", "show tech", "show log", "monitor capture"]), | |
| ("Routing", ["show ip route", "show route", "ip routing"]), | |
| ("System", ["hostname", "show version", "show running", "copy running", "reload", "show inventory", "show env", "show cpu", "show mem"]), | |
| ] | |
| # OS inference heuristic | |
| def detect_os(cmd: str, hint: str = "", src: str = "") -> str: | |
| s = (src or "").lower() | |
| # Strongest signal: source filename | |
| if "junos" in s or "juniper" in s or "srx" in s or "mx-" in s: | |
| return "junos" | |
| if "arista" in s or "/eos" in s or "alista" in s: | |
| return "eos" | |
| if "nxos" in s or "nexus" in s or "nx-os" in s or "nx9k" in s or "9k" in s: | |
| return "nxos" | |
| if "asa" in s or "firepower" in s or "ftd" in s: | |
| return "asa" | |
| c = (cmd + " " + hint).lower() | |
| if any(k in c for k in ["set interfaces", "set protocols", "set system", "show configuration |", "commit confirmed", "set routing-options", "set security zones"]): | |
| return "junos" | |
| first_word = c.split("\n", 1)[0].split(" ", 1)[0] if c else "" | |
| if first_word == "feature" or " feature vpc" in c or " feature ospf" in c: | |
| return "nxos" | |
| if "no switchport" in c and "/" in c and "ip address" in c: | |
| return "nxos" | |
| if "nameif" in c or "security-level " in c or "packet-tracer input" in c: | |
| return "asa" | |
| if "configure session" in c or "ip virtual-router" in c or "mlag configuration" in c or "service routing protocols model" in c: | |
| return "eos" | |
| return "ios" | |
| # Role inference | |
| def detect_role(cmd: str, sec_path: str) -> str: | |
| c = (cmd + " " + sec_path).lower() | |
| if any(k in c for k in ["zone", "policy.*permit", "nameif", "security-level", "vpn", "ipsec", "ike", "failover"]): | |
| return "firewall" | |
| if any(k in c for k in ["vlan ", "switchport", "spanning-tree", "trunk", "vtp ", "port-channel"]): | |
| return "switch" | |
| return "router" | |
| # Vendor inference (we're mostly Cisco from these sources, but be safe) | |
| def detect_vendor(cmd: str, source_name: str) -> str: | |
| c = cmd.lower() | |
| if "set " in c and any(k in c for k in ["interfaces ge-", "protocols ospf", "routing-options"]): | |
| return "Juniper" | |
| if "configure session" in c or "ip virtual-router" in c: | |
| return "Arista" | |
| return "Cisco" | |
| def categorize(cmd: str, section: str) -> str: | |
| text = (cmd + " " + section).lower() | |
| for cat, keys in CAT_KEYWORDS: | |
| for k in keys: | |
| if re.search(k, text): | |
| return cat | |
| return "System" | |
| def clean(s: str) -> str: | |
| # strip prompts, leading "$ " or "# " from shell, leading bullets | |
| s = re.sub(r"^[\s]*[$#>][\s]?", "", s, flags=re.MULTILINE) | |
| s = re.sub(r"^\s*[-*+]\s+", "", s, flags=re.MULTILINE) | |
| return s.rstrip() | |
| def short_title(cmd: str, fallback: str) -> str: | |
| first = cmd.strip().split("\n")[0] | |
| first = re.sub(r"\s+#.*$", "", first) # strip inline comment | |
| if len(first) > 70: first = first[:67] + "..." | |
| return first or fallback or "command" | |
| # headings: track h2/h3/h4/h5 path | |
| def parse_markdown(path: pathlib.Path): | |
| text = path.read_text(encoding="utf-8", errors="ignore") | |
| lines = text.split("\n") | |
| headings = {} # level -> title | |
| items = [] | |
| i = 0 | |
| n = len(lines) | |
| while i < n: | |
| line = lines[i] | |
| m = re.match(r"^(#{2,6})\s+(.+?)\s*$", line) | |
| if m: | |
| lvl = len(m.group(1)) | |
| title = re.sub(r"[`*_:๐๐๐๐]", "", m.group(2)).strip() | |
| # collapse emoji/special chars | |
| title = re.sub(r"[^\w\s\-\(\)\/.,]", "", title).strip() | |
| headings[lvl] = title | |
| # clear deeper levels | |
| for k in list(headings): | |
| if k > lvl: del headings[k] | |
| i += 1 | |
| continue | |
| # detect fenced code blocks | |
| m2 = re.match(r"^```(\w*)\s*$", line) | |
| if m2: | |
| lang = m2.group(1).lower() | |
| block = [] | |
| i += 1 | |
| while i < n and not lines[i].startswith("```"): | |
| block.append(lines[i]) | |
| i += 1 | |
| i += 1 # skip closing ``` | |
| # Only keep blocks that look like config (filter out python/bash unless small) | |
| if lang in {"python", "json", "yaml", "javascript", "js", "html"}: | |
| continue | |
| code = clean("\n".join(block)).strip() | |
| if not code: continue | |
| if len(code) > 1500: continue # skip huge dumps | |
| # Split very long blocks into sub-commands by blank lines or by ! separators if it's clearly multiple | |
| chunks = [c.strip() for c in re.split(r"\n\s*\n", code) if c.strip()] | |
| for chunk in chunks: | |
| # Skip pure prose blocks | |
| if not any(re.search(r"\b(show|interface|ip|router|vlan|set|access-list|hostname|configure|switchport|spanning-tree|crypto|snmp|logging|ntp|aaa|debug|enable|copy|write|reload|monitor|clear|ping|traceroute)\b", l, re.I) for l in chunk.split("\n")): | |
| continue | |
| section = " > ".join(headings.get(k, "") for k in sorted(headings) if headings.get(k)) | |
| items.append({ | |
| "src": path.name, | |
| "section": section, | |
| "cmd": chunk, | |
| }) | |
| continue | |
| # Markdown table: detect "Command|Description" or "Commands|...|" header followed by separator | |
| if "|" in line and i+1 < n and re.match(r"^\s*[-:\s|]+\s*$", lines[i+1]) and any(h in line.lower() for h in ["command", "syntax", "snippet"]): | |
| i += 2 # skip header + separator | |
| while i < n and "|" in lines[i] and lines[i].strip(): | |
| row = lines[i].split("|") | |
| if len(row) >= 2: | |
| raw_cmd = row[0].strip() | |
| desc = row[1].strip() if len(row) >= 2 else "" | |
| # strip backticks/prompts | |
| cmd = re.sub(r"^`+|`+$", "", raw_cmd) | |
| cmd = re.sub(r"^[Rr]\d+(\(config[^)]*\))?[#>]\s*", "", cmd) | |
| cmd = re.sub(r"^[Ss]witch(\(config[^)]*\))?[#>]\s*", "", cmd) | |
| cmd = re.sub(r"^[Hh]ost(\(config[^)]*\))?[#>]\s*", "", cmd) | |
| cmd = cmd.strip() | |
| desc = re.sub(r"`([^`]+)`", r"\1", desc) # remove inline backticks in desc | |
| if cmd and len(cmd) < 250 and re.search(r"\b(show|interface|ip|ipv6|router|vlan|set|access-list|hostname|configure|switchport|spanning-tree|crypto|snmp|logging|ntp|aaa|debug|enable|copy|write|reload|monitor|clear|ping|traceroute|no |service|line|standby|vrrp|hsrp)\b", cmd, re.I): | |
| section = " > ".join(headings.get(k, "") for k in sorted(headings) if headings.get(k)) | |
| items.append({"src": path.name, "section": section + " | " + (desc or ""), "cmd": cmd}) | |
| i += 1 | |
| continue | |
| # Blockquote with inline backticks: > ``R1(config)#ip route ...`` | |
| bq = re.match(r"^>\s*`{1,2}([^`]+)`{1,2}\s*$", line) | |
| if bq: | |
| raw_cmd = bq.group(1).strip() | |
| cmd = strip_prompt(raw_cmd) | |
| if cmd and re.search(r"\b(show|interface|ip|ipv6|router|vlan|set|access-list|hostname|configure|switchport|spanning-tree|crypto|snmp|logging|ntp|aaa|debug|enable|copy|write|reload|monitor|clear|ping|traceroute|no )\b", cmd, re.I): | |
| section = " > ".join(headings.get(k, "") for k in sorted(headings) if headings.get(k)) | |
| items.append({"src": path.name, "section": section, "cmd": cmd}) | |
| i += 1 | |
| continue | |
| # AsciiDoc table: [cols=...] ... |=== |Cmd| Desc |=== โ rows are alternating |cell lines | |
| if line.strip() == "|===": | |
| i += 1 | |
| # consume table until next |=== | |
| rows = [] | |
| cur_row = [] | |
| while i < n and lines[i].strip() != "|===": | |
| ln = lines[i] | |
| m_cell = re.match(r"^\|\s?(.*)$", ln) | |
| if m_cell: | |
| if cur_row and (len(cur_row) >= 2 or ln.startswith("|")): | |
| # new cell starts โ row of 2 cells = command + desc | |
| if len(cur_row) >= 2: | |
| rows.append(cur_row) | |
| cur_row = [] | |
| cur_row.append(m_cell.group(1)) | |
| else: | |
| # continuation of last cell | |
| if cur_row: | |
| cur_row[-1] += "\n" + ln | |
| i += 1 | |
| if cur_row and len(cur_row) >= 2: | |
| rows.append(cur_row) | |
| i += 1 # skip closing |=== | |
| # skip first row if it looks like header (Command/Description) | |
| for r in rows: | |
| raw_cmd, desc = r[0].strip(), r[1].strip() if len(r) > 1 else "" | |
| if re.match(r"^\s*command\s*$", raw_cmd, re.I): continue | |
| if re.match(r"^\s*descritp?tion\s*$", desc, re.I): continue | |
| # strip asciidoc emphasis (*x* or _x_) and backticks | |
| cmd = re.sub(r"[*`_]", "", raw_cmd).strip() | |
| cmd = strip_prompt(cmd) | |
| if cmd and len(cmd) < 300 and re.search(r"\b(show|interface|ip|ipv6|router|vlan|set|access-list|hostname|configure|switchport|spanning-tree|crypto|snmp|logging|ntp|aaa|debug|enable|copy|write|reload|monitor|clear|ping|traceroute|no |request|commit|delete|run)\b", cmd, re.I): | |
| section = " > ".join(headings.get(k, "") for k in sorted(headings) if headings.get(k)) | |
| items.append({"src": path.name, "section": section + " | " + desc[:120], "cmd": cmd}) | |
| continue | |
| # Plain prompt lines anywhere: "Switch# show ...", "Router(config)# ip route ...", "user@JunOS> show ..." | |
| pm = re.match(r"^\s*(?:user@\w+|R\d+|Switch|Router|Host|sw\d+|[A-Z][\w-]*?)(\(config[^)]*\))?[#>%]\s+(.+?)\s*$", line) | |
| if pm and len(pm.group(2)) > 3 and len(pm.group(2)) < 200: | |
| cmd = pm.group(2).strip() | |
| # filter out non-config lines (output text) | |
| if re.search(r"^\s*(?:show|interface|ip |ipv6|router|vlan|set |access-list|hostname|configure|conf t|switchport|spanning-tree|crypto|snmp|logging|ntp|aaa|debug|enable|copy|write|reload|monitor|clear|ping|traceroute|no |request|commit|delete|run|edit|exit|end|description|mtu|speed|duplex|shutdown|sh )", cmd, re.I): | |
| section = " > ".join(headings.get(k, "") for k in sorted(headings) if headings.get(k)) | |
| items.append({"src": path.name, "section": section, "cmd": cmd}) | |
| # Indented (4-space) code lines that look like commands | |
| if line.startswith(" ") and not line.strip().startswith(("//", "#", "--", "|", "*", "-")): | |
| inner = line.strip() | |
| # match: optional `quoted` cmd or prompt+cmd or bare command | |
| inner = re.sub(r'^"([^"]+)"\s*(#.*)?$', r"\1", inner) | |
| cmd = strip_prompt(inner) | |
| cmd = cmd.split("#")[0].strip() # strip inline cisco-style comment | |
| if cmd and 6 <= len(cmd) <= 200 and re.match(r"^(?:show|interface|ip|ipv6|router|vlan|set|access-list|hostname|configure|switchport|spanning-tree|crypto|snmp|logging|ntp|aaa|debug|enable|copy|write|reload|monitor|clear|ping|traceroute|no |service|request|commit|delete|edit|description)\b", cmd, re.I): | |
| section = " > ".join(headings.get(k, "") for k in sorted(headings) if headings.get(k)) | |
| items.append({"src": path.name, "section": section, "cmd": cmd}) | |
| i += 1 | |
| return items | |
| def strip_prompt(s: str) -> str: | |
| s = re.sub(r"^(?:user@\w+|R\d+|Switch|Router|Host|sw\d+|S\d+|[A-Z][\w-]*?)(\(config[^)]*\))?[#>%]\s*", "", s) | |
| return s.strip() | |
| def main(): | |
| raw = [] | |
| for md in sorted(SRC_DIR.glob("*.md")): | |
| # ENCOR/ENARSI has its own dedicated parser โ encor.json | |
| if "encor" in md.name.lower(): continue | |
| # Junos books/reference have their own dedicated parser โ junos.json | |
| if md.name.startswith("junos-dayone") or md.name == "junos-os-cli-reference.md": continue | |
| # Arista EOS user guide has its own dedicated parser โ arista.json | |
| if md.name.startswith("arista-eos"): continue | |
| # Cisco IOS OSPF reference has its own dedicated parser โ cisco_ospf.json | |
| if md.name == "cisco-ios-ospf-reference.md": continue | |
| # Cisco ASA 9.24 + NX-OS VXLAN/BGP-EVPN have their own dedicated parsers | |
| if md.name in {"cisco-asa-924-reference.md", "cisco-nxos-vxlan-evpn-reference.md"}: continue | |
| try: | |
| items = parse_markdown(md) | |
| raw.extend(items) | |
| print(f" parsed {md.name}: {len(items)} blocks", file=sys.stderr) | |
| except Exception as e: | |
| print(f" ! {md.name}: {e}", file=sys.stderr) | |
| # Convert to schema | |
| cmds = [] | |
| seen = set() | |
| for it in raw: | |
| cmd = it["cmd"] | |
| sec = it["section"] | |
| # dedup key on normalized command | |
| key = re.sub(r"\s+", " ", cmd).lower() | |
| if key in seen: continue | |
| seen.add(key) | |
| os = detect_os(cmd, sec, it["src"]) | |
| role = detect_role(cmd, sec) | |
| vendor = detect_vendor(cmd, it["src"]) | |
| # vendor override from filename | |
| sf = it["src"].lower() | |
| if "arista" in sf or "alista" in sf or "eos" in sf: vendor = "Arista" | |
| elif "junos" in sf or "juniper" in sf or "srx" in sf: vendor = "Juniper" | |
| cat = categorize(cmd, sec) | |
| title = short_title(cmd, sec.split(" > ")[-1] if sec else "") | |
| cmds.append({ | |
| "os": os, "role": role, "vendor": vendor, "cat": cat, | |
| "title": title, "cmd": cmd, | |
| "desc": sec or "", | |
| }) | |
| # Merge with the hand-curated dataset extracted from cheatsheet.html | |
| seed_path = pathlib.Path(__file__).parent / "seed.json" | |
| if seed_path.exists(): | |
| seed = json.loads(seed_path.read_text()) | |
| for s in seed: | |
| key = re.sub(r"\s+", " ", s["cmd"]).lower() | |
| if key not in seen: | |
| seen.add(key); cmds.append(s) | |
| print(f" merged seed: {len(seed)} curated entries", file=sys.stderr) | |
| # Merge ENCOR/ENARSI Cisco Press dataset (1,391 commands) | |
| encor_path = pathlib.Path(__file__).parent / "encor.json" | |
| if encor_path.exists(): | |
| encor = json.loads(encor_path.read_text()) | |
| added = 0 | |
| for s in encor: | |
| key = re.sub(r"\s+", " ", s["cmd"]).lower() | |
| if key not in seen: | |
| seen.add(key); cmds.append(s); added += 1 | |
| print(f" merged ENCOR: {added} new entries (deduped from {len(encor)})", file=sys.stderr) | |
| # Merge Junos books (Day One Beginners + Day One CLI + CLI Reference index) | |
| junos_path = pathlib.Path(__file__).parent / "junos.json" | |
| if junos_path.exists(): | |
| junos = json.loads(junos_path.read_text()) | |
| added = 0 | |
| for s in junos: | |
| key = re.sub(r"\s+", " ", s["cmd"]).lower() | |
| if key not in seen: | |
| seen.add(key); cmds.append(s); added += 1 | |
| print(f" merged Junos: {added} new entries (deduped from {len(junos)})", file=sys.stderr) | |
| # Merge Arista EOS User Guide | |
| arista_path = pathlib.Path(__file__).parent / "arista.json" | |
| if arista_path.exists(): | |
| arista = json.loads(arista_path.read_text()) | |
| added = 0 | |
| for s in arista: | |
| key = re.sub(r"\s+", " ", s["cmd"]).lower() | |
| if key not in seen: | |
| seen.add(key); cmds.append(s); added += 1 | |
| print(f" merged Arista: {added} new entries (deduped from {len(arista)})", file=sys.stderr) | |
| # Merge Cisco IOS OSPF dedicated reference (111 commands) | |
| ospf_path = pathlib.Path(__file__).parent / "cisco_ospf.json" | |
| if ospf_path.exists(): | |
| ospf_set = json.loads(ospf_path.read_text()) | |
| added = 0 | |
| for s in ospf_set: | |
| key = re.sub(r"\s+", " ", s["cmd"]).lower() | |
| if key not in seen: | |
| seen.add(key); cmds.append(s); added += 1 | |
| print(f" merged Cisco OSPF: {added} new entries (deduped from {len(ospf_set)})", file=sys.stderr) | |
| # Merge Cisco ASA 9.24 + NX-OS VXLAN BGP EVPN | |
| for extra_name in ("cisco_asa.json", "cisco_nxos_vxlan.json"): | |
| p = pathlib.Path(__file__).parent / extra_name | |
| if not p.exists(): continue | |
| extras = json.loads(p.read_text()) | |
| added = 0 | |
| for s in extras: | |
| key = re.sub(r"\s+", " ", s["cmd"]).lower() | |
| if key not in seen: | |
| seen.add(key); cmds.append(s); added += 1 | |
| print(f" merged {extra_name}: {added} new entries (deduped from {len(extras)})", file=sys.stderr) | |
| # Merge FRR (FRRouting) dataset โ derived from the official FRR docs | |
| # via scripts/parse_frr.py. Source JSON lives outside the repo at | |
| # 06_Documentation/FRR_CLI_Command_Reference.json; the parser checks it in | |
| # as scripts/frr.json so this merger stays purely local. | |
| frr_path = pathlib.Path(__file__).parent / "frr.json" | |
| if frr_path.exists(): | |
| frr = json.loads(frr_path.read_text()) | |
| added = 0 | |
| for s in frr: | |
| key = re.sub(r"\s+", " ", s["cmd"]).lower() | |
| if key not in seen: | |
| seen.add(key); cmds.append(s); added += 1 | |
| print(f" merged FRR: {added} new entries (deduped from {len(frr)})", file=sys.stderr) | |
| # Merge "wave 2" datasets emitted by scripts/parse_extras.py โ 7 new | |
| # vendors (VyOS, SONiC, NVIDIA NVUE, Palo Alto PAN-OS, Nokia SR Linux, | |
| # FortiOS, Mikrotik RouterOS) and 2 backfill files for Cisco / Juniper | |
| # gaps. All share the same row schema so we use a uniform dedupe loop. | |
| extras = [ | |
| "cisco_gaps.json", "juniper_gaps.json", | |
| "vyos.json", "sonic.json", "nvue.json", "panos.json", | |
| "srlinux.json", "fortios.json", "routeros.json", "exos.json", | |
| "aoscx.json", "vrp.json", | |
| ] | |
| for name in extras: | |
| p = pathlib.Path(__file__).parent / name | |
| if not p.exists(): | |
| continue | |
| rows = json.loads(p.read_text()) | |
| added = 0 | |
| for s in rows: | |
| if not (s.get("cmd") and s.get("vendor")): | |
| continue | |
| key = re.sub(r"\s+", " ", s["cmd"]).lower() | |
| if key in seen: | |
| continue | |
| seen.add(key); cmds.append(s); added += 1 | |
| print(f" merged {name}: {added} new entries (deduped from {len(rows)})", file=sys.stderr) | |
| # Merge external dataset (~10k commands previously curated outside this repo). | |
| # Already in the same {os,role,vendor,cat,title,cmd,desc} schema โ straight dedupe by cmd. | |
| ext_path = pathlib.Path(__file__).parent / "external_merged.json" | |
| if ext_path.exists(): | |
| ext = json.loads(ext_path.read_text()) | |
| added = 0 | |
| for s in ext: | |
| if not (s.get("cmd") and s.get("vendor")): | |
| continue | |
| key = re.sub(r"\s+", " ", s["cmd"]).lower() | |
| if key in seen: | |
| continue | |
| seen.add(key); cmds.append(s); added += 1 | |
| print(f" merged external_merged: {added} new entries (deduped from {len(ext)})", file=sys.stderr) | |
| OUT_FILE.write_text(json.dumps(cmds, indent=1, ensure_ascii=False)) | |
| print(f"\nwrote {len(cmds)} commands -> {OUT_FILE}") | |
| # quick stats | |
| from collections import Counter | |
| print("\nBy OS:", dict(Counter(c["os"] for c in cmds))) | |
| print("By role:", dict(Counter(c["role"] for c in cmds))) | |
| print("By cat:", dict(Counter(c["cat"] for c in cmds))) | |
| if __name__ == "__main__": | |
| main() | |