python-type-safety

Par wshobson · agents

Sécurité des types Python avec les annotations de type, les génériques, les protocoles et la vérification stricte des types. À utiliser lors de l'ajout d'annotations de type, de l'implémentation de classes génériques, de la définition d'interfaces structurelles, ou de la configuration de mypy/pyright.

npx skills add https://github.com/wshobson/agents --skill python-type-safety

Sécurité des Types en Python

Exploitez le système de types de Python pour capturer les erreurs lors de l'analyse statique. Les annotations de type servent de documentation appliquée que les outils valident automatiquement.

Quand Utiliser Cette Compétence

  • Ajouter des indications de type au code existant
  • Créer des classes génériques et réutilisables
  • Définir des interfaces structurelles avec des protocoles
  • Configurer mypy ou pyright pour une vérification stricte
  • Comprendre le rétrécissement de type et les gardes
  • Construire des APIs et des bibliothèques type-safe

Concepts Fondamentaux

1. Annotations de Type

Déclarez les types attendus pour les paramètres de fonction, les valeurs de retour et les variables.

2. Génériques

Écrivez du code réutilisable qui préserve les informations de type sur différents types.

3. Protocoles

Définissez des interfaces structurelles sans héritage (duck typing avec sécurité de type).

4. Rétrécissement de Type

Utilisez des gardes et des conditions pour réduire les types dans les blocs de code.

Démarrage Rapide

def get_user(user_id: str) -> User | None:
    """Le type de retour rend explicite « pourrait ne pas exister »."""
    ...

# Le vérificateur de type force la gestion du cas None
user = get_user("123")
if user is None:
    raise UserNotFoundError("123")
print(user.name)  # Le vérificateur de type sait que user est User ici

Modèles Fondamentaux

Modèle 1 : Annoter Toutes les Signatures Publiques

Chaque fonction publique, méthode et classe doit avoir des annotations de type.

def get_user(user_id: str) -> User:
    """Récupérer l'utilisateur par ID."""
    ...

def process_batch(
    items: list[Item],
    max_workers: int = 4,
) -> BatchResult[ProcessedItem]:
    """Traiter les éléments de manière concurrente."""
    ...

class UserRepository:
    def __init__(self, db: Database) -> None:
        self._db = db

    async def find_by_id(self, user_id: str) -> User | None:
        """Retourner User si trouvé, None sinon."""
        ...

    async def find_by_email(self, email: str) -> User | None:
        ...

    async def save(self, user: User) -> User:
        """Sauvegarder et retourner l'utilisateur avec l'ID généré."""
        ...

Utilisez mypy --strict ou pyright en CI pour capturer les erreurs de type en amont. Pour les projets existants, activez le mode strict progressivement en utilisant des remplacements par module.

Modèle 2 : Utiliser la Syntaxe Union Moderne

Python 3.10+ offre une syntaxe d'union plus propre.

# Préféré (3.10+)
def find_user(user_id: str) -> User | None:
    ...

def parse_value(v: str) -> int | float | str:
    ...

# Style ancien (toujours valide, nécessaire pour 3.9)
from typing import Optional, Union

def find_user(user_id: str) -> Optional[User]:
    ...

Modèle 3 : Rétrécissement de Type avec Gardes

Utilisez des conditions pour réduire les types pour le vérificateur de type.

def process_user(user_id: str) -> UserData:
    user = find_user(user_id)

    if user is None:
        raise UserNotFoundError(f"User {user_id} not found")

    # Le vérificateur de type sait que user est User ici, pas User | None
    return UserData(
        name=user.name,
        email=user.email,
    )

def process_items(items: list[Item | None]) -> list[ProcessedItem]:
    # Filtrer et réduire les types
    valid_items = [item for item in items if item is not None]
    # valid_items est maintenant list[Item]
    return [process(item) for item in valid_items]

Modèle 4 : Classes Génériques

Créez des conteneurs réutilisables et type-safe.

from typing import TypeVar, Generic

T = TypeVar("T")
E = TypeVar("E", bound=Exception)

class Result(Generic[T, E]):
    """Représente soit une valeur de succès soit une erreur."""

    def __init__(
        self,
        value: T | None = None,
        error: E | None = None,
    ) -> None:
        if (value is None) == (error is None):
            raise ValueError("Exactly one of value or error must be set")
        self._value = value
        self._error = error

    @property
    def is_success(self) -> bool:
        return self._error is None

    @property
    def is_failure(self) -> bool:
        return self._error is not None

    def unwrap(self) -> T:
        """Obtenir la valeur ou lever l'erreur."""
        if self._error is not None:
            raise self._error
        return self._value  # type: ignore[return-value]

    def unwrap_or(self, default: T) -> T:
        """Obtenir la valeur ou retourner la valeur par défaut."""
        if self._error is not None:
            return default
        return self._value  # type: ignore[return-value]

# L'usage préserve les types
def parse_config(path: str) -> Result[Config, ConfigError]:
    try:
        return Result(value=Config.from_file(path))
    except ConfigError as e:
        return Result(error=e)

result = parse_config("config.yaml")
if result.is_success:
    config = result.unwrap()  # Type : Config

Modèles Avancés

Modèle 5 : Dépôt Générique

Créez des modèles d'accès aux données type-safe.

from typing import TypeVar, Generic
from abc import ABC, abstractmethod

T = TypeVar("T")
ID = TypeVar("ID")

class Repository(ABC, Generic[T, ID]):
    """Interface de dépôt générique."""

    @abstractmethod
    async def get(self, id: ID) -> T | None:
        """Obtenir l'entité par ID."""
        ...

    @abstractmethod
    async def save(self, entity: T) -> T:
        """Sauvegarder et retourner l'entité."""
        ...

    @abstractmethod
    async def delete(self, id: ID) -> bool:
        """Supprimer l'entité, retourner True si elle existait."""
        ...

class UserRepository(Repository[User, str]):
    """Dépôt concret pour les utilisateurs avec des IDs en chaîne."""

    async def get(self, id: str) -> User | None:
        row = await self._db.fetchrow(
            "SELECT * FROM users WHERE id = $1", id
        )
        return User(**row) if row else None

    async def save(self, entity: User) -> User:
        ...

    async def delete(self, id: str) -> bool:
        ...

Modèle 6 : TypeVar avec Limites

Restreignez les paramètres génériques à des types spécifiques.

from typing import TypeVar
from pydantic import BaseModel

ModelT = TypeVar("ModelT", bound=BaseModel)

def validate_and_create(model_cls: type[ModelT], data: dict) -> ModelT:
    """Créer un modèle Pydantic validé à partir d'un dict."""
    return model_cls.model_validate(data)

# Fonctionne avec n'importe quelle sous-classe BaseModel
class User(BaseModel):
    name: str
    email: str

user = validate_and_create(User, {"name": "Alice", "email": "a@b.com"})
# user est typé en tant que User

# Erreur de type : str n'est pas une sous-classe BaseModel
result = validate_and_create(str, {"name": "Alice"})  # Error!

Modèle 7 : Protocoles pour le Typage Structurel

Définissez des interfaces sans nécessiter l'héritage.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Serializable(Protocol):
    """N'importe quelle classe qui peut être sérialisée vers/depuis un dict."""

    def to_dict(self) -> dict:
        ...

    @classmethod
    def from_dict(cls, data: dict) -> "Serializable":
        ...

# User satisfait Serializable sans en hériter
class User:
    def __init__(self, id: str, name: str) -> None:
        self.id = id
        self.name = name

    def to_dict(self) -> dict:
        return {"id": self.id, "name": self.name}

    @classmethod
    def from_dict(cls, data: dict) -> "User":
        return cls(id=data["id"], name=data["name"])

def serialize(obj: Serializable) -> str:
    """Fonctionne avec n'importe quel objet Serializable."""
    return json.dumps(obj.to_dict())

# Fonctionne - User correspond au protocole
serialize(User("1", "Alice"))

# Vérification à l'exécution avec @runtime_checkable
isinstance(User("1", "Alice"), Serializable)  # True

Modèle 8 : Modèles de Protocole Courants

Définissez des interfaces structurelles réutilisables.

from typing import Protocol

class Closeable(Protocol):
    """Ressource qui peut être fermée."""
    def close(self) -> None: ...

class AsyncCloseable(Protocol):
    """Ressource asynchrone qui peut être fermée."""
    async def close(self) -> None: ...

class Readable(Protocol):
    """Objet à partir duquel on peut lire."""
    def read(self, n: int = -1) -> bytes: ...

class HasId(Protocol):
    """Objet avec une propriété ID."""
    @property
    def id(self) -> str: ...

class Comparable(Protocol):
    """Objet qui supporte la comparaison."""
    def __lt__(self, other: "Comparable") -> bool: ...
    def __le__(self, other: "Comparable") -> bool: ...

Modèle 9 : Alias de Type

Créez des noms de type significatifs.

Note : La syntaxe de l'instruction type Alias = ... (PEP 695) a été introduite en Python 3.12, non en 3.10. Pour les projets ciblant les versions antérieures (incluant 3.10/3.11), utilisez l'annotation TypeAlias (PEP 613, disponible depuis Python 3.10).

# Instruction type Python 3.12+ (PEP 695)
type UserId = str
type UserDict = dict[str, Any]

# Instruction type Python 3.12+ avec génériques (PEP 695)
type Handler[T] = Callable[[Request], T]
type AsyncHandler[T] = Callable[[Request], Awaitable[T]]
# Style Python 3.10-3.11 (nécessaire pour une meilleure compatibilité)
from typing import TypeAlias
from collections.abc import Callable, Awaitable

UserId: TypeAlias = str
Handler: TypeAlias = Callable[[Request], Response]
# Utilisation
def register_handler(path: str, handler: Handler[Response]) -> None:
    ...

Modèle 10 : Types Callable

Typez les paramètres de fonction et les callbacks.

from collections.abc import Callable, Awaitable

# Callback synchrone
ProgressCallback = Callable[[int, int], None]  # (current, total)

# Callback asynchrone
AsyncHandler = Callable[[Request], Awaitable[Response]]

# Avec paramètres nommés (utilisant Protocol)
class OnProgress(Protocol):
    def __call__(
        self,
        current: int,
        total: int,
        *,
        message: str = "",
    ) -> None: ...

def process_items(
    items: list[Item],
    on_progress: ProgressCallback | None = None,
) -> list[Result]:
    for i, item in enumerate(items):
        if on_progress:
            on_progress(i, len(items))
        ...

Configuration

Liste de Vérification du Mode Strict

Pour la conformité mypy --strict :

# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_ignores = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
no_implicit_optional = true

Objectifs d'adoption progressive :

  • Tous les paramètres de fonction annotés
  • Tous les types de retour annotés
  • Attributs de classe annotés
  • Minimiser l'utilisation de Any (acceptable pour les données véritablement dynamiques)
  • Les collections génériques utilisent des paramètres de type (list[str] pas list)

Pour les bases de code existantes, activez le mode strict par module en utilisant # mypy: strict ou configurez des remplacements par module dans pyproject.toml.

Résumé des Bonnes Pratiques

  1. Annoter toutes les APIs publiques - Fonctions, méthodes, attributs de classe
  2. Utiliser T | None - Syntaxe d'union moderne plutôt que Optional[T]
  3. Exécuter une vérification stricte de type - mypy --strict en CI
  4. Utiliser les génériques - Préserver les informations de type dans le code réutilisable
  5. Définir des protocoles - Typage structurel pour les interfaces
  6. Réduire les types - Utiliser des gardes pour aider le vérificateur de type
  7. Lier les variables de type - Restreindre les génériques à des types significatifs
  8. Créer des alias de type - Noms significatifs pour les types complexes
  9. Minimiser Any - Utiliser des types spécifiques ou des génériques. Any est acceptable pour les données véritablement dynamiques ou lors de l'interfaçage avec du code tiers non typé
  10. Documenter avec des types - Les types sont une documentation applicable

Skills similaires