Модуль pexpect

Модуль pexpect позволяет автоматизировать интерактивные подключения, такие как:

  • telnet
  • ssh
  • ftp

Примечание

Pexpect - это реализация expect на Python.

Для начала, модуль pexpect нужно установить:

pip install pexpect

Логика работы pexpect такая:

  • запускается какая-то программа
  • pexpect ожидает определенный вывод (приглашение, запрос пароля и подобное)
  • получив вывод, он отправляет команды/данные
  • последние два действия повторяются столько, сколько нужно

При этом сам pexpect не реализует различные утилиты, а использует уже готовые.

pexpect.spawn

Класс spawn позволяет взаимодействовать с вызванной программой, отправляя данные и ожидая ответ.

Например, таким образом можно инициировать соединение SSH:

In [5]: ssh = pexpect.spawn('ssh cisco@192.168.100.1')

После выполнения этой строки, подключение готово. Теперь необходимо указать какую строку ожидать. В данном случае, надо дождаться запроса о пароле:

In [6]: ssh.expect('[Pp]assword')
Out[6]: 0

Обратите внимание как описана строка, которую ожидает pexpect: [Pp]assword. Это регулярное выражение, которое описывает строку password или Password. То есть, методу expect можно передавать регулярное выражение как аргумент.

Метод expect вернул число 0 в результате работы. Это число указывает, что совпадение было найдено и что это элемент с индексом ноль. Индекс тут фигурирует из-за того, что expect можно передавать список строк. Например, можно передать список с двумя элементами:

In [7]: ssh = pexpect.spawn('ssh cisco@192.168.100.1')

In [8]: ssh.expect(['password', 'Password'])
Out[8]: 1

Обратите внимание, что теперь возвращается 1. Это значит, что совпадением было слово Password.

Теперь можно отправлять пароль. Для этого используется команда sendline:

In [9]: ssh.sendline('cisco')
Out[9]: 6

Команда sendline отправляет строку, автоматически добавляет к ней перевод строки на основе значения os.linesep, а затем возвращает число указывающее сколько байт было записано.

Примечание

В pexpect есть несколько вариантов отправки команд, не только sendline.

Для того чтобы попасть в режим enable цикл expect-sendline повторяется:

In [10]: ssh.expect('[>#]')
Out[10]: 0

In [11]: ssh.sendline('enable')
Out[11]: 7

In [12]: ssh.expect('[Pp]assword')
Out[12]: 0

In [13]: ssh.sendline('cisco')
Out[13]: 6

In [14]: ssh.expect('[>#]')
Out[14]: 0

Теперь можно отправлять команду:

In [15]: ssh.sendline('sh ip int br')
Out[15]: 13

После отправки команды, pexpect надо указать до какого момента считать вывод. Указываем, что считать надо до #:

In [16]: ssh.expect('#')
Out[16]: 0

Вывод команды находится в атрибуте before:

In [17]: ssh.before
Out[17]: b'sh ip int br\r\nInterface                  IP-Address      OK? Method Status                Protocol\r\nEthernet0/0                192.168.100.1   YES NVRAM  up                    up      \r\nEthernet0/1                192.168.200.1   YES NVRAM  up                    up      \r\nEthernet0/2                19.1.1.1        YES NVRAM  up                    up      \r\nEthernet0/3                192.168.230.1   YES NVRAM  up                    up      \r\nEthernet0/3.100            10.100.0.1      YES NVRAM  up                    up      \r\nEthernet0/3.200            10.200.0.1      YES NVRAM  up                    up      \r\nEthernet0/3.300            10.30.0.1       YES NVRAM  up                    up      \r\nR1'

Так как результат выводится в виде последовательности байтов, надо конвертировать ее в строку:

In [18]: show_output = ssh.before.decode('utf-8')

In [19]: print(show_output)
sh ip int br
Interface                  IP-Address      OK? Method Status                Protocol
Ethernet0/0                192.168.100.1   YES NVRAM  up                    up
Ethernet0/1                192.168.200.1   YES NVRAM  up                    up
Ethernet0/2                19.1.1.1        YES NVRAM  up                    up
Ethernet0/3                192.168.230.1   YES NVRAM  up                    up
Ethernet0/3.100            10.100.0.1      YES NVRAM  up                    up
Ethernet0/3.200            10.200.0.1      YES NVRAM  up                    up
Ethernet0/3.300            10.30.0.1       YES NVRAM  up                    up
R1

Завершается сессия вызовом метода close:

In [20]: ssh.close()

Специальные символы в shell

Pexpect не интерпретирует специальные символы shell, такие как >, |, *.

Для того, чтобы, например, команда ls -ls | grep SUMMARY отработала, нужно запустить shell таким образом:

In [1]: import pexpect

In [2]: p = pexpect.spawn('/bin/bash -c "ls -ls | grep pexpect"')

In [3]: p.expect(pexpect.EOF)
Out[3]: 0

In [4]: print(p.before)
b'4 -rw-r--r-- 1 vagrant vagrant 3203 Jul 14 07:15 1_pexpect.py\r\n'

In [5]: print(p.before.decode('utf-8'))
4 -rw-r--r-- 1 vagrant vagrant 3203 Jul 14 07:15 1_pexpect.py

pexpect.EOF

В предыдущем примере встретилось использование pexpect.EOF.

Примечание

EOF (end of file) — конец файла

Это специальное значение, которое позволяет отреагировать на завершение исполнения команды или сессии, которая была запущена в spawn.

При вызове команды ls -ls pexpect не получает интерактивный сеанс. Команда выполняется и всё, на этом завершается её работа.

Поэтому если запустить её и указать в expect приглашение, возникнет ошибка:

In [5]: p = pexpect.spawn('/bin/bash -c "ls -ls | grep SUMMARY"')

In [6]: p.expect('nattaur')
---------------------------------------------------------------------------
EOF                                       Traceback (most recent call last)
<ipython-input-9-9c71777698c2> in <module>()
----> 1 p.expect('nattaur')
...

Если передать в expect EOF, ошибки не будет.

Метод pexpect.expect

В pexpect.expect как шаблон может использоваться:

  • регулярное выражение
  • EOF - этот шаблон позволяет среагировать на исключение EOF
  • TIMEOUT - исключение timeout (по умолчанию значение timeout = 30 секунд)
  • compiled re

Еще одна очень полезная возможность pexpect.expect: можно передавать не одно значение, а список.

Например:

In [7]: p = pexpect.spawn('/bin/bash -c "ls -ls | grep netmiko"')

In [8]: p.expect(['py3_convert', pexpect.TIMEOUT, pexpect.EOF])
Out[8]: 2

Тут несколько важных моментов:

  • когда pexpect.expect вызывается со списком, можно указывать разные ожидаемые строки
  • кроме строк, можно указывать исключения
  • pexpect.expect возвращает номер элемента списка, который сработал
    • в данном случае номер 2, так как исключение EOF находится в списке под номером два
  • за счет такого формата можно делать ответвления в программе, в зависимости от того, с каким элементом было совпадение

Пример использования pexpect

Пример использования pexpect для подключения к оборудованию и передачи команды show (файл 1_pexpect.py):

import pexpect
import re
from pprint import pprint


def send_show_command(ip, username, password, enable, commands, prompt="#"):
    with pexpect.spawn(f"ssh {username}@{ip}", timeout=10, encoding="utf-8") as ssh:
        ssh.expect("[Pp]assword")
        ssh.sendline(password)
        enable_status = ssh.expect([">", "#"])
        if enable_status == 0:
            ssh.sendline("enable")
            ssh.expect("[Pp]assword")
            ssh.sendline(enable)
            ssh.expect(prompt)

        ssh.sendline("terminal length 0")
        ssh.expect(prompt)

        result = {}
        for command in commands:
            ssh.sendline(command)
            match = ssh.expect([prompt, pexpect.TIMEOUT, pexpect.EOF])
            if match == 1:
                print(
                    f"Символ {prompt} не найден в выводе. Полученный вывод записан в словарь"
                )
            if match == 2:
                print("Соединение разорвано со стороны сервера")
                return result
            else:
                output = ssh.before
                result[command] = output.replace("\r\n", "\n")
        return result


if __name__ == "__main__":
    devices = ["192.168.100.1", "192.168.100.2", "192.168.100.3"]
    commands = ["sh clock", "sh int desc"]
    for ip in devices:
        result = send_show_command(ip, "cisco", "cisco", "cisco", commands)
        pprint(result, width=120)

Эта часть функции отвечает за переход в режим enable:

enable_status = ssh.expect([">", "#"])
if enable_status == 0:
    ssh.sendline("enable")
    ssh.expect("[Pp]assword")
    ssh.sendline(enable)
    ssh.expect(prompt)

Если ssh.expect([">", "#"]) возвращает индекс 0, значит при подключении не было автоматического перехода в режим enable и его надо выполнить. Если возвращается индекс 1 - значит мы уже находимся в режиме enable, например, потому что на оборудовании настроено privilege 15.

Еще один интересный момент в функции:

for command in commands:
    ssh.sendline(command)
    match = ssh.expect([prompt, pexpect.TIMEOUT, pexpect.EOF])
    if match == 1:
        print(
            f"Символ {prompt} не найден в выводе. Полученный вывод записан в словарь"
        )
    if match == 2:
        print("Соединение разорвано со стороны сервера")
        return result
    else:
        output = ssh.before
        result[command] = output.replace("\r\n", "\n")
return result

Тут по очереди отправляются команды и expect ждет три варианта: приглашение, таймаут или EOF. Если метод expect не дождался #, будет возвращено значение 1 и в этом случае выводится сообщение, что символ не найден. При этом, и когда совпадение найдено и когда был таймаут, полученный вывод записывается в словарь. Таким образом можно увидеть, что было получено с устройства, даже если приглашение не найдено.

Вывод при запуске скрипта:

{'sh clock': 'sh clock\n*13:13:47.525 UTC Sun Jul 19 2020\n',
 'sh int desc': 'sh int desc\n'
                'Interface                      Status         Protocol Description\n'
                'Et0/0                          up             up       \n'
                'Et0/1                          up             up       \n'
                'Et0/2                          up             up       \n'
                'Et0/3                          up             up       \n'
                'Lo22                           up             up       \n'
                'Lo33                           up             up       \n'
                'Lo45                           up             up       \n'
                'Lo55                           up             up       \n'}
{'sh clock': 'sh clock\n*13:13:50.450 UTC Sun Jul 19 2020\n',
 'sh int desc': 'sh int desc\n'
                'Interface                      Status         Protocol Description\n'
                'Et0/0                          up             up       \n'
                'Et0/1                          up             up       \n'
                'Et0/2                          admin down     down     \n'
                'Et0/3                          admin down     down     \n'
                'Lo0                            up             up       \n'
                'Lo9                            up             up       \n'
                'Lo19                           up             up       \n'
                'Lo33                           up             up       \n'
                'Lo100                          up             up       \n'}
{'sh clock': 'sh clock\n*13:13:53.360 UTC Sun Jul 19 2020\n',
 'sh int desc': 'sh int desc\n'
                'Interface                      Status         Protocol Description\n'
                'Et0/0                          up             up       \n'
                'Et0/1                          up             up       \n'
                'Et0/2                          admin down     down     \n'
                'Et0/3                          admin down     down     \n'
                'Lo33                           up             up       \n'}

Работа с pexpect без отключения постраничного вывода команд

Иногда вывод команды сильно большой и его не получается полностью считать или оборудование не дает возможность отключить постраничный вывод. В этом случае необходим немного другой подход.

Примечание

Эта же задача будет повторяться и для других модулей этого раздела.

Пример использования pexpect для работы с постраничным выводом команд show (файл 1_pexpect_more.py):

import pexpect
import re
from pprint import pprint


def send_show_command(ip, username, password, enable, command, prompt="#"):
    with pexpect.spawn(f"ssh {username}@{ip}", timeout=10, encoding="utf-8") as ssh:
        ssh.expect("[Pp]assword")
        ssh.sendline(password)
        enable_status = ssh.expect([">", "#"])
        if enable_status == 0:
            ssh.sendline("enable")
            ssh.expect("[Pp]assword")
            ssh.sendline(enable)
            ssh.expect(prompt)

        ssh.sendline(command)
        output = ""

        while True:
            match = ssh.expect([prompt, "--More--", pexpect.TIMEOUT])
            page = ssh.before.replace("\r\n", "\n")
            page = re.sub(" +\x08+ +\x08+", "\n", page)
            output += page
            if match == 0:
                break
            elif match == 1:
                ssh.send(" ")
            else:
                print("Ошибка: timeout")
                break
        output = re.sub("\n +\n", "\n", output)
        return output


if __name__ == "__main__":
    devices = ["192.168.100.1", "192.168.100.2", "192.168.100.3"]
    for ip in devices:
        result = send_show_command(ip, "cisco", "cisco", "cisco", "sh run")
        with open(f"{ip}_result.txt", "w") as f:
            f.write(result)

Теперь после отправки команды, метод expect ждет еще один вариант --More-- - признак, что дальше идет еще одна страница. Так как заранее не известно сколько именно страниц будет в выводе, чтение выполняется в цикле while True. Цикл прерывается если встретилось приглашение # или в течение 10 секунд не появилось приглашение или --More--.

Если встретилось --More--, страницы еще не закончились и надо пролистнуть следующую. В Cisco для этого надо нажать пробел (без перевода строки). Поэтому тут используется метод send, а не sendline - sendline автоматически добавляет перевод строки.

Эта строка page = re.sub(" +\x08+ +\x08+", "\n", page) удаляет backspace символы, которые находятся вокруг --More-- чтобы они не попали в итоговый вывод.