• Here is a small but powerful automation tool that:

    1. Connects to Cisco IOS-XE routers using pyATS
    2. Parses show ip interface brief using Genie
    3. Reduces the structured data
    4. Sends it to an LLM (OpenAI)
    5. Receives an AI-based health analysis
    6. Prints a structured troubleshooting summary

    This is a practical example of combining Network Automation + AI-driven operations. In order to use OpenAI API you should have credit there.

    Project Structure:

    AI/
    ├── interface_health_check.py
    ├── testbed.yaml
    ├── R1_show_ip_int_brief_full.json
    ├── R1_show_ip_int_brief_compact.json
    ├── R2_show_ip_int_brief_full.json
    ├── R2_show_ip_int_brief_compact.json
    └── venv/
    
    

    testbed.yaml:

    ---
    devices:
      R1:
        os: iosxe
        type: iosxe
        connections:
          cli:
            protocol: ssh
            ip: 192.168.199.30
        credentials:
          default:
            username: admin
            password: cisco
          enable:
            password: cisco
    
      R2:
        os: iosxe
        type: iosxe
        connections:
          cli:
            protocol: ssh
            ip: 192.168.199.31
        credentials:
          default:
            username: admin
            password: cisco
          enable:
            password: cisco
    

    Workflow Overview:

    Connect to device

    Run show ip interface brief

    Genie parses CLI → structured JSON

    Reduce JSON to only relevant fields

    Send structured data to OpenAI

    Receive troubleshooting summary

    Print health analysis

    Code:

    #!/usr/bin/env python3
    import os
    import json
    import logging
    from pyats.topology import loader
    from openai import OpenAI
    
    logging.basicConfig(level=logging.INFO)
    log = logging.getLogger("pyats-ai")
    
    OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
    if not OPENAI_API_KEY:
        raise RuntimeError("Set OPENAI_API_KEY first (see steps below).")
    
    client = OpenAI(api_key=OPENAI_API_KEY)
    
    TESTBED_FILE = "testbed.yaml"
    testbed = loader.load(TESTBED_FILE) #This loads all devices defined in YAML into a Python object
    
    
    def save_json(data, filename):
        with open(filename, "w") as f:
            json.dump(data, f, indent=2)
    
    def summarize_int_brief(parsed):
        """
        Reduce the parsed output to only what the AI needs.
        Genie schema can vary a bit; we defensively extract fields.
        """
        rows = []
    
        # Common structure: parsed["interface"] -> dict of interfaces
        iface_dict = parsed.get("interface", {})
        for ifname, info in iface_dict.items():
            rows.append({
                "interface": ifname,
                "ip_address": info.get("ip_address"),
                "status": info.get("status"),
                "protocol": info.get("protocol"),
            })
    
        # If parser structure differs, fall back to raw object (last resort)
        if not rows:
            return {"raw": parsed}
    
        return {"interfaces": rows}
    
    #controlled AI reasoning with: Define the AI role+Give clear instructions+Define expected output format+Inject structured JSON
    
    def analyze_with_ai(device_name, payload):
        prompt = f"""
    You are a Cisco network troubleshooting assistant.
    Analyze the following structured output from "show ip interface brief" for device {device_name}.
    1) List any interfaces that look unhealthy (down/down, administratively down, protocol down, missing IP where expected).
    2) Give likely causes and quick checks/commands.
    3) Provide a short "overall health" summary.
    Return the answer in clear bullet points.
    Data:
    {json.dumps(payload, ensure_ascii=False)}
    """.strip()
    
        # Responses API (recommended) [Calling the OpenAI API]
        resp = client.responses.create(
            model="gpt-4o-mini",
            input=prompt,
        )
        return resp.output_text
    
    for device_name, device in testbed.devices.items():
        log.info(f"Connecting to {device_name} ({device.connections.cli.ip}) ...")
        try:
            device.connect(
                log_stdout=False,
                init_exec_commands=[],
                init_config_commands=[],
            )
        except Exception as e:
            log.error(f"[{device_name}] connect failed: {e}")
            continue
    
        try:
            log.info(f"[{device_name}] parsing: show ip interface brief")
            parsed = device.parse("show ip interface brief")
            save_json(parsed, f"{device_name}_show_ip_int_brief_full.json")
    
            compact = summarize_int_brief(parsed)
            save_json(compact, f"{device_name}_show_ip_int_brief_compact.json")
    
            log.info(f"[{device_name}] sending to AI...")
            ai_text = analyze_with_ai(device_name, compact)
            print("\n" + "=" * 70)
            print(f"AI analysis for {device_name}\n")
            print(ai_text)
            print("=" * 70 + "\n")
    
        except Exception as e:
            log.error(f"[{device_name}] error: {e}")
        finally:
            device.disconnect()
    
    log.info("Done.")
    

    Output:

    (venv) saeed@saeed-ubuntu:~/Desktop/Network-Automation/pyats-lab/AI$ python3 interface_health_check.py 
    INFO:pyats-ai:Connecting to R1 (192.168.199.30) ...
    INFO:pyats-ai:[R1] parsing: show ip interface brief
    INFO:pyats-ai:[R1] sending to AI...
    INFO:httpx:HTTP Request: POST https://api.openai.com/v1/responses "HTTP/1.1 429 Too Many Requests"
    INFO:openai._base_client:Retrying request to /responses in 0.465011 seconds
    INFO:httpx:HTTP Request: POST https://api.openai.com/v1/responses "HTTP/1.1 429 Too Many Requests"
    INFO:openai._base_client:Retrying request to /responses in 0.818862 seconds
    INFO:httpx:HTTP Request: POST https://api.openai.com/v1/responses "HTTP/1.1 200 OK"
    
    ======================================================================
    AI analysis for R1
    
    ### Unhealthy Interfaces
    - **GigabitEthernet0/1**
      - Status: Admin down
      - Protocol: Down
      - IP Address: Unassigned
      
    - **GigabitEthernet0/2**
      - Status: Admin down
      - Protocol: Down
      - IP Address: Unassigned
      
    - **GigabitEthernet0/3**
      - Status: Admin down
      - Protocol: Down
      - IP Address: Unassigned
    
    ### Likely Causes and Quick Checks/Commands
    - **GigabitEthernet0/1, 0/2, 0/3**
      - **Cause:** These interfaces are administratively down, which typically means they have been shut down via configuration.
      - **Quick Check/Commands:**
        - Use `show running-config interface GigabitEthernet0/1` (or 2, 3) to verify if the `shutdown` command is applied.
        - If they need to be activated, use `interface GigabitEthernet0/1` followed by `no shutdown`.
      
    ### Overall Health Summary
    - **Overall Health:**
      - **GigabitEthernet0/0**: Healthy, active with an assigned IP.
      - **Loopback0 & Loopback100**: Healthy, both up but have no assigned IPs, which is acceptable for loopback interfaces.
      - **GigabitEthernet0/1, 0/2, & 0/3**: Unhealthy, requiring administrative action to bring them up.
      
    In summary, the main concern is with the administratively down interfaces, which need to be reviewed and possibly activated based on network design requirements.
    ======================================================================
    
    INFO:pyats-ai:Connecting to R2 (192.168.199.31) ...
    INFO:pyats-ai:[R2] parsing: show ip interface brief
    INFO:pyats-ai:[R2] sending to AI...
    INFO:httpx:HTTP Request: POST https://api.openai.com/v1/responses "HTTP/1.1 200 OK"
    
    ======================================================================
    AI analysis for R2
    
    ### Unhealthy Interfaces
    - **GigabitEthernet0/1**
      - Status: administratively down
      - Protocol: down
      - IP Address: unassigned
    - **GigabitEthernet0/2**
      - Status: administratively down
      - Protocol: down
      - IP Address: unassigned
    - **GigabitEthernet0/3**
      - Status: administratively down
      - Protocol: down
      - IP Address: unassigned
    
    ### Likely Causes and Quick Checks
    - **GigabitEthernet0/1, 0/2, 0/3 - Administratively Down**
      - **Cause**: Interfaces are manually disabled.
      - **Quick Check**: Use `show running-config interface GigabitEthernet0/x` to verify the administrative status.
      - **Command to Check**: `no shutdown` command in configuration mode to enable the interface if needed.
    
    - **Loopback Interfaces**
      - They are up but have unassigned IP addresses, which may not be a problem if not needed for routing or services but should be checked.
      - It’s fine if loops are not assigned based on the network design.
    
    ### Overall Health Summary
    - **Healthy Interfaces**: GigabitEthernet0/0 is operational with an assigned IP.
    - **Unhealthy Interfaces**: Three GigabitEthernet interfaces are administratively down, indicating they are disabled intentionally or due to configuration.
    - **Action Required**: Review the configuration for the down interfaces and enable as needed based on network requirements. Loopback interfaces require verification to see if IP assignment is actually necessary.
    ======================================================================
    
    INFO:pyats-ai:Done.
    
    (venv) saeed@saeed-ubuntu:~/Desktop/Network-Automation/pyats-lab/AI$ cat  R1_show_ip_int_brief_compact.json
    {
      "interfaces": [
        {
          "interface": "GigabitEthernet0/0",
          "ip_address": "192.168.199.30",
          "status": "up",
          "protocol": "up"
        },
        {
          "interface": "GigabitEthernet0/1",
          "ip_address": "unassigned",
          "status": "administratively down",
          "protocol": "down"
        },
        {
          "interface": "GigabitEthernet0/2",
          "ip_address": "unassigned",
          "status": "administratively down",
          "protocol": "down"
        },
        {
          "interface": "GigabitEthernet0/3",
          "ip_address": "unassigned",
          "status": "administratively down",
          "protocol": "down"
        },
        {
          "interface": "Loopback0",
          "ip_address": "unassigned",
          "status": "up",
          "protocol": "up"
        },
        {
          "interface": "Loopback100",
          "ip_address": "unassigned",
          "status": "up",
          "protocol": "up"
        }
      ]
    }(venv) saeed@saeed-ubuntu:~/Desktop/Network-Automation/pyats-lab/AI$ cat  R1_show_ip_int_brief_full.json
    {
      "interface": {
        "GigabitEthernet0/0": {
          "ip_address": "192.168.199.30",
          "interface_is_ok": "YES",
          "method": "NVRAM",
          "status": "up",
          "protocol": "up"
        },
        "GigabitEthernet0/1": {
          "ip_address": "unassigned",
          "interface_is_ok": "YES",
          "method": "NVRAM",
          "status": "administratively down",
          "protocol": "down"
        },
        "GigabitEthernet0/2": {
          "ip_address": "unassigned",
          "interface_is_ok": "YES",
          "method": "NVRAM",
          "status": "administratively down",
          "protocol": "down"
        },
        "GigabitEthernet0/3": {
          "ip_address": "unassigned",
          "interface_is_ok": "YES",
          "method": "NVRAM",
          "status": "administratively down",
          "protocol": "down"
        },
        "Loopback0": {
          "ip_address": "unassigned",
          "interface_is_ok": "YES",
          "method": "unset",
          "status": "up",
          "protocol": "up"
        },
        "Loopback100": {
          "ip_address": "unassigned",
          "interface_is_ok": "YES",
          "method": "unset",
          "status": "up",
          "protocol": "up"
        }
      }
    
  • pyATS is a powerful network automation and testing framework designed to help network engineers validate, test, and troubleshoot their infrastructures in a structured and reliable way. Instead of manually checking device configurations and network behavior, pyATS allows you to automate these tasks using Python, making your workflows faster, more consistent, and less error-prone. It is widely used for network state validation, pre- and post-change testing, and large-scale network assurance, especially in modern CI/CD and NetDevOps environments.

    We use pyATS to make sure our network devices are configured correctly and consistently. It helps us configure and validate multiple devices in a very short time, instead of checking each one manually. With pyATS, we can easily detect whether configurations have changed and clearly see what exactly was modified. In addition, it allows us to run large-scale tests across the network, which is especially useful for validating changes before and after deployments.

    I installed pyATS with pip install pyats[full] at first.

    I have 4 routers in my eve lab for test which I enabled the ssh on them. In order to pars out the config we can test the pars on R1 but I need a yaml file as test bed file which contains the devices:

    devices:
      R1:
        os: iosxe
        type: router
        platform: x86
    
        credentials:
          default:
            username: admin
            password: cisco
    
        connections:
          cli:
            protocol: ssh
            ip: 192.168.199.30
            
      R2:
        os: iosxe
        type: router
        platform: x86
    
        credentials:
          default:
            username: admin
            password: cisco
    
        connections:
          cli:
            protocol: ssh
            ip: 192.168.199.31
    
    
      R3:
        os: iosxe
        type: router
        platform: x86
    
        credentials:
          default:
            username: admin
            password: cisco
    
        connections:
          cli:
            protocol: ssh
            ip: 192.168.199.32
    
    
      R4:
        os: iosxe
        type: router
        platform: x86
    
        credentials:
          default:
            username: admin
            password: cisco
    
        connections:
          cli:
            protocol: ssh
            ip: 192.168.199.33
    
    

    With this command we can pars out the putput of the cli command on the router, you can see that the out put is in dict formatted file:

    (venv) saeed@saeed-ubuntu:~/Desktop/pyATS$ pyats parse "show ver" --testbed-file devices.yaml --devices R1
      0%|                                                                                                                           | 0/1 [00:00<?, ?it/s]{
      "version": {
        "chassis": "IOSv",
        "chassis_sn": "9SQUBEOEJBKHYNJAKQD8W",
        "compiled_by": "mcpre",
        "compiled_date": "Mon 08-Aug-22 15:22",
        "copyright_years": "1986-2022",
        "curr_config_register": "0x0",
        "hostname": "R1",
        "image_id": "VIOS-ADVENTERPRISEK9-M",
        "image_type": "production image",
        "label": "RELEASE SOFTWARE (fc1)",
        "last_reload_reason": "Unknown reason",
        "main_mem": "984321",
        "mem_size": {
          "non-volatile configuration": "256"
        },
        "number_of_intfs": {
          "Gigabit Ethernet": "4"
        },
        "os": "IOS",
        "platform": "IOSv",
        "processor_board_flash": "0K",
        "processor_type": "revision 1.0",
        "returned_to_rom_by": "reload",
        "rom": "Bootstrap program is IOSv",
        "rtr_type": "IOSv",
        "system_image": "flash0:/vios-adventerprisek9-m",
        "uptime": "44 minutes",
        "version": "15.9(3)M6",
        "version_short": "15.9"
      }
    }
    100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:00<00:00,  1.13it/s]
    
    

    If we want to send multiple commands, we can add other commands just after the first command:

     pyats parse "show ver" "show ip interface brief" --testbed-file devices.yaml --devices R1
    

    Now I want to test the same scenario with the code:

    from genie.testbed import load
    tb = load('devices.yaml')
    dev = tb.devices['R1']
    dev.connect()
    
    p1 = dev.parse("show version")
    
    output:
    Enter configuration commands, one per line.  End with CNTL/Z.
    R1(config)#no logging console
    R1(config)#line console 0
    R1(config-line)#exec-timeout 0
    R1(config-line)#line vty 0 4
    R1(config-line)#exec-timeout 0
    R1(config-line)#end
    R1#
    
    2026-02-09 11:39:00,081: %UNICON-INFO: +++ R1 with via 'cli': executing command 'show install summary' +++
    show install summary
    show install summary
           ^
    % Invalid input detected at '^' marker.
    
    R1#
    
    2026-02-09 11:39:00,306: %UNICON-ERROR: Could not learn the os version
    
    2026-02-09 11:39:01,182: %UNICON-INFO: +++ R1 with via 'cli': executing command 'show version' +++
    show version
    Cisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.9(3)M6, RELEASE SOFTWARE (fc1)
    Technical Support: http://www.cisco.com/techsupport
    Copyright (c) 1986-2022 by Cisco Systems, Inc.
    Compiled Mon 08-Aug-22 15:22 by mcpre
    
    
    ROM: Bootstrap program is IOSv
    
    R1 uptime is 59 minutes
    System returned to ROM by reload
    System image file is "flash0:/vios-adventerprisek9-m"
    Last reload reason: Unknown reason
    
    
    
    This product contains cryptographic features and is subject to United
    States and local country laws governing import, export, transfer and
    use. Delivery of Cisco cryptographic products does not imply
    third-party authority to import, export, distribute or use encryption.
    Importers, exporters, distributors and users are responsible for
    compliance with U.S. and local country laws. By using this product you
    agree to comply with applicable laws and regulations. If you are unable
    to comply with U.S. and local laws, return this product immediately.
    
    A summary of U.S. laws governing Cisco cryptographic products may be found at:
    http://www.cisco.com/wwl/export/crypto/tool/stqrg.html
    
    If you require further assistance please contact us by sending email to
    export@cisco.com.
    
    Cisco IOSv (revision 1.0) with  with 984321K/62464K bytes of memory.
    Processor board ID 9SQUBEOEJBKHYNJAKQD8W
    4 Gigabit Ethernet interfaces
    DRAM configuration is 72 bits wide with parity disabled.
    256K bytes of non-volatile configuration memory.
    2097152K bytes of ATA System CompactFlash 0 (Read/Write)
    0K bytes of ATA CompactFlash 1 (Read/Write)
    0K bytes of ATA CompactFlash 2 (Read/Write)
    0K bytes of ATA CompactFlash 3 (Read/Write)
    
    
    
    Configuration register is 0x0
    
    R1#
    
    

    The first command are the default command which pyATS use to keep the line. But as you can see the out put is like exact cisco cli. bit if we use print (p1) then we can have the structured output:

    
    

    {‘version’: {‘version_short’: ‘15.9’, ‘platform’: ‘IOSv’, ‘version’: ‘15.9(3)M6’, ‘image_id’: ‘VIOS-ADVENTERPRISEK9-M’, ‘label’: ‘RELEASE SOFTWARE (fc1)’, ‘os’: ‘IOS’, ‘image_type’: ‘production image’, ‘copyright_years’: ‘1986-2022’, ‘compiled_date’: ‘Mon 08-Aug-22 15:22’, ‘compiled_by’: ‘mcpre’, ‘rom’: ‘Bootstrap program is IOSv’, ‘hostname’: ‘R1’, ‘uptime’: ‘1 hour, 3 minutes’, ‘returned_to_rom_by’: ‘reload’, ‘system_image’: ‘flash0:/vios-adventerprisek9-m’, ‘last_reload_reason’: ‘Unknown reason’, ‘chassis’: ‘IOSv’, ‘main_mem’: ‘984321’, ‘processor_type’: ‘revision 1.0’, ‘rtr_type’: ‘IOSv’, ‘chassis_sn’: ‘9SQUBEOEJBKHYNJAKQD8W’, ‘number_of_intfs’: {‘Gigabit Ethernet’: ‘4’}, ‘mem_size’: {‘non-volatile configuration’: ‘256’}, ‘processor_board_flash’: ‘0K’, ‘curr_config_register’: ‘0x0’}}

    We can use lean feature of pyATS, so with this feature we can execute all commands related to the object we want. As an example you can see this feature about ospf:

    from genie.testbed import load
    tb = load('devices.yaml')
    dev = tb.devices['R1']
    dev.connect(mit=True)
    p1 = dev.learn('ospf')
    
    
    output:
    show ip ospf
     Routing Process "ospf 1" with ID 1.1.1.1
     Start time: 00:00:36.419, Time elapsed: 02:19:55.512
     Supports only single TOS(TOS0) routes
     Supports opaque LSA
     Supports Link-local Signaling (LLS)
    ..
    show ip protocols
    *** IP Routing is NSF aware ***
    
    Routing Protocol is "application"
      Sending updates every 0 seconds
      Invalid after 0 seconds, hold down 0, flushed after 0
      Outgoing update filter list for all interfaces is not set
    ..
    
    show running-config | section router ospf 1
    router ospf 1
     router-id 1.1.1.1
     network 0.0.0.0 255.255.255.255 area 0
    ...
    show ip ospf neighbor detail
    R1#
    
    2026-02-09 13:00:37,612: %UNICON-INFO: +++ R1 with via 'cli': executing command 'show ip ospf sham-links' +++
    show ip ospf sham-links
    R1#
    
    

    It is possible to use interactive command, in this example it will creat the logs, console and ops file:

    (venv) saeed@saeed-ubuntu:~/Desktop/pyATS$ pyats learn "ospf" --testbed-file devices.yaml --output output
    
    Learning '['ospf']' on devices '['R1']'
    100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:15<00:00, 15.78s/it]
    +==============================================================================+
    | Genie Learn Summary for device R1                                            |
    +==============================================================================+
    |  Connected to R1                                                             |
    |  -   Log: output/connection_R1.txt                                           |
    |------------------------------------------------------------------------------|
    |  Learnt feature 'ospf'                                                       |
    |  -  Ops structure:  output/ospf_iosxe_R1_ops.txt                             |
    |  -  Device Console: output/ospf_iosxe_R1_console.txt                         |
    |==============================================================================|
    
    
    
    (venv) saeed@saeed-ubuntu:~/Desktop/pyATS$ 
    

    For learning all features:

    (venv) saeed@saeed-ubuntu:~/Desktop/pyATS$ pyats learn "all" --testbed-file devices.yaml --output output
    

    We can sent the config in 2 way to the device, as an example for creating a loopback on the device:

    #The first method:
    
    from genie.testbed import load
    
    config_commands = '''
            interface lo0
            ip add 11.11.11.11 255.255.255.255
            '''
    
    tb = load('devices.yaml')
    dev = tb.devices['R1']
    
    dev.connect()
    dev.configure(config_commands)
    
    
    #The second method:
    
    from genie.testbed import load
    from genie.conf.base import Interface
    
    tb = load('devices.yaml')
    dev = tb.devices['R1']
    
    dev.connect(mit=True)
    
    interface = Interface(device=dev, name= "g0/1")
    interface.ipv4 = "192.168.10.1/24"
    interface.shutdown = False
    
    print(interface.build_config(apply=True))
    

    For comparing different network status we can learn the status of the network in 2 different times and then compare them. In this example we learn the status of the interfaces and save it in output1 and then after changing the ip address of one interface and also adding a new loopback we will compare the two files with genie diff output1 output2 command.

    (venv) saeed@saeed-ubuntu:~/Desktop/pyATS$ pyats learn "interface" --testbed-file devices.yaml --output output1
    
    
    Learning '['interface']' on devices '['R1']'
    100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:03<00:00,  3.01s/it]
    +==============================================================================+
    | Genie Learn Summary for device R1                                            |
    +==============================================================================+
    |  Connected to R1                                                             |
    |  -   Log: output1/connection_R1.txt                                          |
    |------------------------------------------------------------------------------|
    |  Learnt feature 'interface'                                                  |
    |  -  Ops structure:  output1/interface_iosxe_R1_ops.txt                       |
    |  -  Device Console: output1/interface_iosxe_R1_console.txt                   |
    |==============================================================================|
    
    
    
    (venv) saeed@saeed-ubuntu:~/Desktop/pyATS$ pyats learn "interface" --testbed-file devices.yaml --output output2
    
    Learning '['interface']' on devices '['R1']'
    100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:03<00:00,  3.01s/it]
    +==============================================================================+
    | Genie Learn Summary for device R1                                            |
    +==============================================================================+
    |  Connected to R1                                                             |
    |  -   Log: output2/connection_R1.txt                                          |
    |------------------------------------------------------------------------------|
    |  Learnt feature 'interface'                                                  |
    |  -  Ops structure:  output2/interface_iosxe_R1_ops.txt                       |
    |  -  Device Console: output2/interface_iosxe_R1_console.txt                   |
    |==============================================================================|
    
    
    
    (venv) saeed@saeed-ubuntu:~/Desktop/pyATS$ genie diff output1 output2
    1it [00:00, 249.88it/s]
    +==============================================================================+
    | Genie Diff Summary between directories output1/ and output2/                 |
    +==============================================================================+
    |  File: interface_iosxe_R1_ops.txt                                            |
    |   - Diff can be found at ./diff_interface_iosxe_R1_ops.txt                   |
    |------------------------------------------------------------------------------|
    
    
    #diff_interface_iosxe_R1_ops file:
    --- output1/interface_iosxe_R1_ops.txt
    +++ output2/interface_iosxe_R1_ops.txt
     info:
      GigabitEthernet0/2:
    +  ipv4:
    +   172.20.1.1/24:
    +    ip: 172.20.1.1
    +    prefix_length: 24
    +    secondary: False
    + Loopback200:
    +  bandwidth: 8000000
    +  counters:
    +   in_broadcast_pkts: 0
    +   in_crc_errors: 0
    +   in_errors: 0
    +   in_multicast_pkts: 0
    +   in_octets: 0
    +   in_pkts: 0
    +   last_clear: never
    +   out_errors: 0
    +   out_octets: 0
    +   out_pkts: 0
    +   rate:
    +    in_rate: 0
    +    in_rate_pkts: 0
    +    load_interval: 300
    +    out_rate: 0
    +    out_rate_pkts: 0
    +  delay: 5000
    +  enabled: True
    +  encapsulation:
    +   encapsulation: loopback
    +  ipv4:
    +   20.20.20.20/24:
    +    ip: 20.20.20.20
    +    prefix_length: 24
    +    secondary: False
    +  mtu: 1514
    +  oper_status: up
    +  port_channel:
    +   port_channel_member: False
    +  switchport_enable: False
    +  type: Loopback
    
    
    # + means it is add and - means it is removed.
    

    We can use genie APIs to for example get info of config any thing on the devices:

    We cna find the APIs here: https://developer.cisco.com/docs/genie-feature-browser/

    #getting routing table of R1
    
    from genie.testbed import load
    
    tb = load('devices.yaml')
    dev = tb.devices['R1']
    dev.connect(mit=True)
    route_table = dev.api.get_routes()
    
    print(route_table)
    
    #output:
    R1#
    
    2026-02-09 15:00:27,462: %UNICON-INFO: +++ R1 with via 'cli': executing command 'show ip route' +++
    show ip route
    Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP
           D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area 
           N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2
           E1 - OSPF external type 1, E2 - OSPF external type 2
           i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2
           ia - IS-IS inter area, * - candidate default, U - per-user static route
           o - ODR, P - periodic downloaded static route, H - NHRP, l - LISP
           a - application route
           + - replicated route, % - next hop override, p - overrides from PfR
    
    Gateway of last resort is 192.168.199.254 to network 0.0.0.0
    
    S*    0.0.0.0/0 [1/0] via 192.168.199.254
          11.0.0.0/32 is subnetted, 1 subnets
    C        11.11.11.11 is directly connected, Loopback0
          20.0.0.0/8 is variably subnetted, 2 subnets, 2 masks
    C        20.20.20.0/24 is directly connected, Loopback200
    L        20.20.20.20/32 is directly connected, Loopback200
          192.168.10.0/24 is variably subnetted, 2 subnets, 2 masks
    C        192.168.10.0/24 is directly connected, GigabitEthernet0/1
    L        192.168.10.1/32 is directly connected, GigabitEthernet0/1
          192.168.199.0/24 is variably subnetted, 2 subnets, 2 masks
    C        192.168.199.0/24 is directly connected, GigabitEthernet0/0
    L        192.168.199.30/32 is directly connected, GigabitEthernet0/0
    R1#
    ['0.0.0.0/0', '11.11.11.11/32', '20.20.20.0/24', '20.20.20.20/32', '192.168.10.0/24', '192.168.10.1/32', '192.168.199.0/24', '192.168.199.30/32']
    (venv) saeed@saeed-ubuntu:~/Desktop/pyATS$ 
    

    pyats create project is a helper command that creates a ready-to-use project structure for pyATS testing. It is like a ptoject template we can creat a project with this linux cli command:

    pyats create project my_project
    

    After that we have 2 files (pyatscli_job.py pyatscli.py) which help us to have our tests.

    We can also create the testbed yaml file using interactive command: (It will ask us about the username/password and the ip address of the devices with platform type.. then it will create us testbed file)

    pyats create testbed interactive --output=devices.yaml
    

    With this command we can validate our testbed yaml file to see if everything with it is ok:

    (venv) saeed@saeed-ubuntu:~/Desktop/pyATS$ pyats validate testbed devices.yaml 
    Loading testbed file: devices.yaml
    --------------------------------------------------------------------------------
    
    Testbed Name:
        devices
    
    Testbed Devices:
    .
    `-- R1 [iosxe/x86]
    
    YAML Lint Messages
    ------------------
      16:1      error    trailing spaces  (trailing-spaces)
    
    Warning Messages
    ----------------
     - Device 'R1' has no interface definitions
    

  • Nornir is a Python-based automation framework designed specifically for network engineers who want more control, flexibility, and scalability in their automation workflows. Unlike traditional tools that hide logic behind rigid abstractions, Nornir acts as a lightweight orchestration layer that lets you combine Python code with popular networking libraries such as Netmiko, Scrapli, NAPALM, and pyATS.

    At first we should install it: pip install nornir nornir-scrapli

    For test Lab we need to configure the routers to accept ssh connection:

    en
    conf t
    hostname R1
    ip domain-name networkpuzzles.com
    crypto key generate rsa >  2048
    ip ssh version 2
    line vty 0 4
    transport input ssh
    login local
    exit
    #username admin privilege 15 password 0 cisco
    

    We need some files for Nornir. A basic Nornir project looks like this: actually

    nornir_project/
    ├── config.yaml
    ├── hosts.yaml
    ├── groups.yaml
    ├── defaults.yaml
    └── main.py
    

    config.yaml: This file tells Nornir where the inventory files are and how to run tasks.
    inventory:
      plugin: SimpleInventory
      options:
        host_file: hosts.yaml
        group_file: groups.yaml
        defaults_file: defaults.yaml
    
    #we can use multi-threading:
    runner:
      plugin: threaded
      options:
        num_workers: 10
    
    
    hosts.yaml: This file contains your network devices.
    router1:
      hostname: 10.10.10.1
      groups:
        - cisco
    
    router2:
      hostname: 10.10.10.2
      groups:
        - cisco
    
    groups.yaml:Groups help avoid repetition.
    cisco:
      platform: ios
    
    defaults.yaml: Defaults apply to all devices unless overridden.
    username: admin
    password: admin
    
    
    main.py: Show version
    from nornir import InitNornir
    from nornir_scrapli.tasks import send_command
    
    nr = InitNornir(config_file="config.yaml")
    
    result = nr.run(
        task=send_command,
        command="show version"
    )
    
    for host, task_result in result.items():
        print(f"\n{host}")
        print(task_result.result)
    
    
    

    pip install nornir_utils nornir_napalm

    Doing a simple test for example show ip int brief:

    from nornir import InitNornir
    
    from nornir_utils.plugins.functions import print_result
    from nornir_napalm.plugins.tasks import napalm_cli, napalm_configure, napalm_get
    from nornir.core.task import Task
    
    
    nr = InitNornir(
        config_file="config.yaml", dry_run=True
    )
    
    def multiple_task(task: Task):
    
        task.run(
            task=napalm_cli, commands=["show ip int brief"]
        )
    
    results = nr.run(
        task=multiple_task
    )
    
    print_result(results)
    
    
    
    
    output:
    
    multiple_task*******************************************************************
    * router1 ** changed : False ***************************************************
    vvvv multiple_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
    ---- napalm_cli ** changed : False --------------------------------------------- INFO
    { 'show ip int brief': 'Interface                  IP-Address      OK? Method '
                           'Status                Protocol\n'
                           'GigabitEthernet0/0         192.168.199.30  YES manual '
                           'up                    up      \n'
                           'GigabitEthernet0/1         unassigned      YES unset  '
                           'administratively down down    \n'
                           'GigabitEthernet0/2         unassigned      YES unset  '
                           'administratively down down    \n'
                           'GigabitEthernet0/3         unassigned      YES unset  '
                           'administratively down down'}
    ^^^^ END multiple_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    * router2 ** changed : False ***************************************************
    vvvv multiple_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
    ---- napalm_cli ** changed : False --------------------------------------------- INFO
    { 'show ip int brief': 'Interface                  IP-Address      OK? Method '
                           'Status                Protocol\n'
                           'GigabitEthernet0/0         192.168.199.30  YES manual '
                           'up                    up      \n'
                           'GigabitEthernet0/1         unassigned      YES unset  '
                           'administratively down down    \n'
                           'GigabitEthernet0/2         unassigned      YES unset  '
                           'administratively down down    \n'
                           'GigabitEthernet0/3         unassigned      YES unset  '
                           'administratively down down'}
    ^^^^ END multiple_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    
    
    Nornir NAPALM

    NAPALM integrates very well with Nornir and adds a higher level of abstraction for network automation. While Nornir is responsible for orchestration, inventory handling, and running tasks in parallel, NAPALM focuses on interacting with network devices in a safe and vendor-neutral way. With Nornir and NAPALM together, engineers can execute show commands, push configuration changes, take configuration backups, and validate the current device state using a consistent API across different network platforms. This combination helps reduce manual CLI work, lowers the risk of configuration errors, and makes automation workflows easier to scale and maintain.

    If we look at the https://github.com/nornir-automation/nornir_napalm/tree/master/nornir_napalm/plugins/tasks we can see the sub modules. In order to use them we should import them in our code, for example for get:

    from nornir_napalm.plugins.tasks import napalm_get
    

    In this example we are going to get the interface info with napalm_get:

    from nornir_napalm.plugins.tasks import napalm_get
    from nornir_utils.plugins.functions import print_result
    from nornir import InitNornir
    
    nr = InitNornir(config_file='config.yaml')
    
    
    results = nr.run(task=napalm_get, getters=['interfaces'])
    
    print_result(results)
    
    
    output:
    napalm_get**********************************************************************
    * router1 ** changed : False ***************************************************
    vvvv napalm_get ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
    { 'interfaces': { 'GigabitEthernet0/0': { 'description': '',
                                              'is_enabled': True,
                                              'is_up': True,
                                              'last_flapped': -1.0,
                                              'mac_address': '50:00:00:01:00:00',
                                              'mtu': 1500,
                                              'speed': 1000.0},
                      'GigabitEthernet0/1': { 'description': '',
                                              'is_enabled': False,
                                              'is_up': False,
                                              'last_flapped': -1.0,
                                              'mac_address': '50:00:00:01:00:01',
                                              'mtu': 1500,
                                              'speed': 1000.0},
                      'GigabitEthernet0/2': { 'description': '',
                                              'is_enabled': False,
                                              'is_up': False,
                                              'last_flapped': -1.0,
                                              'mac_address': '50:00:00:01:00:02',
                                              'mtu': 1500,
                                              'speed': 1000.0},
                      'GigabitEthernet0/3': { 'description': '',
                                              'is_enabled': False,
                                              'is_up': False,
                                              'last_flapped': -1.0,
                                              'mac_address': '50:00:00:01:00:03',
                                              'mtu': 1500,
                                              'speed': 1000.0}}}
    ^^^^ END napalm_get ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    * router2 ** changed : False ***************************************************
    vvvv napalm_get ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
    { 'interfaces': { 'GigabitEthernet0/0': { 'description': '',
                                              'is_enabled': True,
                                              'is_up': True,
                                              'last_flapped': -1.0,
                                              'mac_address': '50:00:00:01:00:00',
                                              'mtu': 1500,
                                              'speed': 1000.0},
                      'GigabitEthernet0/1': { 'description': '',
                                              'is_enabled': False,
                                              'is_up': False,
                                              'last_flapped': -1.0,
                                              'mac_address': '50:00:00:01:00:01',
                                              'mtu': 1500,
                                              'speed': 1000.0},
                      'GigabitEthernet0/2': { 'description': '',
                                              'is_enabled': False,
                                              'is_up': False,
                                              'last_flapped': -1.0,
                                              'mac_address': '50:00:00:01:00:02',
                                              'mtu': 1500,
                                              'speed': 1000.0},
                      'GigabitEthernet0/3': { 'description': '',
                                              'is_enabled': False,
                                              'is_up': False,
                                              'last_flapped': -1.0,
                                              'mac_address': '50:00:00:01:00:03',
                                              'mtu': 1500,
                                              'speed': 1000.0}}}
    
    

    napalm_configure on Cisco IOS needs SCP (and often enable/privilege), not just SSH.

    conf t
    ip scp server enable
    end
    wr mem
    
    

    In order to send the configuration:

    from nornir import InitNornir
    from nornir_utils.plugins.functions import print_result
    from nornir_napalm.plugins.tasks import napalm_configure
    
    
    nr = InitNornir(config_file='config.yaml')
    
    
    results = nr.run(task=napalm_configure, configuration='interface loo100')
    
    
    print_result(results)
    
    
    
    output:
    napalm_configure****************************************************************
    * router1 ** changed : True ****************************************************
    vvvv napalm_configure ** changed : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
    +interface loo100
    ^^^^ END napalm_configure ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    * router2 ** changed : True ****************************************************
    vvvv napalm_configure ** changed : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
    +interface loo100
    ^^^^ END napalm_configure ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    * router3 ** changed : True ****************************************************
    vvvv napalm_configure ** changed : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
    +interface loo100
    ^^^^ END napalm_configure ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    * router4 ** changed : True ****************************************************
    vvvv napalm_configure ** changed : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
    +interface loo100
    
    

    We can also send the configs with a config file:

    
    def napalm_configure(
        task: Task,
        dry_run: Optional[bool] = None,
        filename: Optional[str] = None,
        configuration: Optional[str] = None,
        replace: bool = False,
        commit_message: Optional[str] = None,
        revert_in: Optional[int] = None,
    

    Now we know how to read the data from the router, how we can write them into a file? we can use context manager but there is a function for that in nornir library:

    from nornir import InitNornir
    from nornir_napalm.plugins.tasks import napalm_get
    from nornir_utils.plugins.functions import print_result
    from nornir_utils.plugins.tasks.files import write_file
    
    from nornir import InitNornir
    
    nr = InitNornir(config_file='config.yaml')
    
    
    
    def backup(task):
        get_config = task.run(task=napalm_get, getters=['config'])
        get_running = get_config.result['config']['running']
        task.run(task=write_file, content=get_running, filename='running.txt')
    
    result = nr.run(task=backup)
    
    

    To replace the config, at first we should enable the archive on the router:

    archive
    path flash:archive
    write-memory
    
    from nornir import InitNornir
    from nornir_napalm.plugins.tasks import napalm_configure
    from nornir_utils.plugins.functions import print_result
    
    
    nr = InitNornir(config_file='config.yaml', dry_run=False)
    
    
    results = nr.run(task=napalm_configure, filename='running.txt', replace=True)
    
    print_result(results)
    
    
    
    Nornir Scarpli

    NAPALM and Scrapli solve different but complementary problems in network automation. NAPALM provides a high-level, vendor-neutral abstraction that focuses on intent, consistency, and safety. It is ideal for tasks like configuration backups, state validation, and controlled configuration changes with rollback support. Scrapli, on the other hand, operates at a lower level and focuses on direct device communication. It offers full control over CLI interactions, high performance, and asynchronous execution, making it well suited for large-scale data collection, legacy devices, or environments with inconsistent SSH behavior.

    To install nornir scapli:

    pip install nornir_scrapli
    

    If we want to send the commands to the router(s):

    from nornir import InitNornir
    from nornir_utils.plugins.functions import print_result
    from nornir_scrapli.tasks import send_config, send_configs, send_configs_from_file
    
    nr = InitNornir(config_file="config.yaml")
    
    configs = ['router os 1', [router-id 1.1.1.1]]
    
    # Codeium: Refactor | Explain | Generate Docstring
    def cfg_sender(task):
        result = task.run(task=send_config, config=configs)
    
    result = nr.run(task=cfg_sender)
    print_result(result)
    
    
    # we can also use config file:
    # Codeium: Refactor | Explain | Generate Docstring
    def cfg_sender(task):
        result = task.run(task=send_config_from_file, config='config.txt')
    

    If we want to use Jinja2 template at first install it with pip install nornir_jinja2 then we need to import template_file :

    from nornir_jinja2.plugins.tasks import template_file

    About the data we should have yaml file which contains the data and we should edit the config.yaml file in order to point the data yaml file for host file. So that means the data should be in host file to be able to rendered.

    dry_run=True means show me what would change, but do not actually change anything on the devices. Actually dry_run lets you verify your automation before touching production and keep in mind It only prevents execution.

    from nornir import InitNornir
    from nornir_scrapli.tasks import send_configs
    from nornir_utils.plugins.functions import print_result
    from nornir_jinja2.plugins.tasks import template_file
    
    nr = InitNornir(config_file="config.yaml", dry_run=True)
    
    # Codeium: Refactor | Explain | Generate Docstring
    def run_template(task):
        template = task.run(
            task=template_file,
            template="interface.j2",
            path=""
        ).result
    
        task.run(task=send_configs, configs=template.splitlines())
    
    results = nr.run(task=run_template)
    print_result(results)
    
    
  • Scrapli is a modern Python library for network automation that provides fast, flexible, and reliable communication with network devices. It is designed with a clean API, supports both synchronous and asynchronous workflows, and offers built-in parsing, logging, and interactive command handling.

    # Basic connection
    from scrapli import Scrapli
    
    device = {
        "host": "10.10.20.48",
        "auth_username": "developer",
        "auth_password": "Cisco12345",
        "auth_strict_key": False,
        "platform": "cisco_iosxe",
    }
    
    conn = Scrapli(**device)
    conn.open()
    print(conn.get_prompt())
    
    
    with Scrapli(**device) as conn:
        print(conn.get_prompt())
    
    
    

    Scrapli allows us to send single commands or multiple commands to a network device and collect the output in a structured way. For multiple command we should use list.

    Single Command:
    
    sh_ver = conn.send_command("show version")
    print(sh_ver.result)
    
    
    output:
    Cisco IOS XE Software, Version 17.9.3
    ...
    
    
    Multiple commands:
    
    result = conn.send_commands(["show version", "show run"])
    print(result.result)
    
    output:
    Cisco IOS XE Software, Version 17.9.3
    ...
    !
    Current configuration : 4567 bytes
    ...
    
    
    this is other way with using context manager:
    with Scrapli(**device) as conn:
        result = conn.send_command("show run")
        print(result.result)
    
    

    Parsing converts raw CLI text into structured Python data (dicts / lists) that automation scripts can easily work with.

    Scrapli supports Genie and TextFSM parsing.

    response = conn.send_command("show version")
    print(response.genie_parse_output())
    
    
    Output:
    Returns a nested dictionary with detailed information
    {
        "version": {
            "version": "17.9.3",
            "hostname": "Router",
            "uptime": "2 weeks, 3 days"
        },
        "platform": {
            "chassis": "C9300-48P",
            "os": "IOS-XE"
        }
    }
    
    
    
    response = conn.send_command("show version")
    print(response.textfsm_parse_output())
    
    
    Output:
    Returns a list of dictionaries
    
    [
        {
            "hostname": "Router",
            "version": "17.9.3",
            "uptime": "2 weeks, 3 days"
        }
    ]
    
    

    send_interactive() allows Scrapli to automate CLI commands that require user interaction by matching prompts and responding automatically. It takes a list of interaction steps, where each step is a tuple:

    with IOSXEDriver(**device) as conn:
        interactive = conn.send_interactive(
            [
                ("copy run start", "Destination filename [startup-config]?", "\n"),
            ]
        )
    
    print(interactive.result)
    
    

    Logging helps you see what Scrapli is doing internally, which is critical for debugging SSH connections, authentication issues, and command execution problems.

    import logging
    from scrapli.driver.core import IOSXEDriver
    
    logging.basicConfig(
        filename="scrapli.log",
        level=logging.DEBUG
    )
    
    device = {
        "host": "10.10.20.47",
        "auth_username": "developer",
        "auth_password": "Cisco12345",
        "auth_strict_key": False,
    }
    
    conn = IOSXEDriver(**device)
    conn.open()
    print(conn.get_prompt())
    print(conn.send_command("show run | i hostname").result)
    
    
    
    or using Scrapli logging:
    
    from scrapli import Scrapli
    from scrapli.logging import enable_basic_logging
    
    enable_basic_logging(file=True, level="debug")
    
    device = {
        "host": "10.10.20.48",
        "auth_username": "developer",
        "auth_password": "Cisco12345",
        "auth_strict_key": False,
        "platform": "cisco_iosxe",
    }
    
    with Scrapli(**device) as conn:
        print(conn.get_prompt())
        print(conn.send_command("show run | i hostname").result)
    
    

    We can send the config from a configuration file to the device instead of the commands:

    In Send command from file it does it in Exec mode but in Send config from file it does it from configuration mode.

    config.txt >
    
    interface loopback50
     ip address 50.50.50.50 255.255.255.255
     description CONFIG_FROM_FILE
    
    Code>
    
    from scrapli import Scrapli
    
    with open("config.txt") as f:
        config_lines = [line.strip() for line in f if line.strip()]
    
    with Scrapli(**device) as conn:
        response = conn.send_configs(config_lines)
        print(response.result)
    
    
    
    Asynchronous Programming

    Asynchronous programming is a programming style that allows code to run without waiting for long tasks to finish.

    Instead of blocking execution (waiting for one task to complete), the program can start other tasks while waiting, making it much faster and more efficient.

    import asyncio
    
    async def first_function():
        print("Start of first function")
        await asyncio.sleep(2)  # Simulating a 2-second delay
        print("End of first function")
    
    async def second_function():
        print("Start of second function")
        await asyncio.sleep(1)  # Simulating a 1-second delay
        print("End of second function")
    
    async def main():
        task1 = asyncio.create_task(first_function())
        task2 = asyncio.create_task(second_function())
        await asyncio.gather(task1, task2)
    
    asyncio.run(main())
    
    Output:
    Start of first function
    Start of second function
    End of second function
    End of first function
    
    
    

    Multithreading is another way to run tasks concurrently, but it works very differently from asynchronous programming. In multithreading Each task runs in its own thread, The operating system switches between threads and also it is more resource like CPU and memory consuming.

  • If you work with network devices (Cisco, Juniper, Aruba, Fortinet, etc.) and you want simple, reliable SSH automation, Netmiko is one of the best Python libraries to start with.

    To install it use pip install netmiko.

    Netmiko uses a device dictionary and ConnectHandler() to create an SSH connection.

    We should specify the device type in this example cisco_ios, then IP or Host, then user/pass, if we have enable password we should provide it too. Then we send the deice dictionary to ConnectHndler function, the ** before device means we send each key:value separately to the function and the function treat them separately. At the end we should disconnect the connection, otherwise it will stay open, but with context manager we don’t need to disconnect the connection.

    from netmiko import ConnectHandler
    
    device = {
        "device_type": "cisco_ios",
        "host": "10.10.10.1",
        "username": "admin",
        "password": "Cisco123!",
        "secret": "Enable123!",   # enable password (optional, but common)
    }
    
    conn = ConnectHandler(**device)
    print(conn.find_prompt())
    conn.disconnect()
    
    # Using the context manager to disconnect automatically
    with ConnectHandler(**device) as net_connect:
        print(net_connect.find_prompt())
    
    

    Some devices need enable mode to run privileged commands or configuration.

    from netmiko import ConnectHandler
    
    conn = ConnectHandler(
        device_type="cisco_ios",
        host="10.10.10.1",
        username="admin",
        password="Cisco123!",
        secret="Enable123!"
    )
    
    conn.enable()
    output = conn.send_command("show ip int brief")
    print(output)
    
    conn.exit_enable()
    conn.disconnect()
    
    

    Use send_command() for sending command to the device.

    output = conn.send_command("show version")
    print(output)
    

    The easiest way to push multiple config lines is to use a list: We need here to use send_config_set().

    cfg = [
        "interface loopback22",
        "ip address 22.22.22.22 255.255.255.255",
        "description CREATED_BY_NETMIKO",
    ]
    
    output = conn.send_config_set(cfg)
    print(output)
    
    output>>
    config term
    Enter configuration commands, one per line.  End with CNTL/Z.
    Router(config)#interface loopback22
    Router(config-if)#ip address 22.22.22.22 255.255.255.255
    Router(config-if)#description CREATED_BY_NETMIKO
    Router(config-if)#end
    Router#
    
    
    

    We can do show running-config and then save it into a txt file:

    
    running = conn.send_command("show running-config")
    outfile = OUT_DIR / f"{ip}_running-config.txt"
    outfile.write_text(running, encoding="utf-8")
    

    Netmiko can parse many commands using TextFSM templates (NTC Templates).

    from netmiko import ConnectHandler
    from pprint import pprint
    
    conn = ConnectHandler(
        device_type="cisco_ios",
        host="10.10.10.1",
        username="admin",
        password="Cisco123!"
    )
    
    data = conn.send_command("show ip int brief", use_textfsm=True)
    pprint(data)
    
    conn.disconnect()
    
    
    Output:
    [
        {
            'interface': 'GigabitEthernet0/0',
            'ip_address': '10.10.10.1',
            'status': 'up',
            'protocol': 'up'
        },
        {
            'interface': 'GigabitEthernet0/1',
            'ip_address': 'unassigned',
            'status': 'administratively down',
            'protocol': 'down'
        },
        {
            'interface': 'Loopback0',
            'ip_address': '1.1.1.1',
            'status': 'up',
            'protocol': 'up'
        }
    ]
    
    

    delay_factor tells Netmiko to wait longer before reading the device’s response.

    output = net_connect.send_command_timing("copy running-config startup-config")
    
    if "Destination filename" in output:
        output += net_connect.send_command_timing("\n", delay_factor=2)
    
    

    We can run the same function on the same device at the same time.

    import concurrent.futures
    
    with concurrent.futures.ThreadPoolExecutor() as executor:  #Automatically starts the thread pool and cleans up threads when finished
        executor.map(execute_command, (R1, R2, R3)) #Calls the commands in Parallel
    
    

    We can pull the config from a file:

    output = connection.send_config_from_file('config.txt')
    print(output)
    
    Text file:
    interface loopback33
     ip address 33.33.33.33 255.255.255.255
     description CONFIG_FROM_FILE
    
    output:
    config term
    Enter configuration commands, one per line.  End with CNTL/Z.
    Router(config)#interface loopback33
    Router(config-if)#ip address 33.33.33.33 255.255.255.255
    Router(config-if)#description CONFIG_FROM_FILE
    Router(config-if)#end
    Router#
    
    

    We can save the log to a file, Using session_log in Netmiko saves the entire SSH interaction to a file, making debugging and auditing much easier.

    device = {
        'device_type': 'cisco_ios',
        'ip': '192.168.31.99',
        'username': 'admin',
        'password': 'cisco',
        'secret': 'cisco',
        'session_log': 'my_session.txt',  # Save session logs into a file
    }
    
    my_session.txt
    
    Router#
    Router#show ip interface brief
    Interface              IP-Address      OK? Method Status                Protocol
    GigabitEthernet0/0     192.168.31.99    YES manual up                    up
    Router#configure terminal
    Router(config)#interface loopback10
    Router(config-if)#description TEST
    Router(config-if)#end
    Router#
    
    

    The Python logging module captures Netmiko’s internal debug information, making it ideal for troubleshooting and production automation:

    Logs everything, including: DEBUG, INFO, WARNING, ERROR, CRITICAL

    import logging
    
    logging.basicConfig(filename='test.log', level=logging.DEBUG)
    logger = logging.getLogger("netmiko")
    
    
    text.log file:
    
    DEBUG:netmiko:Establishing SSH connection to 192.168.31.99
    DEBUG:netmiko:Authentication successful
    DEBUG:netmiko:Sending command: show ip interface brief
    DEBUG:netmiko:Received output (length=312)
    
    
  • Cisco Network Services Orchestrator is a Linux application which orchestrates the configuration life cycle of network devices. NSO uses software packages called Network Element Drivers (NEDs) to facilitate telnet, SSH, or API interactions with the devices that it manages. The NED provides an abstraction layer that reads in the device’s running configuration and parses it into a data-model-validated snapshot in the CDB.

    NSO is maybe the best tool for automating the controller-less networks.

    If we want to configure other vendors we need to install the driver of them in NSO.

    Here is a GUI of NSO which I reseved it from cisco sandbox. At first I select all the devices and sync config from the devices so that NSO has the config of them.

    All the configuration can be made through the CLI too.

    In order to see the the config of the device we can just select the device from the the menu.

    As an example I choose dist-rtr1 and I changed description and add secondary ip on one of the interfaces.

    In tools section there is a Commit manager which we can see our changes ,commit or revert them.

    We can load committed configuration and then edit or delete it. This can be selective that means only for some devices not all of them.

  • In this example I want to get the configuration of the routers with rest API in host file and save the config files in a folder.

    this line of the code: “outfile = Path(OUT_DIR) / f”{ip}_running-config.txt”” creates the file name using the router IP. And with “outfile.write_text(r.text, encoding=”utf-8″)” we are writing the value of the r.text into the file.

    hosts.yaml
    
    hosts:
      - 10.10.10.1
      - 10.10.11.1
      - 10.10.12.1
      - 10.10.13.1
      - 10.10.14.1
    
    
    #Backup_running_config.py
    
    import getpass
    from pathlib import Path
    
    import requests
    import yaml
    
    HOSTS_FILE = "hosts.yaml"
    OUT_DIR = "backups"
    
    # Cisco IOS XE REST API endpoint for running config (text/plain)
    RUNNING_CFG_PATH = "/api/v1/global/running-config"
    
    # If your routers use self-signed certs, set to False
    VERIFY_SSL = False
    
    
    with open(HOSTS_FILE, "r", encoding="utf-8") as f:
        hosts = yaml.safe_load(f)["hosts"]
    
    username = input("Username: ").strip()
    password = getpass.getpass("Password: ")
    
    Path(OUT_DIR).mkdir(exist_ok=True)
    
    if not VERIFY_SSL:
        requests.packages.urllib3.disable_warnings()
    
    for ip in hosts:
        try:
            url = f"https://{ip}{RUNNING_CFG_PATH}"
            r = requests.get(
                url,
                auth=(username, password),
                headers={"Accept": "text/plain"},
                verify=VERIFY_SSL,
                timeout=20,
            )
    
            if r.status_code == 200:
                outfile = Path(OUT_DIR) / f"{ip}_running-config.txt"
                outfile.write_text(r.text, encoding="utf-8")
                print(f"✅ Backed up {ip} -> {outfile}")
            else:
                print(f"❌ {ip} failed ({r.status_code}): {r.text}")
    
        except Exception as e:
            print(f"❌ {ip} error: {e}")
    
    
    
    
  • If we want to automate a network with many devices we have normally 4 files a python code, host files, config file and data file.

    In this example I want to automate BGP configuration of routers. So I prepare these 4 files. The first file is Host file which contains the IP of the devices, the second file is BGP template file that is a jinja2 and as you can see I am using for loop in this file. In bgp.yaml file we have the data which will be rendered into the jinja2 template and then pushes to the devices, and this is the python script job.

    The sample config on one router:
    Router bgp 100
     neighbor 192.168.10.1
     neighbor 192.168.20.1
     neighbor 192.168.30.1
    
    #hosts.yaml host file
    
    hosts:
      - 10.10.10.1
      - 10.10.11.1
      - 10.10.12.1
      - 10.10.13.1
      - 10.10.14.1
    
    
    #BGP_Template.j2 file
    
    router bgp {{ bgp.asn }}
    {% for n in bgp.neighbors %}
     neighbor {{ n.ip }}
    {% endfor %}
    
    
    #bgp.yaml file:
    
    bgp:
      asn: 100
      neighbors:
        - ip: 192.168.10.1
        - ip: 192.168.20.1
        - ip: 192.168.30.1
    
    

    Lets look at the python code. It loads the Host and bgp yaml file and also the jinja2 template file. Then it renders the template with the data. so we have the config file now. With help of the netmiko library we can push the prepared config to the routers.

    import yaml
    import getpass
    from jinja2 import Template
    from netmiko import ConnectHandler
    
    # files
    HOSTS_FILE = "hosts.yaml"
    DATA_FILE = "bgp.yaml"
    TEMPLATE_FILE = "BGP_Template.j2"
    
    DEVICE_TYPE = "cisco_ios"   # change if needed
    
    
    # read hosts
    with open(HOSTS_FILE, "r") as f:
        hosts = yaml.safe_load(f)["hosts"]
    
    # read data
    with open(DATA_FILE, "r") as f:
        data = yaml.safe_load(f)
    
    # read template
    with open(TEMPLATE_FILE, "r") as f:
        template_text = f.read()
    
    # render config
    config_text = Template(template_text).render(**data)
    
    print("\n=== Rendered Config ===")
    print(config_text)
    
    # credentials
    username = input("Username: ").strip()
    password = getpass.getpass("Password: ")
    
    # push to routers
    for ip in hosts:
        try:
            conn = ConnectHandler(
                device_type=DEVICE_TYPE,
                host=ip,
                username=username,
                password=password
            )
    
            commands = [line for line in config_text.splitlines() if line.strip()]
            conn.send_config_set(commands)
            conn.save_config()
            conn.disconnect()
    
            print(f" Done: {ip}")
    
    
  • For cisco Switches there is another API framework called NX-API which we can manage them with.

    I am using a test device in cisco sandbox. at first we need to enable the nx-api feature on the device. Unlike postman, here we have only one URL which is /ins and also we use only POST.

    If I choose cli method and writ the cisco cli command It will make the request json rpc code and I also can send them to the device and see what is the result. As an example I used show interface status here:

    like postman we can have the python code for action:

    import requests
    import json
    
    """
    Modify these please
    """
    #For NXAPI to authenticate the client using client certificate, set 'client_cert_auth' to True.
    #For basic authentication using username & pwd, set 'client_cert_auth' to False.
    client_cert_auth=False
    switchuser='USERID'
    switchpassword='PASSWORD'
    client_cert='PATH_TO_CLIENT_CERT_FILE'
    client_private_key='PATH_TO_CLIENT_PRIVATE_KEY_FILE'
    ca_cert='PATH_TO_CA_CERT_THAT_SIGNED_NXAPI_SERVER_CERT'
    
    url='http://10.10.20.40/ins'
    myheaders={'content-type':'application/json-rpc'}
    payload=[
      {
        "jsonrpc": "2.0",
        "method": "cli",
        "params": {
          "cmd": "show interface status",
          "version": 1
        },
        "id": 1,
        "rollback": "rollback-on-error"
      }
    ]
    
    if client_cert_auth is False:
        response = requests.post(url,data=json.dumps(payload), headers=myheaders,auth=(switchuser,switchpassword)).json()
    else:
        url='https://10.10.20.40/ins'
        response = requests.post(url,data=json.dumps(payload), headers=myheaders,auth=(switchuser,switchpassword),cert=(client_cert,client_private_key),verify=ca_cert).json()
    

    we can also use different data formating like xml. As you know cisco nexux switches have linux bash which we can use ns-api sandbox to sent command to it.

    we can use the sanbox to have the jason, xml format of our requests and use them in postman.

  • I want to get the interface Gig2 with RESTCONF using Curl. -v is verbose so we can see the logs.-k is for certificate -H is for header and with +json we define the format of the data.

    Restconf is also enabled on the router.

    curl.exe -v -k -u developer:C1sco12345 -H "Accept: application/yang-data+json" "https://10.10.20.48/restconf/data/ietf-interfaces:interfaces/interface=GigabitEthernet2"
    
    
    Result:
    
    *   Trying 10.10.20.48:443...
    * schannel: disabled automatic use of client certificate
    * schannel: using IP address, SNI is not supported by OS.
    * ALPN: curl offers http/1.1
    * ALPN: server accepted http/1.1
    * Established connection to 10.10.20.48 (10.10.20.48 port 443) from 192.168.254.11 port 54031
    * using HTTP/1.x
    * Server auth using Basic with user 'developer'
    > GET /restconf/data/ietf-interfaces:interfaces/interface=GigabitEthernet2 HTTP/1.1
    > Host: 10.10.20.48
    > Authorization: Basic ZGV2ZWxvcGVyOkMxc2NvMTIzNDU=
    > User-Agent: curl/8.16.0
    > Accept: application/yang-data+json
    >
    * Request completely sent off
    * schannel: remote party requests renegotiation
    * schannel: renegotiating SSL/TLS connection
    * schannel: SSL/TLS connection renegotiated
    * schannel: remote party requests renegotiation
    * schannel: renegotiating SSL/TLS connection
    * schannel: SSL/TLS connection renegotiated
    < HTTP/1.1 200 OK
    < Server: openresty
    < Date: Sat, 31 Jan 2026 12:50:10 GMT
    < Content-Type: application/yang-data+json
    < Transfer-Encoding: chunked
    < Connection: keep-alive
    < Cache-Control: private, no-cache, must-revalidate, proxy-revalidate
    < Pragma: no-cache
    < Content-Security-Policy: default-src 'self'; block-all-mixed-content; base-uri 'self'; frame-ancestors 'none';
    < Strict-Transport-Security: max-age=15552000; includeSubDomains
    < X-Content-Type-Options: nosniff
    < X-Frame-Options: DENY
    < X-XSS-Protection: 1; mode=block
    <
    {
      "ietf-interfaces:interface": [
        {
          "name": "GigabitEthernet2",
          "description": "Network Interface",
          "type": "iana-if-type:ethernetCsmacd",
          "enabled": true,
          "ietf-ip:ipv4": {
            "address": [
              {
                "ip": "1.1.1.1",
                "netmask": "255.255.255.0"
              },
              {
                "ip": "2.2.2.2",
                "netmask": "255.255.255.0"
              }
            ]
          },
          "ietf-ip:ipv6": {
          }
        }
      ]
    }
    * Connection #0 to host 10.10.20.48:443 left intact
    
    

    Using postman we can do the same thing easy. We just need to define the Authentication, Heders and payload. I also disabled the ssl-certification verification in setting.

    In postman we can also generate the code for example for python!

    In this code we can see it is using request library.

    To create a new loopback we use POST with the json data format in body.

    It is possible to use environmental variable in post man. We need to add an environment at first with defined key, values. Then we can use them in body.