O impacto da tipagem no python
Os “type hints” chegaram no python na versão 3.5, o que permitiu aos programadores criar fluxos mais legíveis, facilitando a vida de quem precisa ler o código de outro desenvolvedor. Por que a tipagem precisa ser algo essencial na sua vida? Quando pegamos linguagens de programação fortemente tipadas como java, C++ e etc, um dos recursos/técnicas que julgo mais importante e que é bem difícil de se reproduzir nas linguagens fracamente tipadas é a inversão de dependência (DI – Dependency Inversion). A ideia fundamental na inversão de dependência é que devemos fazer com que nossas classes não dependam da implementação real, e sim de abstração, afinal as abstrações são contratos que raramente mudam. Exemplo ruim: class GasStation: def fill_tank(car, amount): car.fill(amount) No exemplo citado acima, o posto de gasolina somente poderia abastecer carros, ou até pior, como não temos tipagem definida na função “fill_tank”, qualquer valor poderia ser passado e o erro só seria pego em tempo de execução. Exemplo bom: from typing import Protocol class Vehicle(Protocol): def fill(amount: int) -> None: ... class GasStation: def fill_tank(vehicle: Vehicle, amount: int) -> None: vehicle.fill(amount) No exemplo, primeiro construímos a classe abstrata Vehicle, usando a classe Protocol do módulo typing do python. Com a classe construída, implementamos a classe GasStation, que ao invés de esperar um carro na função “fill_tank”, agora espera um veículo, ou seja, ficou mais genérico, podendo agora abastecer qualquer veículo que implemente o método “fill” definido na classe abstrata Vehicle. O que é PyDIT? Tirando proveito desse novo sistema de tipagem, construí uma biblioteca que facilita o uso da inversão de dependência, o nome dela é PyDIT (Python Dependency Injection with Types) Vamos pensar que precisamos de um local para gravar usuários na nossa base de dados, independente se ela usa PostgreSQL, MySQL, OracleDB, em memória ou algum banco NoSQL. Para isso, precisamos implementar a classe que vai fazer a conexão com o banco e disponibilizar as funções para ler, gravar e deletar registros. from time import sleep from typing import TypedDict from typing_extensions import override from uuid import UUID from src.configs.di import pydit from src.adapters.repositories.interfaces.user import UserRepository from src.constants.injection import MEMORY_REPOSITORY_CONFIG_TOKEN from src.domain.user.models.user import UserModel class ConfigType(TypedDict): delay: int class MemoryUserRepository(UserRepository): __users: dict[UUID, UserModel] = {} def __init__(self): self.__delay = self.config.get("delay", 0.2) @pydit.inject(token=MEMORY_REPOSITORY_CONFIG_TOKEN) def config(self) -> ConfigType: # TODO: supress return type error pass @override def get_by_id(self, *, id_: UUID) -> UserModel: sleep(self.__delay) user = self.__users.get(id_) if user is None: raise ValueError("User not found") return user @override def save(self, *, data: UserModel) -> None: sleep(self.__delay) self._check_pk_conflict(pk=data.id) self.__users[data.id] = data @override def list_(self) -> list[UserModel]: return list(self.__users.values()) def _check_pk_conflict(self, *, pk: UUID) -> None: if pk not in self.__users: return raise ValueError("Primary key conflicts: DB alrady has a user with this ID") Depois de implementadas, como garantimos que nosso código vai funcionar independente da tecnologia usada para banco? Simples, definiremos um contrato para que todas essas classes sigam. from abc import abstractmethod from typing import Protocol from uuid import UUID from src.domain.user.models.user import UserModel class UserRepository(Protocol): @abstractmethod def get_by_id(self, *, id_: UUID) -> UserModel: pass @abstractmethod def save(self, *, data: UserModel) -> None: pass @abstractmethod def list_(self) -> list[UserModel]: pass Com nosso contrato definido, vamos inicializar nossa dependência, para que a gente possa injeta-la. from src.adapters.repositories.in_memory.user import MemoryUserRepository from src.constants.injection import MEMORY_REPOSITORY_CONFIG_TOKEN from .di import pydit from .get_db_config import get_db_config def setup_dependencies(): pydit.add_dependency(get_db_config, token=MEMORY_REPOSITORY_CONFIG_TOKEN) pydit.add_dependency(MemoryUserRepository, "UserRepository") Com as dependências inicializadas, vamos injetar ela no nosso módulo que tem como objetivo criar usuários: from typing import cast from src.adapters.repositories.interfaces.user import UserRepository from src.configs.di import pydit from src.domain.user.models.create_user import CreateUserModel from src.domain.user.models.user import
Os “type hints” chegaram no python na versão 3.5, o que permitiu aos programadores criar fluxos mais legíveis, facilitando a vida de quem precisa ler o código de outro desenvolvedor.
Por que a tipagem precisa ser algo essencial na sua vida?
Quando pegamos linguagens de programação fortemente tipadas como java, C++ e etc, um dos recursos/técnicas que julgo mais importante e que é bem difícil de se reproduzir nas linguagens fracamente tipadas é a inversão de dependência (DI – Dependency Inversion).
A ideia fundamental na inversão de dependência é que devemos fazer com que nossas classes não dependam da implementação real, e sim de abstração, afinal as abstrações são contratos que raramente mudam.
Exemplo ruim:
class GasStation:
def fill_tank(car, amount):
car.fill(amount)
No exemplo citado acima, o posto de gasolina somente poderia abastecer carros, ou até pior, como não temos tipagem definida na função “fill_tank”, qualquer valor poderia ser passado e o erro só seria pego em tempo de execução.
Exemplo bom:
from typing import Protocol
class Vehicle(Protocol):
def fill(amount: int) -> None:
...
class GasStation:
def fill_tank(vehicle: Vehicle, amount: int) -> None:
vehicle.fill(amount)
No exemplo, primeiro construímos a classe abstrata Vehicle, usando a classe Protocol do módulo typing do python. Com a classe construída, implementamos a classe GasStation, que ao invés de esperar um carro na função “fill_tank”, agora espera um veículo, ou seja, ficou mais genérico, podendo agora abastecer qualquer veículo que implemente o método “fill” definido na classe abstrata Vehicle.
O que é PyDIT?
Tirando proveito desse novo sistema de tipagem, construí uma biblioteca que facilita o uso da inversão de dependência, o nome dela é PyDIT (Python Dependency Injection with Types)
Vamos pensar que precisamos de um local para gravar usuários na nossa base de dados, independente se ela usa PostgreSQL, MySQL, OracleDB, em memória ou algum banco NoSQL. Para isso, precisamos implementar a classe que vai fazer a conexão com o banco e disponibilizar as funções para ler, gravar e deletar registros.
from time import sleep
from typing import TypedDict
from typing_extensions import override
from uuid import UUID
from src.configs.di import pydit
from src.adapters.repositories.interfaces.user import UserRepository
from src.constants.injection import MEMORY_REPOSITORY_CONFIG_TOKEN
from src.domain.user.models.user import UserModel
class ConfigType(TypedDict):
delay: int
class MemoryUserRepository(UserRepository):
__users: dict[UUID, UserModel] = {}
def __init__(self):
self.__delay = self.config.get("delay", 0.2)
@pydit.inject(token=MEMORY_REPOSITORY_CONFIG_TOKEN)
def config(self) -> ConfigType: # TODO: supress return type error
pass
@override
def get_by_id(self, *, id_: UUID) -> UserModel:
sleep(self.__delay)
user = self.__users.get(id_)
if user is None:
raise ValueError("User not found")
return user
@override
def save(self, *, data: UserModel) -> None:
sleep(self.__delay)
self._check_pk_conflict(pk=data.id)
self.__users[data.id] = data
@override
def list_(self) -> list[UserModel]:
return list(self.__users.values())
def _check_pk_conflict(self, *, pk: UUID) -> None:
if pk not in self.__users:
return
raise ValueError("Primary key conflicts: DB alrady has a user with this ID")
Depois de implementadas, como garantimos que nosso código vai funcionar independente da tecnologia usada para banco? Simples, definiremos um contrato para que todas essas classes sigam.
from abc import abstractmethod
from typing import Protocol
from uuid import UUID
from src.domain.user.models.user import UserModel
class UserRepository(Protocol):
@abstractmethod
def get_by_id(self, *, id_: UUID) -> UserModel:
pass
@abstractmethod
def save(self, *, data: UserModel) -> None:
pass
@abstractmethod
def list_(self) -> list[UserModel]:
pass
Com nosso contrato definido, vamos inicializar nossa dependência, para que a gente possa injeta-la.
from src.adapters.repositories.in_memory.user import MemoryUserRepository
from src.constants.injection import MEMORY_REPOSITORY_CONFIG_TOKEN
from .di import pydit
from .get_db_config import get_db_config
def setup_dependencies():
pydit.add_dependency(get_db_config, token=MEMORY_REPOSITORY_CONFIG_TOKEN)
pydit.add_dependency(MemoryUserRepository, "UserRepository")
Com as dependências inicializadas, vamos injetar ela no nosso módulo que tem como objetivo criar usuários:
from typing import cast
from src.adapters.repositories.interfaces.user import UserRepository
from src.configs.di import pydit
from src.domain.user.models.create_user import CreateUserModel
from src.domain.user.models.user import UserModel
from src.domain.user.services.create import CreateUserService
from src.domain.user.services.list import ListUsersService
class UserModule:
@pydit.inject()
def user_repository(self) -> UserRepository:
return cast(UserRepository, None)
def create(self, data: CreateUserModel) -> None:
CreateUserService(self.user_repository).execute(data)
def list_(self) -> list[UserModel]:
return ListUsersService().execute()
Como podemos ver, nossa dependência foi injetada como uma property, sendo possível utilizar ela via “self” ou referenciando a instância do módulo “module.user_repository”.
O exemplo aqui tem como objetivo ser simples, porém, utilizando PyDIT vários cenários de configuração de projetos, abstração de código e uso do SOLID podem ser alcançados, então sinta-se a vontade para experimentar e contribuir com o projeto :D
Repostiório: Github
Linkedin: Marcelo Almeida (MrM4rc)
Pypi: python-pydit