Uno de los principales problemas al desarrollar software siempre son los tests, muchas veces esto se convierte en una deuda técnica o cuando se realizan los test se vuelven muy complejos de escribir, leer, entender e incluso mantener, una de las principales razones son sus dependencias externas, esto generalmente se traduce en tener mocks muy complejos y algunos incluso podrían considerar esto como code smell, en este contexto es donde  entra en juego la inyección de dependencias Ver Figura 1, el concepto no están fácil de abordar, por eso este articulo se dividirá en dos partes, la primera enfocada a las dependencias y la segunda a tests. Iniciando con este concepto de inyección de dependencias, consiste en pensar en las librerías que se necesitan como abstracciones y en tiempo de ejecución enviársela a cada función o método para que lo llame e implemente su lógica, esto va muy de la mano con orientación a objetos, pero no quiere decir que en programación funcional no se pueda hacer, en python tenemos una singularidad y es que python es un lenguaje dinámico, es decir no tiene tipo de datos, pero nos podemos valer de los typing para construir nuestra entrada de dependencias, para ellos utilizaremos un framework llamado dependency injector

Figura 1 inyección de dependencias

Utilizando el framework dependecy injector

Este es un framework que nos facilita la utilización de inyección de dependencias en python, este framework se soporta sobre varios principios, los cuales destaco los siguientes:

  • Providers estos son los ensambladores o creadores de instancias y encargados de inyectarlos, son los que contienen las referencias y configuración de lo que se inyectara
  • Typing los provider son mypy-friendly es decir a pesar de que la configuración se encapsula en ensambladores, los editores de código reconocen que tipos de datos se están trabajando
  • Containers son gestores de providers, al tener muchos providers puede existir la necesidad de agruparlos para que su gestion sea mas sencilla, los containers ayudan a  sincronizarlos
  • Perfomance este framework esta escrito en ctyhon lo que permite agregar un plus en el rendimiento

Antes de iniciar se debe instalar injector, yo utilizando poetry lo hago muy sencillo con el siguiente comando

poetry add dependency-injector

con estos conceptos claros, podemos pasar al código, voy a crear 6 archivos  llamados domain.py, repository.py, infraestructure.py, injection.py, main.py  y config.ini los cuales escribo a continuación

from dataclasses import dataclass


@dataclass
class Document:
    location: str
    name: str
    id: int = 0
Archivo domain.py
from abc import ABC, abstractmethod
from domain import Document
from io import BytesIO


class DatabaseDocumentRepository(ABC):
    @abstractmethod
    def save(self, document: Document) -> None:
        raise NotImplementedError
    
    @abstractmethod
    def get_by_id(self, id: int) -> Document:
        raise NotImplementedError


class Bucket(ABC):
    @abstractmethod
    def upload(self, file:BytesIO):
        raise NotImplementedError
Archivo repository.py
from repository import DatabaseDocumentRepository, Bucket
from domain import Document
from sqlite3 import Connection
from io import BytesIO
from mypy_boto3_s3 import S3Client


class DatabaseDocumentSQLite(DatabaseDocumentRepository):

    def __init__(self, connection:Connection):
        self.connection = connection
        self.table_name = "document"
        try:
            cursor = self.connection.cursor()
            cursor.execute( f""" CREATE TABLE IF NOT EXISTS {self.table_name} (
                                        id integer PRIMARY KEY,
                                        name text NOT NULL,
                                        location text); """)
            self.connection.commit()
        except Exception as e:
            print(e)

    def save(self, document: Document) -> None:
        cursor = self.connection.cursor()
        cursor.execute( """INSERT INTO document(name, location) VALUES (:name, :location)""", document.__dict__)

        self.connection.commit()
        
    def get_by_id(self, id: int) -> Document:
        pass


class BucketS3(Bucket):
    def __init__(self, client:S3Client):
        self.client = client

    def upload(self, file:BytesIO)->None:
        self.client.upload_fileobj(Fileobj=file, Bucket="ejemplo-test", Key="myfile.txt")


Archivo infrastructure.py
import sqlite3
import boto3
from dependency_injector import providers, containers
import infrastructure


class Container(containers.DeclarativeContainer):

    config = providers.Configuration(ini_files=["config.ini"])
    database_client = providers.Singleton(
        sqlite3.connect,
        config.database.dsn,
    )
    s3_client = providers.Singleton(
        boto3.client,
        service_name="s3",
        aws_access_key_id=config.aws.access_key_id,
        aws_secret_access_key=config.aws.secret_access_key,
    )

    document_database = providers.Factory(
        infrastructure.DatabaseDocumentSQLite,
        connection=database_client,
    )
    bucket = providers.Factory(
        infrastructure.BucketS3,
        client=s3_client,
    )
archivo injection.py

from dependency_injector.wiring import Provide, inject
from domain import Document
from repository import DatabaseDocumentRepository, Bucket
from injection import Container
from io import BytesIO


@inject
def create_document(
        name:str,
        location:str,
        file: BytesIO,
        document_database: DatabaseDocumentRepository = Provide[Container.document_database],
        bucket: Bucket = Provide[Container.bucket]

) -> bool:
    try:
        document = Document(name=name, location=location)
        document_database.save(document)
        bucket.upload(file)
        return True
    except Exception as error:
        print(error)
        return False

def execute(name:str, location:str, file:BytesIO):

    container = Container()
    container.init_resources()
    container.wire(modules=[__name__])
    result = create_document(
            name=name,
            location=location,
            file=file
    )

    return result


if __name__ == "__main__":

    with open("example.txt", "rb") as file:
        result = execute("example.txt", "myfolder/example.txt", file=file)

    if result:
        print("ok")
    else:
        print("error")



Archivo main.py
[database]
dsn=mydb.db

[aws]
access_key_id=KEY
secret_access_key=SECRET

Archivo config.ini
  • Domain aquí se tiene la clase del dominio en este caso seria una clase solamente con atributos que representan un documento
  • Repository este modulo contiene el código abstracto que usaremos para las implementaciones según nuestra necesidad, en este caso definí dos abstracciones, uno para la base de datos y otro para el almacenamiento de datos
  • Infrastructure este contiene la implementacion especifica para las abstracciones que anteriormente cree, para ello creo un constructor que recibe como parámetro el cliente especifico para cada implementacion, para el caso de SQLite es una instancia de Connection
  • Injection aquí ocurre la magia, en este modulo es donde realmente se realizan las inyecciones, la clase container como mencione arriba, gestiona los providers, para ellos los tenemos de diferentes tipos, en el caso de la base de datos como necesitamos solo una conexión usamos un provider de tipo  Singleton y como parametro a la instancia recibe un dsn que cargamos anteriormente de otro provider de tipo config, este provider permite gestionar datos de configuración de los archivos .ini y ademas permite referenciar datos de tipo clases, una vez declarados estos provider, se instancia los de tipo Factory, que trabajan con cualquier tipo de clases y aquí reciben como parámetro los providers que ya habia inicado arriba, esto por lo siguiente
provider1()
│
├──> provider2()
│
├──> provider3()
│    │
│    └──> provider4()
│
└──> provider5()
     │
     └──> provider6()

como ven los providers se pueden referenciar entre si y el framework realiza el trabajo de instanciar y traducir todo esto a objetos

  • main aquí se invoca el código a ejecutar, lo interesante aquí es el @injector que es un decorector que permite indicar en el metodo que recibira la injeccion de dependencias,  aqui por cada  parametro se llama al container, por ello las siguientes lineas de código son muy importantes
  container = Container()
  container.init_resources()
  container.wire(modules=[__name__])

estas lineas lo que hacen es inicializar todos los objetos necesarios o requeridos en las dependencias, por lo tanto cuando se cargue la aplicación estos quedan en memoria, esto implica que probablemente la aplicación tenga un inicio lento,  esto varia  en función a la cantidad de objetos que se carguen, pero al ventaja a largo plazo es que se tienen los objetos listos para usarse, convirtiendo esto es una "inversión", teniendo un inicio lento pero durante el tiempo de vida de la aplicación, se tendrá acceso rápido a los objetos necesarios, por lo tanto es de vital importancia que la inicializacion este junto al punto de entrada del proyecto

Conclusiones

Trabajar con inyección de dependencias permite tener un código menos acoplado y con mayor cohesión Ver Figura 2, separando de esta manera responsabilidades, trayendo una alta reutilizacion de codigo, creando un efecto positivo a la hora de escribir test y agregando una mayor agilidad  al proceso de parametrizacion, debido a que injector lo gestiona de una forma eficiente, para comprobar que los test son mas sencillos lo veremos en un siguiente articulo

Figura 2: A menor acoplamiento mayor cohesión. Obtenido de https://python-dependency-injector.ets-labs.org/introduction/di_in_python.html