Skip to content

Latest commit

 

History

History
1263 lines (938 loc) · 32.5 KB

File metadata and controls

1263 lines (938 loc) · 32.5 KB

Simulación de tipos, y protocolos

Las clases definidas por el usuario pueden comportarse como otros tipos gracias a la implementación de diversos métodos mágicos. Este tema ilustra cómo se implementan algunos de ellos para emular tipos numéricos, contenedores y funciones.

El tema finaliza con la implementación de iteradores, generadores y contextos, tipos todos ellos para los que Python tiene una sintaxis especial.

Invocación e implementación de métodos mágicos

Los métodos mágicos son invocados por el intérprete de Python al utilizar cierta clase de sintaxis o utilizar un cierto tipo de funcionalidad. Por ejemplo, cuando hacemos:

3 + 5

En realidad estamos haciendo:

int.__add__(3, 5)

O, también:

3 in [1, 2, 5]

Que se traduce a:

list.__contains__([1, 2, 5], 3)

Fíjate en como los parámetros no siempre se pasan en el orden en que aparecen en la sintaxis.

Sea como sea, los métodos mágicos siempre se llaman desde el tipo del valor, pasando explícitamente los valores de self y del resto de parámetros.

La implementación de métodos mágicos tiene que ocurrir en la clase. De nada sirve hacer algo así:

class NerdAlien:
    pass

o = NerdAlien()
o.__repr__ = lambda: 'I am Ziltoid!'
repr(o)

La forma correcta es hacer:

class NerdAlien:
    def __repr__(self):
        return 'I am Ziltoid!'
o = NerdAlien()
repr(o)

¿Por qué la primera forma no funciona y la segunda sí?

Simulando un tipo numérico: vectores bidimensionales

Vamos a crear un vector bidimensional Vector2D e implementaremos algunas operaciones de los tipos numéricos.

  1. Comencemos con lo básico:

    class Vector2D:
        """A vector in a 2D euclidean space."""
    
        def __init__(self, x=0, y=0):
            self.x = x
            self.y = y

    Recuerda que habrás de modificar la clase entera cada vez que añadas un nuevo método.

  2. Ahora crea un punto:

    Vector2D(10, 10)

    Mira la representación por defecto. Vamos a mejorarla implementando el método mágico __repr__:

    # inside the Vector2D class
    def __repr__(self):
        return f'{self.__class__.__name__}({self.x}, {self.y})'
  3. Continuamos con la suma y resta:

    # inside the Vector2D class
    def __add__(self, other):
        if isinstance(other, Vector2D):
            return Vector2D(self.x + other.x, self.y + other.y)
    
        return NotImplemented
    
    def __sub__(self, other):
        if isinstance(other, Vector2D):
            return Vector2D(self.x - other.x, self.y - other.y)
    
        return NotImplemented

    Fíjate en NotImplemented. No lo confundas con la clase de exepción NotImplementedError. NotImplemented es un valor que indica que una operación no se encuentra disponible para un determinado tipo de argumento. En nuestro caso, sólo implementamos la suma o la resta cuando el otro argumento es un Vector2D.

    Prueba ahora:

    Vector2D(1, -5) + Vector2D(10, 10)
  4. Continuemos con algo más interesante, la negación, la multiplicación por un escalar y la división (en su versión con decimales):

    # inside the class Vector2D
    def __neg__(self):
        return Vector2D(-self.x, -self.y)
    
    def __mul__(self, scalar):
        import numbers
        if isinstance(scalar, numbers.Real):
            return Vector2D(self.x * scalar, self.y * scalar)
    
        return NotImplemented
    
    def __truediv__(self, scalar):
        import numbers
        if isinstance(scalar, numbers.Real):
            return Vector2D(self.x / scalar, self.y / scalar)
    
        return NotImplemented

    Con esto podemos hacer cosas como:

    -Vector2D(10, -10)
    Vector2D(1, 1) * 5
    Vector2D(5, 5) / 5

    Sin embargo, no podemos hacer:

    5 * Vector2D(1, 1)
  5. Esto se debe a que la expresión anterior es equivalente a:

    int.__mul__(5, Vector2D(1, 1))

    Y el tipo int no podía saber de antemano cómo sumar un Vector2D.

    Para poder arreglar esta situación, Python establece un protocolo: si una operación falla con NotImplemented, se llama a su versión "refleja" (mismo nombre del método mágico precedido de una r). Sólo si esta también devuelve NotImplemented se deduce que la operación no está soportada.

    Ahora sabemos que cuando la operación anterior falle, Python intentará:

    Vector2D.__rmul__(Vector2D(1, 1), 5)

    Así que implementamos __rmul__:

    # inside the Vector2D class
    def __rmul__(self, scalar):
        return self * scalar

    Y ya podemos hacer cosas como:

    5 * Vector2D(1, 1)

    Consulta la documentación de los operadores "reflejos" para una lista eshaustiva.

    Antes de continuar, prueba a implementar la división entera. ¿Tiene sentido implementar la división refleja para el tipo Vector2D?

  6. Existe toda una serie de operaciones in place (in situ):

    5 += 1
    7 -= 10
    8 *= -1

    Cuando el tipo es inmutable, estas operaciones no tienen más remedio que devolver un nuevo valor. Pero cuando los tipos son mutables, Python recomienda que, en la medida de los posible, la modificación se realice sobre el primer operando.

    Si decidimos que Vector2D sea mutable, podemos implementar las versiones in situ de las operaciones que ya hemos implementado de la siguiente manera:

    # inside the Vector2D class
    def __iadd__(self, other):
        if isinstance(other, Vector2D):
            self.x += other.x
            self.y += other.y
            return self
    
        return NotImplemented

    Consulta la documentación de las operaciones in situ e implementa el resto.

  7. Cambia la definición de la multiplicación para que permita "multiplicar por un vector". Implementa el producto escalar para estos casos.

  8. También podemos alterar la forma en que un tipo se comporta en una expresión booleana, es decir, cuándo se considera verdadero y cuándo falso. Por ejemplo, sería conveniente que el vector cero, Vector2D() fuera considerado falso. Sin embargo:

    origin = Vector2D()
    bool(origin)

    Podemos cambiar este comportamiento, implementando el método mágico __bool__ (que debe devolver True o False):

    # inside the Vector2D class
    def __bool__(self):
        return bool(self.x or self.y)

    Comprueba que la modificación ha servido de algo.

    En la documentación se muestra que además de __bool__, también puedes implementar la conversión a otros tipos.

Simulando un tipo contenedor

Continuamos simulando otros tipos, implementando los métodos mágicos típicos de un contenedor. Nótese que la forma más sencilla sería heredar de alguna de las clases abstractas de collections.abc como hacíamos en el tema anterior pero esta aproximación también sirve.

  1. Comencemos por implementar la longitud de Vector2D:

    # inside the Vector2D class
    def __len__(self):
        return 2

    Con lo que ya podemos hacer:

    len(Vector2D(1, 1))
  2. Permitamos ahora indexar los componentes del vector:

    # inside the Vector2D class
    def __getitem__(self, index):
        index = self._correct_index(index)
        return self.x if index == 0 else self.y
    
    def __setitem__(self, index, value):
        index = self._correct_index(index)
        if index == 0:
            self.x = value
        else:
            self.y = value
    
    def _correct_index(self, index):
        if index < 0:
            index += len(self)
        if index < 0 or index >= len(self):
            raise IndexError
        return index
  3. Nos faltaría el test de pertenencia y la iteración, es decir, las sintáxis:

    v = Vector2D(10, -10)
    10 in v
    for item in v:
        print(item)

    Implementaremos aquí el primero, dejando el segundo para la última sección del tema:

    # inside the Vector2D class
    def __contains__(self, item):
        return self.x == item or self.y == item

Clases orientadas a datos (data classes)

Python 3.7 añade un decorador @dataclass cuyo objetivo es implementar automáticamente algunos protocolo. Su motivación viene explicada en el PEP 557.

La implementación automática es posible porque las clases orientadas a datos tienen una forma particular que consiste en una serie de campos de datos con tipo. Los protocolos implementados se realizan considerando el nombre de los campos y sus tipos.

En la biblioteca estándar de Python ya has encontrado otros intentos destinados a reducir la la cantidad de código y andamiaje necesario para implementar otros protocolos como el módulo abc o el tipo namedtuple. En el ecosistema Python existen proyectos de software attrs con las mismas motivaciones pero objetivos más ambiciosos.

  1. Crea una clase como la siguiente:

    import dataclasses
    from dataclasses import dataclass
    
    @dataclass
    class Vector3:
        x: float = 0
        y: float = 0
        z: float = 0
  2. Con esto únicamente, tu clase tendrá un constructor que acepta estos parámetros:

    zero = Vector3()
    y_axis = Vector3(y=1)
    corner = Vector3(x=1, y=1, z=1)

    Además su representación será representativa:

    print(zero)
  3. Se puede convertir en tupla y diccionario:

    dataclasses.asdict(corner)
    dataclasses.astuple(corner)
  4. Dos instancias son iguales si sus tipos son iguales y sus miembros son iguales:

    another_corner = Vector3(1, 1, 1)
    assert corner == another_corner
  5. Con unas ligeras modificaciones, además:

    import dataclasses
    from dataclasses import dataclass
    
    @dataclass(order=True, frozen=True)
    class Vector3:
        x: float = 0
        y: float = 0
        z: float = 0
  6. Si el parámetro order del decorador es True, la clase tiene un orden, definido en términos de sus elementos, en orden:

    v = Vector3(z=1)
    vv = Vector3(z=10)
    vvv = Vector3(x=5)
    assert v < vv < vvv

    Esta opción es incompatible con proveer de alguno de los métodos mágicos de comparación:

    @dataclass(order=True)
    class Whatever:
        def __lt__(self, other):
            ...
  7. Si el parámetro frozen del decorador es True, la clase es, además, inmutable:

    v.z = 3

    Si el parámetro eq es True (y lo es, por defecto) y frozen es True, el elemento es además hashable y puede actuar como la clave de un mapa o añadirse a un conjunto.

  8. Se puede comprobar si una clase es una dataclass:

    assert dataclasses.is_dataclass(Vector3)
    assert not dataclasses.is_dataclass(dict)
  9. Se puede obtener un nuevo ejemplar de una dataclass indicando los cambios sobre otro:

    corner = Vector3(1, 1, 1)
    further_corner = dataclasses.replace(corner, z=10)
    assert corner < further_corner

Se puede controlar el comportamiento de cada cambio para cada funcionalidad. Por ejemplo si quisiéramos excluir un campo de ser parte de la representación, o si quisiéramos exluir un campo de la comparación, o si quisiéramos crear un campo que depende del valor de otros campos:

  1. Imagina que queremos precalcular el módulo del vector:

    @dataclass(order=True, frozen=True)
    class Vector3:
        x: int = 0
        y: int = 0
        z: int = 0
        module: int = dataclasses.field(init=False, repr=False, compare=False)
    
        def __post_init__(self):
            object.__setattr__(self, 'module',
                (self.x ** 2 + self.y ** 2 + self.z ** 2) ** .5)

    No lo has visto en clase pero... ¿se te ocurre por qué tenemos que asignar de esta manera a module en vez de hacer self.module = ...?

  2. Puedes obtener los campos de una dataclass con dataclasses.fields() para comprobar que realmente es un campo:

    corner = Vector3(1, 1, 1)
    assert corner.module == 3 ** .5
    list(dataclasses.fields(corner))
  3. Otro uso de la función field() es el de proporcionar una factoría de valores por defecto:

    @dataclass
    class Node:
        data = None
        children: list = dataclasses.field(default_factory=list)

    El parámetro debe ser un invocable al que se llamará, sin argumentos, para generar el nuevo valor por defecto. De esta forma, cada nuevo nodo puede tener una lista de hijos diferente:

    a = Node()
    b = Node()
    assert a == b
    assert a.children == b.children
    assert a.children is not b.children

Simulando un tipo invocable: decoradores con parámetros

Los invocables son aquellos objetos a los que podemos "llamar", es decir, acompañar de un par de paréntesis con la sintáxis de paso de parámetros que conocemos. Estos paréntesis con sus parámetros son reamente una invocación a un método mágico. Esto:

def add(a, b):
    return a + b

add(5, 2)

Es en realidad, esto:

functiontype = type(add)
functiontype.__call__(add, 5, 2)

El método mágico __call__ convierte a un objeto en un invocable.

  1. Considera la siguiente clase:

    class Multiplier:
    
        def __init__(self, a):
            self.a = a
    
        def __call__(self, b):
            return self.a * b
  2. La clase Multiplier crea multiplicadores. En su inicialización captura el número por el que multiplicará durante la invocación:

    nullify = Multiplier(0)
    once = Multiplier(1)
    double = Multiplier(2)
    triple = Multiplier(3)
    assert nullify(100) == 0
    assert once(100) == 100
    assert double(100) == 200
    assert triple(100) == 300

    Razona esta sintáxis, también válida:

    assert Multiplier(4)(100) == 400
  3. Vamos a utilizar un invocable para simular un decorador con parámetros. Antes, repasa cómo se hacía con funciones:

    # capture the parameters
    def log(tag):
        # capture the function to decorate
        def _decorator(f):
            # capture the orginal arguments of the function
            def _decorated(*args, **kwargs):
                print(f'[{tag}] Calling {f.__name__} with {args} and {kwargs}')
                return f(*args, **kwargs)
    
            return _decorated
    
        return _decorator
    
    @log('test')
    def echo(something):
        return something
    
    echo(42)
  4. Compara ahora cómo se haría con una clase:

    class Log:
    
        # captures the parameters
        def __init__(self, tag):
            self.tag = tag
    
        # captures the function
        def __call__(self, f):
            self.f = f
            return self._decorated
    
        # captures de arguments of the original function
        def _decorated(self, *args, **kwargs):
            f = self.f
            tag = self.tag
            print(f'[{tag}] Calling {f.__name__} with {args} and {kwargs}')
            return f(*args, **kwargs)
    
    @Log('test')
    def echo(something):
        return something
  5. Convéncete del funcionamiento de la clase utilizando el depurador.

Protocolos

No confundir con la propuesta de protocolos pensada para Python 3.8.

Un protocolo, en Python, es una secuencia de acciones que se coordinan para lograr un fin. Normalmente es un tipo de sintaxis Python la que desencadena el protocolo.

Por ejemplo, una sentencia if como:

if [1, 2, 3]:
    ...

Desencadena comprobar el valor de la lista como booleano. Sólo eso implica algo así:

def convert_to_bool(condition):
    if hasattr(condition, '__bool__'):
        result = type(condition).__bool__(condition)
        if not isinstance(result, bool):
            raise TypeError
        return result

    if hasattr(condition, '__len__'):
        result = type(condition).__len__(condition)
        if not isinstance(result, int):
            raise TypeError
        return result != 0

    return object.__bool__(condition)

Como ves, interpretar la lista como un booleano va más allá de llamar a su método mágico __bool__, también supone una serie de pasos, señales, comprobaciones de tipos, alternativas...

Iteradores

El protocolo iterador es el que entra en juego cuando se utiliza la sentencia for ... in ...:

for i in [1, 2, 3]:
    print(i)

En realidad, lo que ocurre es algo así:

iterable = [1, 2, 3]
iterator = iter(iterable)
while True:
    try:
        i = next(iterador)
        print(i)
    except StopIteration:
        break

Primero se extrae, mediante iter(), el iterador por defecto de la lista, luego ese iterador se usa con next() para generar valores indefinidamente hasta que se captura la excepción StopIteration, momento en el cual, el bucle termina.

  • Un iterable es un objeto sobre el que se puede usar iter(), a través del método mágico __iter__: la operación obtiene un iterador.
  • Un iterador es un objeto sobre el que se puede usar next(), a través del método mágico __next__: la operación genera un valor o señala el fin de la generación de valores.

Las definiciones no bastan para implementar el protocolo correctamente, además:

  1. Un iterador es un iterable, es decir, implementa el método __iter__.
  2. Su iterador por defecto es él mismo.

Veamos algunos ejemplos:

  1. Comencemos por algo sencillo, la mayoría de las implementaciones de iteradores delegan en los iteradores de otros tipos. Así, podemos implementar __iter__ para el tipo Vector2D como:

    # inside the Vector2D class
    def __iter__(self):
        return iter([self.x, self.y])

    Con esto ya podemos hacer:

    for component in Vector2D(5, 10):
        print(component)
  2. Cambiemos de colección. Considera ahora el tipo Polygon, que representa un polígono en el espacio y puede iterarse para obtener los puntos que lo forman:

    class Polygon:
    
        def __init__(self, center, sides, radius=1):
            self.center = center
            self.sides = sides
            self.radius = radius
    
        def __iter__(self):
            sides = self.sides
            radius = self.radius
            return iter(self._make_points(sides, radius))
    
        def _make_points(self, sides, radius):
            import math
            center = self.center
            points = []
            for step in range(sides):
                angle = 2 * math.pi / sides * step
                point = center + Vector2D(
                    math.cos(angle) * radius,
                    math.sin(angle) * radius)
    
                points.append(point)
    
            return points

    Con esto podemos hacer:

    square = Polygon(Vector2D(), 4)
    for point in square:
        print(point)
  3. Finalmente, considera el tipo Circle que implementa un círculo como un polígono de muchos lados:

    class Circle:
    
        def __init__(self, center, radius=1, step=0.00001):
            import math
            sides = math.floor(2 * math.pi / step)
            self._polygon = Polygon(center, sides, radius)
    
        def __iter__(self):
            return iter(self._polygon)

    Con lo que podemos hacer:

    circle = Circle(Vector2D())
    for point in circle:
        print(point)

    Fíjate, no obstante, en el pequeño retraso antes de comenzar a iterar. Este es uno de los límites que tiene delegar en listas: tener que generarla antes con la consumición de espacio y tiempo que implique.

  4. Podemos solucionar este problema implementando nuestro propio iterador del polígono:

    class PolygonPointsIterator:
    
        def __init__(self, polygon):
            self._polygon = polygon
            self._current = 0
    
        def __next__(self):
            import math
    
            if self._current >= self._polygon.sides:
                raise StopIteration
    
            angle = 2 * math.pi / self._polygon.sides * self._current
            point = self._polygon.center + Vector2D(
                math.cos(angle) * self._polygon.radius,
                math.sin(angle) * self._polygon.radius)
    
            self._current += 1
    
            return point
    
        def __iter__(self):
            return self

    Ahora, la clase Polygon puede reimplementarse como:

    class Polygon:
    
        def __init__(self, center, sides, radius=1):
            self.center = center
            self.sides = sides
            self.radius = radius
    
        def __iter__(self):
            return PolygonPointsIterator(self)

    Y recorrer el círculo funciona sin tener que hacer nada más:

    for p in Circle(Vector2D()):
        print(p)

    Además, gracias a la implementacion de __iter__ por parte del iterador, podemos hacer cosas como:

    circle = Circle(Vector2D())
    points = iter(circle)
    for p in points:
        print(p)

    Razone el por qué.

Generadores

Un generador no es un protocolo, sino una sintaxis conveniente para producir iteradores. Un generador es una función que hace uso de yield:

  1. Considera la siguiente función:

    def rgb():
        yield 'red'
        yield 'green'
        yield 'blue'

    Ahora puedes hacer:

    for color in rgb():
        print(color)
  2. La mera presencia de la instrucción yield convierte a la función en un generador. Su invocación crea un objeto generator:

    f = rgb
    type(f)
    
    g = rgb()
    type(g)
    
    v = next(g)
    type(v)
  3. Los generadores resultan muy convenientes a la hora de escribir iteradores, sin necesidad de crear una clase nueva:

    class Polygon:
    
        def __init__(self, center, sides, radius=1):
            self.center = center
            self.sides = sides
            self.radius = radius
    
        def __iter__(self):
            import math
            for step in range(self.sides):
                angle = 2 * math.pi / self.sides * step
                yield self.center + Vector2D(
                    math.cos(angle) * self.radius,
                    math.sin(angle) * self.radius)
  4. De hecho, existe una sintaxis intensional para generadores, sútilmente distinta de la de una lista intensional:

    perfect_squares = (i**2 for i in range(100))
    type(perfect_squares)

    ¿Adviertes la diferencia?

  5. Un generador no es una lista, pero es un iterable y, por tanto, lo podemos convertir a una lista:

    all_perfect_squares = list(perfect_squares)

    Los generadores son de usar y tirar, si hemos gastado uno, no hay nada más que podamos hacer con él:

    more_squares = list(perfect_squares)
    assert not more_squares
    next(perfect_squares)
  6. Las secuencias infinitas, otra de las limitaciones de las listas, son fácilmente expresables con generadores:

    def inifinite(start=0):
        while True:
            yield start
            start += 1

    Con lo que podemos hacer:

    g = infinite()
    next(g)
    next(g)
    next(g)
  7. Un aspecto muy interesante de los generadores es que podemos comunicarnos con ellos y alterar su estado interno. Por ejemplo:

    def infinite(start=0):
        while True:
            next_ = yield start
            start = next_ if next_ is not None else (start + 1)
    
    g = infinite()
    print(next(g))
    print(next(g))
    print(g.send(100)) # send also resumes the execution
    print(next(g))
  8. Además podemos componer nuevos generadores en función de otros generadores:

    def perfect_squares(start=0):
        for i in infinite(start):
            yield i ** 2
    
    g = perfect_squares()
    print(next(g))
    print(next(g))
    print(g.send(100))
    print(next(g))

    ¿Por qué el envío no funciona?

  9. Aunque componerlos correctamente puede ser complicado:

    def perfect_squares(start=0):
        g = iter(infinite(start))
        next_ = None
        while True:
            next_ = yield g.send(next_) ** 2
    
    g = perfect_squares()
    print(next(g))
    print(next(g))
    print(g.send(100))
    print(next(g))
  10. La sentencia yield from permite transferir el control a otro generador. Por ejemplo, en lugar de hacer:

    def chain(*iterables):
        for each_iterable in iterables:
            for item in each_iterable:
                yield item
    
    list(chain('abc', '123'))

    Podemos escribir:

    def chain(*iterables):
        for each_iterable in iterables:
            yield from each_iterable
    
    list(chain('abc', '123'))

    Fíjate como yield from funciona, no sólo con generadores, sino con iterables, en general.

El módulo itertools

Gran parte de la potencia de generadores e iterables es su capacidad de composición. El módulo itertools ofrece multitud de funcionalidad afin.

  1. Comienza importando el módulo:

    import itertools
  2. Familiarízate con la función islice (iterator slice) que selecciona determinados índices del generador, por ejemplo:

    for sq in itertools.islice(perfect_squares(), 10, 21):
        print(sq)
  3. Otras función de selección interesante es takewhile():

    def lower_than_250(x):
        return x < 250
    
    for sq in itertools.takewhile(lower_than_250, perfect_squares()):
        print(sq)
  4. También incluye algunos de los generadores que hemos estudiado anteriormente como count() y chain():

    numbers = itertools.islice(itertools.count(), 10, 21)
    squares = itertools.islice(perfect_squares(), 10, 21)
    for item in itertools.chain(numbers, squares):
        print(item)
  5. Y un conjunto de utilidades para generar distintas formas de combinaciones:

    def get_2d_grid(limit, step=1):
        yield from map(
            lambda p: Vector2D(*p),
            itertools.product(range(0, limit, step), repeat=2))
    
    for p in get_2d_grid(10):
        print(p)

    Además de product() también existen permutations(), combinations() y combinations_with_replacement() de las que se puede encontrar un ejemplo en la documentación y que repetimos aquí:

    list(itertools.product('ABCD', repeat=2))
    list(itertools.permutations('ABCD', 2))
    list(itertools.combinations('ABCD', 2))
    list(itertools.combinations_with_replacement('ABCD', 2))

Manejadores de contexto

El PEP 343 introduce la sentencia with. Has utilizado esta sentencia con ficheros:

with open('./answer', 'w+') as f:
    f.write('42')

Lo utilizabas para asegurar que el fichero quedaba cerrado se produjera, o no, una excepción.

Resulta que el valor devuelto por open es un manejador de contextos que implementa los métodos mágicos __enter__ y __exit__.

  1. Observa el funcionamiento básico:

    class ObviousContext:
        def __enter__(self):
            print('entering')
    
        def __exit__(self, exc_type, exc_val, exc_tb):
            print('exception info:', (exc_type, exc_val, exc_tb))
            print('exiting')
    
    with ObviousContext():
        print('in the context')

    Fíjate como los parámetros de __exit__ son None, los tres porque no se ha producido ninguna excepción en el contexto.

  2. Para usar la notación as de with, la función __enter__ tiene que devolver un objeto que será el que se enlace a la variable que sucede a la palabra clave as:

    class ObviousContext:
        def __init__(self, something):
            self._something = something
    
        def __enter__(self):
            print('entering')
            return self._something
    
        def __exit__(self, exc_type, exc_val, exc_tb):
            print('exception info:', (exc_type, exc_val, exc_tb))
            print('exiting')
    
    answer = 42
    with ObviousContext(answer) as target:
        assert answer is target
  3. Observa qué pasa si lanzamos una excepción:

    with ObviousContext():
        raise RuntimeError('a forced error')

    En tal caso, los parámetros pasados a __exit__ son el tipo de la excepción, su valor y la pila de llamadas. Lo importante es notar que, pese a la excepción, la función __exit__ se ha llamado.

    Los contextos nos permiten implementar código de inicialización y finalización específicos y controlar los errores que se produzcan en el código del bloque with (también llamado suite en el vocabulario de Python).

  4. Si el método __exit__ devuelve un valor verdadero, la excepción se suprime:

    class VerboseSuppress:
        def __enter__(self):
            print('entering')
    
        def __exit__(self, exc_type, exc_val, exc_tb):
            print('exception info:', (exc_type, exc_val, exc_tb))
            print('exiting')
            return True
    
    with VerboseSuppress():
        raise RuntimeError('a forced error')

    En ningún caso se debe re-lanzar la excepción capturada por __exit__, tan sólo devolver un valor falso para indicar al protocolo de contextos que no debe suprimir la excepción.

  5. Considera el siguiente contexto:

    from functools import wraps
    
    class Inspect:
    
        def __init__(self, f):
            self._f = f
    
        def __enter__(self):
            @wraps(self._f)
            def _decorated(*args, **kwargs):
                result = self._f(*args, **kwargs)
                print(f'{self._f.__name__}(*{args}, **{kwargs}) -> {result}')
                return result
    
            return _decorated
    
        def __exit__(self, *_):
            pass

    ¿Qué hace?

  6. ¿Podrías diseñar un contexto, que reemplace un método de una clase por una función cualquiera, sólo durante la ejecución del contexto? Se utilizaría de esta forma:

    class Echo:
    
        def __init__(self, something):
            self._something = something
    
        def tell(self):
            return self._something
    
    def answer(*_):
        return 42
    
    class Replace:
    
        def __init__(self, cls, method_name, replacement):
            ...
    
        def __enter__(self):
            ...
    
        def __exit__(self):
            ...
    
    with Replace(Echo, 'tell', answer):
        o = Echo('I am Ziltoid!')
        assert o.tell() == 42
    
    o = Echo('I am Ziltoid')
    assert o.tell() == 'I am Ziltoid'