Kodumaro :: Concern em Python em 15 minutos

Publicado em 15 de Agosto, 2018
As sombras da programação.
Python

Um recurso bacana em Ruby é concern: este módulo do ActiveSupport abstrai a criação de mixins, deixando muito simples e prático fazer programação orientada a aspecto. Dê uma olhada lá na documentação, pois, logo no começo da página, há dois exemplos de mixin, sem e com concern, a título de comparação.

Isso me fez pensar: e Python?

Mixins

Já escrevi um artigo sobre mixins em Python, que deixa claro a complexidade quando fazemos a cola dos mixins na classe principal usando herança múltipla. Basta olhar a assinatura da classe para ver como isso pode ficar complicado com a adição de novos mixins:

class Grades(SerialisableGradeMixin, PersistenceMixin, GradesMixin):

Concern em Python

Pensando nisso, resolvi implementar uma versão de concern em Python.

O que precisamos fazer: a factory deve injetar no contexto em que ele for chamado métodos e atributos da classe mixin.

A primeira coisa que precisamos é ter acessível no corpo da função o contexto da criação da classe. Para isso usamos o módulo inspect.

O atributo f_locals do frame, retornado pela função frame.currentframe(), é equivalente a locals(), mas queremos os “locais” do frame que chama nossa função. Esse é o f_back:

context = inspect.currentframe().f_back.f_locals

Já temos o contexto da criação da classe à disposição, agora só falta injetar nele o dicionário de atributos do mixin:

context.update(aspect.__dict__)

O corpo todo do módulo Python fica:

# file: concerns.py
import inspect

def concern(aspect: type) -> None:
    context: dict = inspect.currentframe().f_back.f_locals
    context.update(aspect.__dict__)

Só isso já é suficiente! Porém não temos garantias de que funcione, precisamos de testes.

Teste unitário

Para testar, vamos criar um mixin de serialização e usá-lo num classe muito simples de pessoa.

O mixin terá dois métodos: um método de serialização e outro de classe de desserialização. Vamos usar JSON para a serialização, ordenando as chaves para facilitar os testes.

# file: serial.py
import json

class SerialMixin:

    def serialize(self) -> str:
        return json.dumps(self._asdict(), sort_keys=True)

    @classmethod
    def deserialize(cls, data: str):
        return cls(**json.loads(data))

A classe pessoa será uma nomeada – porém tuplas em Python são primitivas e não suportam acesso à seu dicionário de atributos.

Podemos contornar isso facilmente criando uma classe base, que será a tupla em si, e uma classe herdeira, que permite acesso ao dicionário de atributos:

# file: person.py
from typing import NamedTuple
from concerns import concern
from .serial import SerialMixin

class PersonBase(NamedTuple):
    name: str
    surname: str

class Person(PersonBase):
    concern(SerialMixin)

Já temos a classe com uso de mixin. Vamos criar agora o teste, que verifica se instâncias de pessoa podem ser serializadas e desserializadas:

# file: test_concern.py
from unittest import TestCase
from .person import Person

class TestConcern(TestCase):

    def test_serialize(self):
        p = Person(name='John', surname='Doe')
        self.assertEqual(
            p.serialize(),
            '{"name": "John", "surname": "Doe"}',
        )

    def test_deserialize(self):
        p = Person.deserialize('{"name": "J", "surname": "Quest"}')
        self.assertIsInstance(p, Person)
        self.assertEqual(p.name, "J")
        self.assertEqual(p.surname, "Quest")

Rodando os testes:

sh> python3 -munittest test_concern.py
..
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

Voltando ao exemplo inicial

E a classe Grades do exemplo anterior?

Usando nossa factory, seu cabeçalho ficaria assim:

class Grades:
    concern(SerialisableGradeMixin)
    concern(PersistenceMixin)
    concern(GradesMixin)

Perceba que, nessa abordagem, a adição de novos mixins não suja a assintura da classe.

[update 2018-08-16]

O projeto está no PyPI:

sh> pip3 install concerns

[/update]

Conceitual | Python