Python for Network Engineers — Part 6: Parsing CLI Output — From Regex to AI

In Part 5, we collected show version output from three devices and wrote a regex parser to extract the fields we needed. It worked — but it was fragile. If Arista changes a single label in their output, the parser breaks. Writing and maintaining regex parsers for dozens of show commands across multiple vendors doesn’t scale.

The network automation community has largely solved this problem. TextFSM templates, the NTC-templates library, and Netmiko’s built-in integration mean that for most common show commands on popular platforms, someone has already written the parser — and it’s one import away.

But community templates don’t cover everything. Vendor-specific diagnostic commands, less common platforms, and proprietary output formats still need custom parsing. That’s where AI-assisted parsing fills the gap — and it’s the part of this post that doesn’t exist in older network automation guides.


The Parsing Problem

send_command() returns a string. To do anything useful with it — compare values, filter by status, produce reports, feed data into another system — you need structured data: dicts or lists of dicts, not a wall of text.

Part 4’s regex approach works, but consider what it takes:

  • Read the command output carefully
  • Identify the fields
  • Write a pattern for each field
  • Handle edge cases (wrapped lines, missing fields, different IOS versions)
  • Test against multiple devices and software versions
  • Maintain it when output formats change

For a single command on a single platform, that’s manageable. For a library of commands across Cisco, Arista, Fortinet, and Juniper, it becomes a full-time job.

TextFSM systematises the template-writing process. NTC-templates means you almost never have to write one yourself.


TextFSM

TextFSM is a template language created by Google for parsing semi-structured text. A TextFSM template defines what fields to extract and how to recognise them in the output — essentially a structured wrapper around regex.

The output of a TextFSM parse is always a list of records. Each record is one row of data — one interface, one route, one BGP neighbour.

Anatomy of a Template

A TextFSM template has two sections, separated by a blank line:

  1. Value definitions — the fields to extract and their regex
  2. State machine — rules that describe how to read through the output line by line

Here’s a template for show ip interface brief on Cisco IOS-XE:

Value INTERFACE (\S+)
Value IP_ADDRESS ([\d.]+|unassigned)
Value OK (\S+)
Value METHOD (\S+)
Value STATUS ([\w ]+?)
Value PROTOCOL (\S+)

Start
  ^Interface\s+IP-Address -> Continue
  ^${INTERFACE}\s+${IP_ADDRESS}\s+${OK}\s+${METHOD}\s+${STATUS}\s{2,}${PROTOCOL} -> Record

Value lines follow the pattern Value FIELDNAME (regex). The regex is a capture group that extracts the field’s content.

State lines are indented rules inside a state block. Start is the initial state. Each rule has the form ^pattern -> action.

  • -> Record saves the current set of matched values as a completed record and resets them, ready for the next line
  • -> Continue matches the line but doesn’t advance — useful when you want to match the same line in multiple rules
  • -> Next advances to the next line (the default, so it’s often omitted)

The ^ anchor means the pattern must match at the start of the line — important because TextFSM processes output line by line.

When all input is consumed, any in-progress record is discarded unless you force a final -> Record.

Running TextFSM Directly

Install it:

uv pip install textfsm

Use it:

import textfsm

template_text = """\
Value INTERFACE (\\S+)
Value IP_ADDRESS ([\\d.]+|unassigned)
Value STATUS ([\\w ]+?)
Value PROTOCOL (\\S+)

Start
  ^Interface.*Protocol -> Continue
  ^${INTERFACE}\\s+[\\d.]+|unassigned\\s+\\S+\\s+\\S+\\s+${STATUS}\\s{2,}${PROTOCOL} -> Record
"""

output = """
Interface              IP-Address      OK? Method Status                Protocol
GigabitEthernet0/0/1   10.0.0.1        YES manual up                    up
GigabitEthernet0/0/2   unassigned      YES unset  administratively down down
"""

import io
fsm = textfsm.TextFSM(io.StringIO(template_text))
records = fsm.ParseText(output)
headers = fsm.header

for record in records:
    print(dict(zip(headers, record)))

In practice, you’d store templates in .textfsm files rather than strings — one file per command. But you rarely need to run TextFSM directly when NTC-templates is available.


NTC-Templates

NTC-templates is a community-maintained library of TextFSM templates covering hundreds of show commands across the most common network vendors. Rather than writing a template for show ip interface brief, you install the library and call it.

uv pip install ntc-templates

Using NTC-Templates

from ntc_templates.parse import parse_output

raw_output = """
Interface              IP-Address      OK? Method Status                Protocol
GigabitEthernet0/0/1   10.0.0.1        YES manual up                    up
GigabitEthernet0/0/2   unassigned      YES unset  administratively down down
GigabitEthernet0/0/3   192.168.1.1     YES DHCP   up                    up
"""

parsed = parse_output(
    platform="cisco_ios",
    command="show ip interface brief",
    data=raw_output,
)

for interface in parsed:
    print(interface)

Output:

{'INTF': 'GigabitEthernet0/0/1', 'IPADDR': '10.0.0.1', 'STATUS': 'up', 'PROTO': 'up'}
{'INTF': 'GigabitEthernet0/0/2', 'IPADDR': 'unassigned', 'STATUS': 'administratively down', 'PROTO': 'down'}
{'INTF': 'GigabitEthernet0/0/3', 'IPADDR': '192.168.1.1', 'STATUS': 'up', 'PROTO': 'up'}

parse_output() takes a platform string (matching Netmiko’s device_type convention), the exact command string, and the raw output. It returns a list of dicts — one per record in the output.

Supported Platforms and Commands

The platform strings follow Netmiko conventions. Commonly supported platforms include:

NTC Platform StringCovers
cisco_iosIOS and IOS-XE
cisco_nxosNX-OS
cisco_xrIOS-XR
cisco_asaASA
arista_eosEOS
juniper_junosJunOS
fortinet_fortiosFortiOS
paloalto_panosPAN-OS

To check whether a template exists for a particular command before trying to use it:

from ntc_templates.parse import parse_output
from ntc_templates import templates

# List all templates for a platform
import os
template_dir = os.path.dirname(templates.__file__)
cisco_templates = [
    f for f in os.listdir(template_dir)
    if f.startswith("cisco_ios_")
]
print(sorted(cisco_templates)[:10])

Or browse github.com/networktocode/ntc-templates directly — the filenames follow the pattern {platform}_{command_with_underscores}.textfsm.

If parse_output() has no template for your command, it raises TemplateNotFound. That’s your signal to either write a template or use the AI-assisted approach below.


Netmiko + TextFSM Integration

Netmiko has TextFSM built in. Pass use_textfsm=True to send_command() and Netmiko calls NTC-templates automatically, returning structured data instead of a raw string:

with ConnectHandler(**device) as conn:
    # Returns a list of dicts instead of a string
    interfaces = conn.send_command("show ip interface brief", use_textfsm=True)

for intf in interfaces:
    if intf["STATUS"] == "up":
        print(f"{intf['INTF']:<30} {intf['IPADDR']}")

When a template exists, use_textfsm=True is the cleanest option — you get structured data in a single call. When no template exists, it falls back to returning the raw string, so it’s safe to use by default and handle both cases:

output = conn.send_command("show version", use_textfsm=True)

if isinstance(output, list):
    # Template found — structured data
    data = output[0]   # show version returns one record
else:
    # No template — raw string, parse manually
    data = parse_manually(output)

Genie Parsers

Genie is Cisco’s open-source parsing library, part of the pyATS test framework. It covers Cisco IOS, IOS-XE, IOS-XR, NX-OS, and ASA with very comprehensive parsers — often returning deeper structured data than NTC-templates.

uv pip install genie pyats
from genie.conf.base import Device
from genie.libs.parser.iosxe.show_interface import ShowIpInterfaceBrief

device = Device("router", os="iosxe")
device.custom.setdefault("abstraction", {})["order"] = ["os", "platform"]

parser = ShowIpInterfaceBrief(device=device)
parsed = parser.parse(output=raw_output)

Genie’s output is deeply nested — it mirrors the actual data model of the platform — which makes it powerful but also more verbose to work with than NTC-templates’ flat list of dicts.

Two caveats to know before committing to Genie:

Cisco-only. Genie parses Cisco platforms. If your environment is multi-vendor, NTC-templates covers more ground.

Heavy dependency. pyats pulls in a large set of packages. In a project where you’re already using Netmiko and NTC-templates, adding Genie roughly triples your dependency footprint. For scripts that run against exclusively Cisco gear, it’s worth it. For mixed environments, stick with NTC-templates.


AI-Assisted Parsing

NTC-templates covers the common cases well. But network environments are full of exceptions:

  • Vendor-specific diagnostic commands with no community template
  • Commands whose output format varies between major software versions
  • Less common platforms with limited template coverage
  • Proprietary management plane commands

This is where AI-assisted parsing changes the workflow fundamentally. Instead of spending an afternoon reverse-engineering output format and writing TextFSM templates, you describe the output to an AI and ask it to write the parser.

The Workflow

Here’s a concrete example using Fortinet FortiGate output — a platform with limited NTC-template coverage for its diagnostic commands.

The command get system performance status on a FortiGate produces output like this:

CPU states: 3% user 1% system 0% nice 96% idle
CPU0 states: 2% user 1% system 0% nice 97% idle
CPU1 states: 4% user 1% system 0% nice 95% idle
Memory: 15950460k total, 7634908k used (47.9%), 8315552k free (52.1%)
Average network usage: 34 kbps in 1 minute, 37 kbps in 10 minutes, 35 kbps in 30 minutes
Average sessions: 1423 sessions in 1 minute, 1451 sessions in 10 minutes, 1398 sessions in 30 minutes
Average session setup rate: 18 sessions/sec in last 1 minute, 17 sessions/sec in last 10 minutes, 18 sessions/sec in last 30 minutes
Virus caught: 0 total in 1 minute
IPS attacks blocked: 0 total in 1 minute
Uptime: 47 days,  3 hours,  22 minutes

There’s no NTC-template for this command. Writing the regex from scratch for all those fields — memory values, percentages, session counts, uptime — takes time and invites mistakes.

Instead, paste the output into Claude with this prompt:

I have this output from a Fortinet FortiGate get system performance status command. Write a Python function called parse_fortigate_perf_status(output: str) -> dict that extracts the following fields as a dictionary: cpu_user_pct, cpu_system_pct, cpu_idle_pct, memory_total_kb, memory_used_kb, memory_used_pct, avg_sessions_1min, uptime_days, uptime_hours, uptime_minutes. Return numeric types where appropriate (int or float, not strings). If a field can’t be found, set it to None.

[paste output here]

The AI generates something like:

import re

def parse_fortigate_perf_status(output: str) -> dict:
    """Parse 'get system performance status' output from FortiGate."""

    result = {
        "cpu_user_pct": None,
        "cpu_system_pct": None,
        "cpu_idle_pct": None,
        "memory_total_kb": None,
        "memory_used_kb": None,
        "memory_used_pct": None,
        "avg_sessions_1min": None,
        "uptime_days": None,
        "uptime_hours": None,
        "uptime_minutes": None,
    }

    cpu_match = re.search(
        r"^CPU states:\s+(\d+)%\s+user\s+(\d+)%\s+system\s+\d+%\s+nice\s+(\d+)%\s+idle",
        output, re.MULTILINE
    )
    if cpu_match:
        result["cpu_user_pct"] = int(cpu_match.group(1))
        result["cpu_system_pct"] = int(cpu_match.group(2))
        result["cpu_idle_pct"] = int(cpu_match.group(3))

    mem_match = re.search(
        r"Memory:\s+(\d+)k total,\s+(\d+)k used \(([\d.]+)%\)",
        output
    )
    if mem_match:
        result["memory_total_kb"] = int(mem_match.group(1))
        result["memory_used_kb"] = int(mem_match.group(2))
        result["memory_used_pct"] = float(mem_match.group(3))

    session_match = re.search(
        r"Average sessions:\s+(\d+)\s+sessions in 1 minute",
        output
    )
    if session_match:
        result["avg_sessions_1min"] = int(session_match.group(1))

    uptime_match = re.search(
        r"Uptime:\s+(\d+)\s+days,\s+(\d+)\s+hours,\s+(\d+)\s+minutes",
        output
    )
    if uptime_match:
        result["uptime_days"] = int(uptime_match.group(1))
        result["uptime_hours"] = int(uptime_match.group(2))
        result["uptime_minutes"] = int(uptime_match.group(3))

    return result

Validating AI-Generated Parsers

Before using this in production, validate it properly:

Test against the sample. Run the function against the exact output you gave the AI and verify every field:

sample = """CPU states: 3% user 1% system 0% nice 96% idle
...
Uptime: 47 days,  3 hours,  22 minutes"""

result = parse_fortigate_perf_status(sample)
assert result["cpu_user_pct"] == 3
assert result["memory_used_pct"] == 47.9
assert result["uptime_days"] == 47
assert result["avg_sessions_1min"] == 1423
print("All assertions passed")

Test against edge cases. What happens when CPU is 100%? When uptime is 0 days? When a value is missing entirely? AI-generated parsers often handle the happy path well but miss edge cases. Try a few variations.

Read the regex. Understand each pattern before deploying it. This is the same discipline from Part 1’s AI section — you’re the engineer who owns the script. If you can’t explain what r"Memory:\s+(\d+)k total" matches, ask the AI to explain it.

Test against multiple devices. Output format can vary between FortiOS versions, between hardware models, and between HA and standalone units. Collect samples from several real devices and run them through the parser.

When AI Parsing Saves the Most Time

The AI-assisted approach is particularly valuable for:

  • One-off collection scripts. You need to pull a specific diagnostic value from 50 devices, once. Writing and maintaining a full TextFSM template isn’t worth the investment — AI-generated regex gets you there in minutes.

  • Vendor-specific commands. FortiGate diagnostic commands, Palo Alto show system resources, Cisco ASA show conn count — commands that no community template covers.

  • Exploring unfamiliar output. If you’ve never worked with a particular command’s output before, give it to an AI and ask both to parse it and to explain what each field means. It accelerates the learning process.

The AI is not a replacement for TextFSM templates and NTC-templates for commands you’ll run repeatedly across a large fleet. For that, take the time to write or find a proper template. But for the long tail of commands that don’t have templates, it’s the most practical solution available today.


Choosing the Right Approach

Does an NTC-template exist for this command/platform?

├── Yes → use_textfsm=True in Netmiko, or ntc_templates.parse_output()

└── No  ─┬── Cisco IOS/IOS-XE/NX-OS/XR/ASA?
          │     └── Check Genie — often has deeper coverage

          └── Other vendor, or Genie doesn't cover it?
                ├── Recurring command (run regularly, many devices)
                │     └── Write a TextFSM template or regex parser (Part 4)
                │         — invest in it properly, test thoroughly

                └── One-off or rare command
                      └── AI-assisted parsing — fast, validate before use

Capstone: Extending the Inventory Collector

In Part 5, parsers.py used hand-written regex for show version. Here’s the same script updated to use NTC-templates instead — less code, more fields, no regex maintenance:

Install the additional dependency:

uv pip install ntc-templates

Replace parsers.py with:

"""Parsers using NTC-templates for structured CLI output."""

from ntc_templates.parse import parse_output
from ntc_templates.exceptions import TemplateNotFound


def parse_version(raw_output: str, platform: str) -> dict:
    """Parse 'show version' using NTC-templates.

    Falls back to a minimal dict if no template is available.
    """
    try:
        records = parse_output(
            platform=platform,
            command="show version",
            data=raw_output,
        )
        if records:
            return records[0]   # show version always returns one record
    except TemplateNotFound:
        pass

    # Fallback for platforms without a template
    return {"raw": raw_output}


def parse_interfaces(raw_output: str, platform: str) -> list[dict]:
    """Parse 'show interfaces status' or equivalent using NTC-templates."""
    try:
        return parse_output(
            platform=platform,
            command="show interfaces status",
            data=raw_output,
        )
    except TemplateNotFound:
        return []

Update collect.py to also collect interface status alongside version:

"""Collect version and interface status from all lab devices."""

import sys
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException

from inventory import DEVICES
from parsers import parse_version, parse_interfaces


def collect_device_data(device: dict) -> tuple[dict | None, str | None]:
    """Collect version and interface data from a single device."""
    conn_params = {k: v for k, v in device.items() if k != "name"}
    platform = device["device_type"]

    try:
        with ConnectHandler(**conn_params) as conn:
            version_raw = conn.send_command("show version")
            interfaces_raw = conn.send_command("show interfaces status")

            return {
                "name": device.get("name", device["host"]),
                "host": device["host"],
                "version": parse_version(version_raw, platform),
                "interfaces": parse_interfaces(interfaces_raw, platform),
            }, None

    except NetmikoTimeoutException:
        return None, "timeout"
    except NetmikoAuthenticationException:
        return None, "authentication failed"


def main():
    print(f"\nCollecting from {len(DEVICES)} device(s)...\n")

    results = []
    failures = []

    for device in DEVICES:
        name = device.get("name", device["host"])
        print(f"  {name} ({device['host']})... ", end="", flush=True)
        data, error = collect_device_data(device)
        if data:
            results.append(data)
            intf_count = len(data["interfaces"])
            print(f"OK — {intf_count} interface(s)")
        else:
            failures.append((name, error))
            print(f"FAILED — {error}")

    # Version summary
    print(f"\n{'Name':<12} {'Host':<16} {'EOS Version':<16} {'Model'}")
    print("─" * 65)
    for r in results:
        v = r["version"]
        print(
            f"{r['name']:<12} "
            f"{r['host']:<16} "
            f"{v.get('VERSION', v.get('eos_version', '—')):<16} "
            f"{v.get('HARDWARE', v.get('model', '—'))}"
        )

    # Interface status summary
    for r in results:
        if r["interfaces"]:
            up = sum(1 for i in r["interfaces"] if i.get("STATUS") == "connected")
            total = len(r["interfaces"])
            print(f"\n{r['name']}: {up}/{total} interfaces connected")
            for intf in r["interfaces"]:
                status = intf.get("STATUS", "—")
                name_col = intf.get("PORT", intf.get("INTERFACE", "—"))
                vlan = intf.get("VLAN", "—")
                print(f"  {name_col:<22} {status:<14} VLAN {vlan}")

    if failures:
        print(f"\nFailed: {', '.join(n for n, _ in failures)}")
        sys.exit(1)


if __name__ == "__main__":
    main()

The parse_version() function tries NTC-templates first and falls back gracefully — a pattern that works as you add new platforms with varying template coverage.


What’s Coming in Part 7

Part 7 covers YAML and JSON — the data formats that network automation lives in. Inventory files, config data, API responses, and variable stores are almost universally one or the other. We’ll also look at Pydantic for validating that the data you read actually matches what you expect before it reaches your automation logic.


This post uses the same Containerlab topology as Part 5 (topology.yaml). No new lab infrastructure is required.