<jupyter_start><jupyter_text>Протоколы

Понятие протокол введено для обозначения частичного или полного
подчинения объектов тому или иному договору или интерфейсу.

В утиной типизации Python любой объект, вне зависимости от типа, может
объявить о своём следовании договору путём поддержки протокола. Обычно
поддержка протокола заявляется путём реализации одного или нескольких
методов, характерных для него.

В Python есть несколько встроенных протоколов, поддержка которых
объявялется с помощью реализации классом ряда "магических" и не только
методов. О некоторых из них мы уже упоминали. Например, протоко числа,
требует реализации магических методов, поддерживающих работу с
арифметическими и другими операторами; протокол копирования требует
реализации `__copy__` или `__deepcopy__` и т.д.

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

Реализация методов
[протокола последовательности](https://docs.python.org/3/reference/datamodel.htmlemulating-container-types)
позволяет использовать свой класс как последовательность и,
соответственно, использовать все инструменты работы с
последовательностями, например, оператор квадратные скобки, вычисление
длины и др.

Для полной поддержки этого протокола необходимо реализовать следующие мтеоды:
- `__len__` - длина последовательности, поддерка функции `len`
- `__contains__` - оператор включения `in`
- `__getitem__` - оператор чтения элемента по индексу
- `__setitem__` - оператор записи элемента по индексу
- `__delitem__` - оператор удаления элемента по индексу
- `__reversed__` - оператор инвертирования последовательности

Для работы со срезами в методах `__*item__` нужно предусмотреть передачу
объекта типа `slice`.


Реализуем свою последовательность. Это проста последовательность,
обертка над другимиколлекциями Python, в качестве аргумента `items`
можно использовать списки или кортежи. Для подобного использования
базовых коллекций лучше всего будет просто использовать наследование от
типа `list`, `tuple` или других.<jupyter_code>class MySequence:
    def __init__(self, items):
        self._items = items

    def __len__(self):
        return len(self._items)

    def __getitem__(self, index):
        return self._items[index]

    def __setitem__(self, index, value):
        self._items[index] = value

    def __delitem__(self, index):
        return self._items.pop(index)

    def __contains__(self, item):
        return item in self._items

    def __reversed__(self):
        return reversed(self._items)

my_seqence = MySequence([1, 2, 3])
print(f'{len(my_seqence) = }')
print(f'{my_seqence[0] = }')
print(f'{my_seqence[1:] = }')
print(f'{3 in my_seqence = }')
print(f'{list(reversed(my_seqence)) = }')
print('-' * 50)

del my_seqence[1]
my_seqence[0] = 42
print(f'{list(my_seqence) = }')
print('-' * 50)

for item in my_seqence:
    print(item)<jupyter_output>len(my_seqence) = 3
my_seqence[0] = 1
my_seqence[1:] = [2, 3]
3 in my_seqence = True
list(reversed(my_seqence)) = [3, 2, 1]
--------------------------------------------------
list(my_seqence) = [42, 3]
--------------------------------------------------
42
3<jupyter_text>Протокол итератора

Реализация классом протокола итератора позволяет получать из него
итератор. Стоит отметить, что реализация метода `__getitem__` также
позволяет получать итератор, т.к. функция `iter` проверяет и этот метод.

Методы этого протокола:
- `__next__` - получение следующего элемента, поддержка функции `next`
- `__iter__` - получение итератора, поддержка функции `iter`
  Вызываемый объект

Данный протокол позволяет использовать экземплярам класса оператора
круглые скобки, т.е. быть вызываемым объектом.

Протокол содержит только один метод:
- `__call__`  Менеджер контекста

О менеджерах контекста мы говорили в
[предыдущих главах](../05_files/03_cmanagers.ipynb). Здесь мы поговорим
о том, как добавить своему классу поддержку этой функциональности, т.е.
обеспечить возможность классу работать с конструкцией `with ... as ...`.

Протокол менеджера контекста довольно прост и имеет только два метода:
- `__enter__` - метод, вызываемый при входе в контекст
- `__exit__` - метод, вызываемый при выходе из контекста

Асинхронный вариант:
- `__aenter__`
- `__aexit__`

Метод `__enter__` (`__aenter__`) неявно вызывается в момент входа в
контекст, т.е. при выполенении инструкции `with ... as ...`. Этот метод
должен реализовывать, например, логику получения ресурсов (открытие
файла, подключение к базе данных, открытие сокета и др.).

Метод `__exit__` (`__aexit__`) неявно вызывается при выходе из
контекста, т.е. в момент завершения блока кода внутри `with ... as ...`.
Этот метод должен реализовывать, например, логику освобождения ресурсов
(закрытие файла, закрытие подключения к базе данных, закрытие сокета и
др.).

Метод `__exit__` предоставляет выход из контекста среды выполнения и
возвращает логический флаг, указывающий, следует ли подавлять любое
возникшее исключение. Он должен принимать три аргумента: тип исключения
`exc_type`, значение `exc_val` и информацию о трассировке `exc_tb`.
Если исключения не было возбуждено внутри блока `with`, то все три
аргумента принимают значение `None`. 

Метод `__exit__` должен возвращать одно значение - флаг подавления
исключения. Если у метода `__exit__` установить возвращаемое значение
как `True`, то это приведет к тому, что оператор `with` будет подавлять
возникающие исключения внутри себя и продолжит выполнение с оператора,
непосредственно следующим за оператором `with`. В противном случае
исключение `exc_type` продолжает распространяться после завершения
выполнения этого метода. Исключения, возникающие во время выполнения
этого метода, заменят все исключения, возникшие в теле оператора `with`.

Передаваемое исключение `exc_type` никогда не следует повторно вызывать
явно, вместо этого метод `__exit__` должен возвращать `False`, чтобы
указать, что метод завершился успешно и не хочет подавлять возникшее
исключение. Это позволяет коду управления контекстом легко определять,
действительно ли метод `__exit__` потерпел неудачу.

Ниже приведен пример использования протокола менеджера контекста.<jupyter_code>class SimpleMC:
    def __enter__(self):
        print('Вход в контекст')
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type:
            print(f'Контекст завершился с ошибкой: {exc_type}; {exc_val}')
        else:
            print('Контекст успешно завершился')<jupyter_output><empty_output><jupyter_text>Продемонстрируем работу класса `SimpleMC`<jupyter_code>with SimpleMC():
    print('Очередная магия')
with SimpleMC():
    print('Очередная магия')
    raise ValueError('Что-то пошло не так')<jupyter_output>Вход в контекст
Очередная магия
Выход из контекста. Исключение: <class 'ValueError'>; Что-то пошло не так<jupyter_text>Протокол дескриптора

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

В [документации](https://docs.python.org/3/howto/descriptor.htmldefinition-and-introduction)
дано следующее определение
[дескрипторов](https://docs.python.org/3/glossary.htmlterm-descriptor):

>Дескриптор это любой объект, у которого определены методы `__get__`,
>`__set__` или `__delete__`. Если дескриптором является атрибут класса,
>то для него определено специальное поведение при поиске имени атрибута.

Таким образом в Python дескриптор представляет собой прослойку на уровне
операций поиска, изменения и удаления атрибута.

Поведение по умолчанию для доступа к атрибутам заключается в получении,
установке или удалении атрибута из словаря объекта. Например, у объекта
`obj` есть атрибут `obj.attr`. В таком случае цепочка поиска будет
начинаться в словаре самого объекта, т.е. в `obj.__dict__['attr']`,
затем, если атрибут не будет найден, поиск перейдет в объект класса
`type(a).__dict__['x']` и продолжающуюся через базовые классы `type(a)`,
за исключением метаклассов. Если искомое значение является объектом,
определяющим один из методов дескриптора, тогда Python может
переопределить поведение по умолчанию и вместо этого вызвать метод
дескриптора. То, где это происходит в цепочке приоритетов, зависит от
того, какие методы дескриптора были определены.

Дескрипторы — это мощный протокол общего назначения. Это механизм,
лежащий в основе свойств, методов, статических методов, методов класса
и `super`. Они предлагают гибкий набор новых инструментов для
повседневных программ Python.

Протокол дескриптора содержит четыре метода:
- `descr.__get__(self, obj, type=None) -> value` - метод, определяющий
поведение при доступе к атрибуту, т.е. при выполнении `obj.attr`
- `descr.__set__(self, obj, value) -> None` - метод, определяющий
поведение при создании/изменении атрибута, т.е. при выполнении
`obj.attr = value`
- `descr.__delete__(self, obj) -> None` - метод, определяющий поведение
при удалении атрибута, т.е. при выполнении `del obj.attr`
- `descr.__set_name__(self, owner, name) -> None` - метод, определяющий
поведение при доступе к атрибуту, т.е. при выполнении `obj.attr`

В документации можно ознакомиться с отличным туториалом по дескрипторам:
[Descriptor HowTo Guide](https://docs.python.org/3/howto/descriptor.html)

Рассмотрим работу этих методов на примере дескриптора для валидации
атрибутов. Это пример простого валидатора для целых чисел. Он проверят
тип значения, а также проверяет принадлежность определенным границам.
Если какая-либо проверка не пройдена, будет возбуждено исключение
`ValueError`.

Так как в этом валидаторе нам важно отслеживать момент изменения
значения атрибута, то переопределим метод `__set__`, метод `__get__` в
данном контексте нам не нужен, он будет использован с реализацией по
умолчанию. Важно отметить, что мы также реализовали метод
`__set_name__`, который позволяет автоматически передавать имя атрибута,
с которым связан дескриптор. Это явялется важной функциональной
возможностью, т.к. теперь нет необходимости явно указывать имя атрибута
внутри дескриптора. Однако, используя этот метод, приходится
использовать функции `setattr` и `getattr` для доступа к атрибуту по его
имени. Тем не менее это позволяет реализовать один дескриптор сразу для
нескольких атрибутов. Ранее требовалось реализовывать один дескриптор
для одного атрибута (см. пример в
[документации](https://docs.python.org/3/howto/descriptor.htmlcustomized-names)).<jupyter_code>class IntValidate:
    """Простой валидатор для целых чисел"""
    def __init__(self, min_=0, max_=None) -> None:
        self._min = min_
        if max_ is not None:
            self._max = max_
        else:
            self._max = float('inf')

    def __set__(self, obj, value):
        print(f'Попытка изменения атрибута {self.public_name} у {obj}')
        if isinstance(value, int) and self._min <= value <= self._max:
            setattr(obj, self.private_name, value)
        else:
            message = (
                f'Некорректное значение {value}. '
                f'Оно должно лежать в отрезке [{self._min}, {self._max}]'
            )
            raise ValueError(message)

    def __set_name__(self, owner, name):
        print(f'Передача имени атрибута: {owner = }, {name = }')
        self.public_name = name
        self.private_name = '_' + name<jupyter_output><empty_output><jupyter_text>Использовать дескрипторы очень просто. Главным моментом использования
дескрипторов являтеся то, что нужно создавать атрибут класса.
Использование дескриптора внутри метода `__init__` будет
недействительно.

Разберем порядок работы такого дескриптора на примере атрибута `count` в
случае, когда создается аналогичный атрибут в методе `__init__`.<jupyter_code>class MyModel:
    count = IntValidate()
    def __init__(self, count) -> None:
        self.count = count


my_model = MyModel(42)<jupyter_output>Передача имени атрибута: owner = <class '__main__.MyModel'>, name = 'count'
Попытка изменения атрибута count у <__main__.MyModel object at 0x00000192AAA90610><jupyter_text>Такой дескриптор работает следующим образом. Важно понимать, что при
инициализации атрибутов экземпляра, атрибут класса с именем `count`
уже будет создан. В методе `__init__` выражение `self.count = count`
вызовет метод поиска атрибутов `__setattr__`. Реализация этого метода
автоматически определяет, является ли атрибут дескриптором. Если да, то
вызывается его метод `__set__`.

Метод дескрипторов `__get__` работает аналогичным образом, только для
случая чтения атрибута.

Дескрипторы деляться на два типа: дескрипторы данных (data descriptor) и
дескрипторы без данных (non-data descriptors). Дескрипторы данных
реализуют методы `__set__` и `__delete__`. Как оворит их название, они
обычно предназначены для работы (сохранения/удаления) данных.
Дескрипторы без данных реализуют метод `__get__` и обычно предназначены
для методов. Запрет определенных действий возможен с помощью возбуждения
исключения `AttributeError`. Например, для реализации дескриптора только
для чтения можно возбудить исключение в методе `__set__`. Такое деление
обусловлено тем, что они работают немного по разному при поиске
атрибутов. Рассмотрим это подробнее на немного другом примере класса.
Обратите внимание, что в нем нет метода `__init__`.

Существует следующее правило, определяющее порядок процесса поиска
атрибутов для дескрипторов:
- Обращение `MyClass().attr` будет перенаправлено `Descr.__get__`
если:
    - `Descr` - дескриптор данных, реализующий метод `__get__`;
    - `Descr` - дескриптор, реализующий **только** метод
    `__get__`, и в `self.__dict__` нет искомого атрибута.
- Во всех остальных случаях порядок классический:
`self.__dict__` -> `cls.__dict__` -> рекурсивно по всем предкам.<jupyter_code>class DescrA:
    """Дескриптор данных"""
    def __set__(self, obj, value):
        print(f'Попытка изменения атрибута {self.public_name} у {obj}')
        setattr(obj, self.private_name, value)
    
    def __delete__(self, obj):
        print(f'Удаление атрибута {self.public_name} у {obj}')
        delattr(obj, self.public_name)

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name


class DescrB(DescrA):
    """Дескриптор данных с методом __get__"""
    def __get__(self, obj, owner):
        print(f'Попытка чтения атрибута {self.public_name} у {obj}')
        return 42


class DescrC(DescrA):
    """Дескриптор без данных"""
    def __set__(self, obj, value):
        print(f'Попытка изменения атрибута {self.public_name} у {obj}')
        setattr(obj, self.private_name, value)

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name


class MyClass:
    attr_a = DescrA()
    attr_b = DescrB()
    attr_c = DescrC()


instance = MyClass()
print(f'{instance.attr_a = }')  # поиск в MyClass.__dict__
print('-' * 50)
print(f'{instance.attr_b = }')  # перенаправление в DescrB.__get__
print('-' * 50)
print(f'{instance.attr_c = }')  # поиск в MyClass.__dict__
print('-' * 50)<jupyter_output>instance.attr_a = <__main__.DescrA object at 0x000001D020EA59A0>
--------------------------------------------------
Попытка чтения атрибута attr_b у <__main__.MyClass object at 0x000001D020ECCEE0>
instance.attr_b = 42
--------------------------------------------------
instance.attr_c = <__main__.DescrC object at 0x000001D020ECC130>
--------------------------------------------------<jupyter_text>Важно отметить, что дескрипторы работают **только** при наличии
экземпляров. Если использовать попробовать написать
`MyClass.attr_a = 42`, то мы просто перезапишем дескриптор на число 42. Ниже представлен пример получения протоколов непосредственно из имен
методов<jupyter_code>from collections import abc


def get_protocols(source=abc):
    """Протоколы модуля `collections.abc`."""
    protocols = {}
    for objname in dir(source):
        if objname.startswith("_"):
            continue
        obj = getattr(source, objname)
        abmethods = sorted(obj.__abstractmethods__)
        if not abmethods:
            continue    
        protocols[objname] = abmethods        
    return protocols


get_protocols()<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.0932680094356741, 'fraction_numerical': 0.00647190467549749, 'mean_word_length': 4.1459694989106755, 'pattern_counts': {'":': 0, '<': 33, '<?xml version=': 0, '>': 42, 'https://': 5, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '7', 'dir': 'jupyter-structured-clean-dedup', 'id': '254452', 'license': 'MIT', 'n_tokens_mistral': 6414, 'n_tokens_neox': 7345, 'n_words': 1696, 'path': 'python_pd/06_classes/06_protocols.ipynb', 'repo_name': 'Alexey-smurf/python-bp', 'size': '16533'}