Fluent Python: with statement

Небольшая заметка о конструкции with и о том как создавать свои менеджеры контекста.

Продолжаю читать Fluent Python и дошла до раздела про менеджеры контекстов.

Конструкция with

Конструкция with используется для оборачивания блока кода менеджером контекста.

Менеджер контекста - это объект, который определяет контекст выполнения операций в конструкции with. Задача менеджера контекста выполнить определенные операции в начале блока with, и в конце.

Чаще всего, менеджер контекста вызывается с помощью блока with, но может использоваться и вызывая его методы напрямую.

Один из самых распространенных примеров использования конструкции with - работа с файлами.

Весь код находится в репозитории

Пример открытия файла:

In [1]: with open('r1.txt') as f:
   ...:     for line in f:
   ...:         print(line, end='')
   ...:

Именно эта конструкция обычно используется, чтобы открыть файл, что-то сделать с содержимым и затем закрывать файл.

В данном случае, после завершения конструкции with, файл автоматически закроется:

In [1]: with open('r1.txt') as f:
   ...:     for line in f:
   ...:         print(line, end='')
   ...:
...

In [2]: f.closed
Out[2]: True

Обратите внимание, что переменная f доступна за пределами конструкции f, так как with не создает отдельного пространства имен, как функция.

Внутри конструкции with происходит следующее:

In [19]: f = open('r1.txt').__enter__()

In [20]: for line in f:
    ...:     print(line, end='')
    ...:
...

In [21]: f.__exit__()

In [23]: f.closed
Out[23]: True

Метод __enter__ вызывается в самой конструкции with. Если в with есть выражение as, результат выполнения метода, присваивается в переменную, которая указана после as.

Потом выполняются какие-то действия, которые находятся в блоке with.

И в конце вызывается метод __exit__. В данном случае, этот метод закрывает файл.

В примере с файлом, метод __exit__ гарантирует закрытие файла, независимо от того, было ли исключение в блоке with.

Генератор и @contextlib.contextmanager

В Python существует целый модуль для работы с менеджерами контекста - contextlib.

Например, в этом модуле находится декоратор @contextlib.contextmanager, который позволяет создавать менеджер контекста из генератора.

Простой пример:

import contextlib

@contextlib.contextmanager
def lines():
    print('-'*10, 'START', '-'*10)
    yield
    print('-'*11, 'END', '-'*11)

Генератор должен выдавать только одно значение. При этом то, что находится до yield, будет выполняться в начале блока with. А то, что находится после yield - в конце.

Использование функции lines() выглядит таким образом:

with lines():
    print('inside with block')

print('outside')

Результат выполнения:

$ python with_simple.py
---------- START ----------
inside with block
----------- END -----------
outside

SQLite и with

Выполнение транзакции в блоке with

При работе с SQLite, Python позволяет использовать объект Connection, как менеджер контекста.

Пример использования соединения с базой, как менеджера контекста (файл with_sqlite3_transaction.py):

# -*- coding: utf-8 -*-
import sqlite3

data = [('00:19:FF:3D:D6:58', '10.1.10.33', '10', 'FastEthernet0/3'),
       #('00:09:BB:3D:D6:58', '10.1.15.55', '15', 'FastEthernet0/17'),
        ('00:14:33:FE:5B:69', '10.1.15.2', '15', 'FastEthernet0/12'),
        ('00:15:BF:7E:9B:60', '10.1.15.4', '15', 'FastEthernet0/6')]


connection = sqlite3.connect('dhcp_snooping.db')

try:
    with connection:
        query = 'INSERT into dhcp values (?, ?, ?, ?)'
        connection.executemany(query, data)
except sqlite3.IntegrityError as e:
    print('Error occured: ', e)
else:
    print('Запись данных прошла успешно')


for row in connection.execute('select * from dhcp'):
    print(row)

connection.close()

В блоке with в БД добавляются данные. При этом:

  • при возникновении исключения, транзакция автоматически откатывается
  • если исключения не было, автоматически выполняется commit

До выполнения скрипта в таблице dhcp такие записи:

$ sqlite3 dhcp_snooping.db
-- Loading resources from /home/vagrant/.sqliterc

SQLite version 3.8.7.1 2014-10-29 13:59:56
Enter ".help" for usage hints.
sqlite> select * from dhcp;
mac                ip          vlan        interface
-----------------  ----------  ----------  ---------------
00:09:BB:3D:D6:58  10.1.10.2   10          FastEthernet0/1
00:04:A3:3E:5B:69  10.1.5.2    5           FastEthernet0/1
00:05:B3:7E:9B:60  10.1.5.4    5           FastEthernet0/9
00:09:BC:3F:A6:50  10.1.10.6   10          FastEthernet0/3
sqlite>

Результат выполнения:

$ python with_sqlite3_transaction.py
Запись данных прошла успешно
('00:09:BB:3D:D6:58', '10.1.10.2', '10', 'FastEthernet0/1')
('00:04:A3:3E:5B:69', '10.1.5.2', '5', 'FastEthernet0/10')
('00:05:B3:7E:9B:60', '10.1.5.4', '5', 'FastEthernet0/9')
('00:09:BC:3F:A6:50', '10.1.10.6', '10', 'FastEthernet0/3')
('00:19:FF:3D:D6:58', '10.1.10.33', '10', 'FastEthernet0/3')
('00:14:33:FE:5B:69', '10.1.15.2', '15', 'FastEthernet0/12')
('00:15:BF:7E:9B:60', '10.1.15.4', '15', 'FastEthernet0/6')

Если теперь раскомментировать строку в списке кортежей data, будет возникать исключение, так как MAC-адрес в этой строке совпадает с уже существующим в таблице, а поле mac является primary key и поэтому должно быть уникальным:

$ sqlite3 dhcp_snooping.db
-- Loading resources from /home/vagrant/.sqliterc

SQLite version 3.8.7.1 2014-10-29 13:59:56
Enter ".help" for usage hints.
sqlite> .schema dhcp
CREATE TABLE dhcp (
    mac          text not NULL primary key,
    ip           text,
    vlan         text,
    interface    text
);

Чтобы еще раз попробовать добавить данные, надо раскомментировать строку в списке data и вернуть БД в исходное состояние:

$ cp dhcp_snooping_backup.db dhcp_snooping.db

$ python with_sqlite3_transaction.py
Error occured:  UNIQUE constraint failed: dhcp.mac
('00:09:BB:3D:D6:58', '10.1.10.2', '10', 'FastEthernet0/1')
('00:04:A3:3E:5B:69', '10.1.5.2', '5', 'FastEthernet0/10')
('00:05:B3:7E:9B:60', '10.1.5.4', '5', 'FastEthernet0/9')
('00:09:BC:3F:A6:50', '10.1.10.6', '10', 'FastEthernet0/3')

При добавлении данных возникло исключение UNIQUE constraint failed и транзакция откатилась. Поэтому в таблице остались те же записи, которые были до попытки добавить новую информацию.

Содержимое таблицы dhcp до и после добавления информации - одинаково. Это значит, что не записалась ни одна строка из списка data.

Так получилось из-за того, что используется метод executemany и в пределах одной транзакции мы пытаемся записать все 4 строки. Если возникает ошибка с одной из них - откатываются все изменения.

Иногда, это именно то поведение, которое нужно. Если же надо чтобы игнорировались только строки с ошибками, надо использовать метод execute и записывать каждую строку отдельно.

Соединение с БД в блоке with

В sqlite3 with используется только для работы с транзакциями.

И хотя соединение тоже можно открывать в блоке with (файл with_sqlite_conn.py):

import sqlite3

with sqlite3.connect('dhcp_snooping.db') as conn:
    for row in conn.execute('select * from dhcp'):
        print(row)

На самом деле, после этого блока соединение не закрыто:

In [1]: import sqlite3

In [2]: with sqlite3.connect('dhcp_snooping.db') as conn:
   ...:     for row in conn.execute('select * from dhcp'):
   ...:         print(row)
   ...:
('00:09:BB:3D:D6:58', '10.1.10.2', '10', 'FastEthernet0/1')
('00:04:A3:3E:5B:69', '10.1.5.2', '5', 'FastEthernet0/10')
('00:05:B3:7E:9B:60', '10.1.5.4', '5', 'FastEthernet0/9')
('00:09:BC:3F:A6:50', '10.1.10.6', '10', 'FastEthernet0/3')

In [3]: conn.execute('select * from dhcp')
Out[3]: <sqlite3.Cursor at 0xb5705660>

Поэтому лучше не открывать соединение таким образом, так как создается впечатление, что оно будет автоматически закрыто.

Но, можно создать свой менеджер контекста, который будет закрывать соединение:

import contextlib
import sqlite3

@contextlib.contextmanager
def sqlite3_connection(db_name):
    connection = sqlite3.connect(db_name)
    yield connection
    connection.close()

with sqlite3_connection('dhcp_snooping.db') as conn:
    for row in conn.execute('select * from dhcp'):
        print(row)

try:
    conn.execute('select * from dhcp')
except sqlite3.ProgrammingError as e:
    print(e)

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

$ python with_sqlite3_conn_contextmanager.py
('00:09:BB:3D:D6:58', '10.1.10.2', '10', 'FastEthernet0/1')
('00:04:A3:3E:5B:69', '10.1.5.2', '5', 'FastEthernet0/10')
('00:05:B3:7E:9B:60', '10.1.5.4', '5', 'FastEthernet0/9')
('00:09:BC:3F:A6:50', '10.1.10.6', '10', 'FastEthernet0/3')
Cannot operate on a closed database.

contextlib.closing

В модуле contextlib есть менеджер контекста closing, который вызывает метод close, в конце блока with.

Соответственно, в предыдущем примере можно не создавать менеджер контекста, а использовать closing (файл with_sqlite3_conn_closing.py):

import contextlib
import sqlite3


with contextlib.closing(sqlite3.connect('dhcp_snooping.db')) as conn:
    for row in conn.execute('select * from dhcp'):
        print(row)

try:
    conn.execute('select * from dhcp')
except sqlite3.ProgrammingError as e:
    print(e)

Результат выглядит аналогично:

$ python with_sqlite3_conn_closing.py
('00:09:BB:3D:D6:58', '10.1.10.2', '10', 'FastEthernet0/1')
('00:04:A3:3E:5B:69', '10.1.5.2', '5', 'FastEthernet0/10')
('00:05:B3:7E:9B:60', '10.1.5.4', '5', 'FastEthernet0/9')
('00:09:BC:3F:A6:50', '10.1.10.6', '10', 'FastEthernet0/3')
Cannot operate on a closed database.

Соединение SSH в блоке with

И напоследок еще одна идея для использования менеджера контекста - подключение по SSH с помощью netmiko (файл with_netmiko_contextmanager.py):

import contextlib
import netmiko

@contextlib.contextmanager
def ssh_connection(device_params):
    connection = netmiko.ConnectHandler(**device_params)
    yield connection
    connection.disconnect()

DEVICE_PARAMS = {'device_type': 'cisco_ios',
                 'ip': '192.168.100.1',
                 'username': 'cisco',
                 'password': 'cisco',
                 'secret': 'cisco' }

with ssh_connection(DEVICE_PARAMS) as ssh:
    ssh.enable()
    result = ssh.send_command("sh ip int br")
    print(result)


try:
    ssh.send_command("sh ip int br")
except OSError as e:
    print(e)

Выполнение выглядит так:

$ python with_netmiko_contextmanager.py
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
Socket is closed

Последнее сообщение появляется из-за того что в блоке try метод send_command выполняется на закрытом соединении.

Дополнительные материалы

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

Документация:

Статьи:

Ответ на stackoverflow:

В книге Fluent Python этой теме посвящен 15 раздел: