Python >> Tutoriel Python >  >> Python

Vérification de l'exhaustivité avec Mypy

Mypy est un vérificateur de type statique facultatif pour Python. Il existe depuis 2012 et gagne du terrain depuis. L'un des principaux avantages de l'utilisation d'un vérificateur de type est d'obtenir des erreurs au "moment de la compilation" plutôt qu'au moment de l'exécution.

La vérification de l'exhaustivité est une caractéristique courante des vérificateurs de type, et très utile ! Dans cet article, je vais vous montrer comment vous pouvez faire en sorte que mypy effectue une vérification d'exhaustivité !

Vérification de l'exhaustivité

Disons que vous avez un système pour gérer les commandes. Pour représenter le statut d'une commande, vous disposez de l'énumération suivante :

import enum

class OrderStatus(enum.Enum):
 Ready = 'ready'
 Shipped = 'shipped'

Vous disposez également du code suivant pour traiter un Order :

def handle_order(status: OrderStatus) -> None:
 if status is OrderStatus.Ready:
 print('ship order')

 elif status is OrderStatus.Shipped:
 print('charge order')

Lorsque la commande est prête, vous l'expédiez; et lorsqu'il est expédié, vous le facturez.

Quelques mois passent et votre système devient gros. Si grand en fait, que vous ne pouvez plus expédier les commandes immédiatement, et vous ajoutez un nouveau statut :

import enum

class OrderStatus(enum.Enum):
 Ready = 'ready'
 Scheduled = 'scheduled'
 Shipped = 'shipped'

Avant de pousser ce changement en production, vous exécutez une vérification rapide avec mypy pour vous assurer que tout va bien :

$ mypy main.py
Success: no issues found in 1 source file

Mypy ne voit rien de mal dans ce code, pensez-vous ? Le problème est que vous avez oublié de gérer le nouveau statut dans votre fonction .

Une façon de vous assurer que vous gérez toujours tous les statuts de commande possibles consiste à ajouter une assertion ou à lever une exception :

def handle_order(status: OrderStatus) -> None:
 if status is OrderStatus.Ready:
 print('ship order')

 elif status is OrderStatus.Shipped:
 print('charge order')

 assert False, f'Unhandled status "{status}"'

Maintenant, lorsque vous exécutez la fonction avec le nouveau statut OrderStatus.Scheduled , vous obtiendrez une erreur d'exécution :

>>> handle_order(OrderStatus.Scheduled)
AssertionError: Unhandled status "OrderStatus.Scheduled"

Une autre façon de traiter des cas comme celui-ci consiste à parcourir votre suite de tests et à ajouter des scénarios dans tous les endroits qui utilisent le statut de la commande. Mais... si vous avez oublié de changer la fonction lorsque vous avez ajouté le statut, quelles sont les chances que vous vous souveniez de mettre à jour les tests ? Ce n'est pas une bonne solution...

Vérification de l'exhaustivité dans Mypy

Et si mypy pouvait vous avertir au "moment de la compilation" de tels cas ? Eh bien... c'est possible, en utilisant cette petite fonction magique :

from typing import NoReturn
import enum

def assert_never(value: NoReturn) -> NoReturn:
 assert False, f'Unhandled value: {value} ({type(value).__name__})'

Avant de creuser dans l'implémentation, essayez de l'utiliser pour voir comment cela fonctionne. Dans la fonction ci-dessus, placez assert_never après avoir traité tous les statuts de commande possibles, là où vous utilisiez auparavant assert ou déclenche une exception :

def handle_order(status: OrderStatus) -> None:
 if status is OrderStatus.Ready:
 print('ship order')

 elif status is OrderStatus.Shipped:
 print('charge order')

 else:
 assert_never(status)

Maintenant, vérifiez le code avec Mypy :

$ mypy main.py
error: Argument 1 to "assert_never" has incompatible type "Literal[OrderStatus.Scheduled]";
expected "NoReturn"
Found 1 error in 1 file (checked 1 source file)

Étonnante! Mypy vous avertit d'un statut que vous avez oublié de gérer ! Le message inclut également la valeur, OrderStatus.Scheduled . Si vous utilisez un éditeur moderne tel que VSCode, vous pouvez obtenir ces avertissements dès que vous tapez :

Vous pouvez maintenant continuer et corriger votre fonction pour gérer le statut manquant :

def handle_order(status: OrderStatus) -> None:
 if status is OrderStatus.Pending:
 print('schedule order')

 elif status is OrderStatus.Scheduled:
 print('ship order')

 elif status is OrderStatus.Shipped:
 print('charge order')

 else:
 assert_never(status)

Vérifiez à nouveau avec mypy :

$ mypy main.py
Success: no issues found in 1 source file

Super! Vous pouvez maintenant être assuré que vous avez géré tous les statuts de commande. La meilleure partie est que vous l'avez fait sans pas de tests unitaires , et il n'y avait aucune erreur d'exécution . Si vous incluez mypy dans votre CI, le mauvais code ne passera jamais en production .

Types d'énumération

Dans la section précédente, vous avez utilisé mypy pour effectuer un contrôle d'exhaustivité sur un Enum . Vous pouvez utiliser mypy et assert_never pour effectuer un contrôle d'exhaustivité sur d'autres types d'énumération également.

Vérification de l'exhaustivité d'une union

Un Union type représente plusieurs types possibles. Par exemple, une fonction qui transtype un argument en float peut ressembler à ceci :

from typing import Union

def get_float(num: Union[str, float]) -> float:
 if isinstance(num, str):
 return float(num)

 else:
 assert_never(num)

Vérifiez la fonction avec mypy :

$ mypy main.py
error: Argument 1 to "assert_never" has incompatible type "float"; expected "NoReturn"

Oups... vous avez oublié de gérer le float tapez le code :

from typing import Union

def get_float(num: Union[str, float]) -> float:
 if isinstance(num, str):
 return float(num)

 elif isinstance(num, float):
 return num

 else:
 assert_never(num)

Vérifiez à nouveau :

$ mypy main.py
Success: no issues found in 1 source file

Super! mypy est heureux...

Vérification de l'exhaustivité d'un littéral

Un autre type utile est Literal . Il est inclus dans le typing intégré module depuis Python3.8, et avant cela, il fait partie du complément typing_extensions paquet.

Un Literal est utilisé pour taper des valeurs primitives telles que des chaînes et des nombres. Literal est également un type d'énumération, vous pouvez donc également utiliser la vérification d'exhaustivité :

from typing_extensions import Literal

Color = Literal['R', 'G', 'B']

def get_color_name(color: Color) -> str:
 if color == 'R':
 return 'Red'
 elif color == 'G':
 return 'Green'
 # elif color == 'B':
 # return 'Blue'
 else:
 assert_never(color)

Vérifier le code sans la partie commentée produira l'erreur suivante :

$ mypy main.py
error: Argument 1 to "assert_never" has incompatible type "Literal['B']"; expected "NoReturn"

Très pratique en effet !

Restriction de type dans Mypy

Maintenant que vous avez vu ce que assert_never peut faire, vous pouvez essayer de comprendre comment cela fonctionne. assert_never fonctionne avec "réduction de type" , qui est une fonctionnalité mypy où le type d'une variable est restreint en fonction du flux de contrôle du programme. En d'autres termes, mypy élimine progressivement les types possibles pour une variable.

Tout d'abord, il est important de comprendre comment différentes choses se traduisent par un Union tapez mypy :

Optional[int]
# Equivalent to Union[int, None]

Literal['string', 42, True]
# Equivalent to Union[Literal['string'], Literal[42], Literal[True]]

class Suit(Enum):
 Clubs = "♣"
 Diamonds = "♦"
 Hearts = "♥"
 Spades = "♠"

Suit
# ~Equivalent to Union[
# Literal[Suit.Clubs],
# Literal[Suit.Diamonds],
# Literal[Suit.Hearts],
# Literal[Suit.Spades]
# ]

Pour afficher le type d'une expression, mypy fournit un utilitaire utile appelé reveal_type . Utilisation de reveal_type vous pouvez demander à mypy de vous montrer le type déduit d'une variable au moment où elle est appelée :

def describe_suit(suit: Optional[Suit]) -> str:
 # Revealed type is Union[Suit, None]
 reveal_type(suit)

Dans la fonction ci-dessus, le type révélé de suit est Union[Suit, None] , qui est le type de l'argument suit .

À ce stade, vous n'avez rien fait dans la fonction, donc mypy est incapable de réduire le type. Ensuite, ajoutez un peu de logique et voyez comment mypy réduit le type de la variable suit :

def describe_suit(suit: Optional[Suit]) -> str:
 assert suit is not None
 # Revealed type is Suit
 reveal_type(suit)

Après avoir éliminé l'option de costume étant None , le type révélé est Suit . Mypy a utilisé la logique de votre programme pour restreindre le type de la variable.

Gardez à l'esprit que le type Suit est équivalent au type Union[Literal[Suit.Clubs], Literal[Suit.Diamonds], Literal[Suit.Hearts], Literal[Suit.Spades]] , alors essayez ensuite d'affiner encore plus le type :

def describe_suit(suit: Optional[Suit]) -> str:
 assert suit is not None

 if suit is Suit.Clubs:
 # Revealed type is Literal[Suit.Clubs]
 reveal_type(suit)
 return "Clubs"

 # Revealed type is Literal[Suit.Diamonds, Suit.Hearts, Suit.Spades]
 reveal_type(suit)

Après avoir vérifié si suit est Suit.Clubs , mypy est capable de réduire le type à Suit.Clubs . Mypy est également assez intelligent pour comprendre que si la condition ne tient pas, la variable n'est certainement pas Clubs , et réduit le type à Diamonds , Hearts ou Spades .

Mypy peut également utiliser d'autres instructions conditionnelles pour affiner davantage le type, par exemple :

def describe_suit(suit: Optional[Suit]) -> str:
 assert suit is not None

 if suit is Suit.Clubs:
 # Revealed type is Literal[Suit.Clubs]
 reveal_type(suit)
 return "Clubs"

 # Revealed type is Literal[Suit.Diamonds, Suit.Hearts, Suit.Spades]
 reveal_type(suit)

 # `and`, `or` and `not` also work.
 if suit is Suit.Diamonds or suit is Suit.Spades:
 # Revealed type is Literal[Suit.Diamonds, Suit.Spades]
 reveal_type(suit)
 return "Diamonds or Spades"

 # Revealed type is Literal[Suit.Hearts]
 reveal_type(suit)

À la fin de la fonction, mypy a réduit le type de suit à Suit.Hearts . Si, par exemple, vous ajoutez une condition qui implique un type différent pour suit , mypy émettra une erreur :

def describe_suit(suit: Optional[Suit]) -> str:
 assert suit is not None

 if suit is Suit.Clubs:
 # Revealed type is Literal[Suit.Clubs]
 reveal_type(suit)
 return "Clubs"

 # Revealed type is Literal[Suit.Diamonds, Suit.Hearts, Suit.Spades]
 reveal_type(suit)

 # `and`, `or` and `not` also work.
 if suit is Suit.Diamonds or suit is Suit.Spades:
 # Revealed type is Literal[Suit.Diamonds, Suit.Spades]
 reveal_type(suit)
 return "Diamonds or Spades"

 # Revealed type is Literal[Suit.Hearts]
 reveal_type(suit)

 # mypy error [comparison-overlap]: Non-overlapping identity check
 # left operand type: "Literal[Suit.Hearts]"
 # right operand type: "Literal[Suit.Diamonds]"
 if suit is Suit.Diamonds:
 # mypy error [unreachable]: Statement is unreachable
 return "Diamonds"

Après que mypy ait réduit le type de suit à Literal[Suit.Hearts] , il connaît la condition suivante suit is Suit.Diamonds sera toujours évalué à False et émet une erreur.

Une fois toutes les possibilités réduites, le reste de la fonction devient inaccessible :

def describe_suit(suit: Optional[Suit]) -> str:
 assert suit is not None

 if suit is Suit.Clubs:
 return "Clubs"

 if suit is Suit.Diamonds or suit is Suit.Spades:
 return "Diamonds or Spades"

 if suit == Suit.Hearts:
 return 'Hearts'

 # This is currently unreachable
 assert_never(suit)

assert_never fonctionne en prenant un argument de type NoReturn , ce qui n'est possible que lorsque le type d'argument est "vide". C'est-à-dire lorsque toutes les possibilités ont été réduites et que l'énoncé est inaccessible. Si l'instruction devient accessible, alors le NoReturn n'est pas autorisé et mypy génère une erreur. Pour illustrer, supprimez la dernière condition et vérifiez le code avec mypy :

def describe_suit(suit: Optional[Suit]) -> str:
 assert suit is not None

 if suit is Suit.Clubs:
 return "Clubs"

 if suit is Suit.Diamonds or suit is Suit.Spades:
 return "Diamonds or Spades"

 # if suit == Suit.Hearts:
 # return 'Hearts'

 # mypy error: Argument 1 to "assert_never" has
 # incompatible type "Literal[Suit.Hearts]"; expected "NoReturn"
 assert_never(suit)

Mypy a réduit le type de suit à Suit.Hearts , mais assert_never attend NoReturn . Cette non-concordance déclenche l'erreur, qui effectue effectivement une vérification d'exhaustivité pour suit .

Le futur

En 2018 Guido si assert_never est une astuce assez astucieuse, mais elle n'a jamais été intégrée à mypy. Au lieu de cela, la vérification de l'exhaustivité deviendra officiellement disponible dans le cadre de mypy si/quand PEP 622 - Structural Pattern Matching est implémenté. Jusque-là, vous pouvez utiliser assert_never à la place.

Bonus :vérification de l'exhaustivité dans Django

Django fournit un attribut très utile à la plupart des types de champs de modèle appelé choices :

from django.db import models
from django.utils.translation import gettext_lazy as _

class Order(models.Model):

 status: str = models.CharField(
 max_length = 20,
 choices = (
 ('ready', _('Ready')),
 ('scheduled', _('Scheduled')),
 ('shipped', _('Shipped')),
 ),
 )

Lorsque vous fournissez des choix à un champ, Django y ajoute toutes sortes de choses intéressantes :

  • Ajouter un contrôle de validation à ModelForm (qui sont utilisés par l'administrateur de Django, entre autres)
  • Rendre le champ sous la forme d'un <select> élément html dans les formulaires
  • Ajouter un get_{field}_display_name méthode pour obtenir la description

Cependant, mypy ne peut pas savoir qu'un champ Django avec des choix a un ensemble limité de valeurs, il ne peut donc pas effectuer de vérification d'exhaustivité dessus. Pour adapter notre exemple d'avant :

# Will not perform exhaustiveness checking!
def handle_order(order: Order) -> None:
 if order.status == 'ready':
 print('ship order')

 elif order.status == 'shipped':
 print('charge order')

 else:
 assert_never(status)

La fonction ne gère pas le statut "planifié", mais mypy ne peut pas le savoir.

Une façon de surmonter cela consiste à utiliser une énumération pour générer les choix :

import enum
from django.db import models

class OrderStatus(enum.Enum):
 Ready = 'ready'
 Scheduled = 'scheduled'
 Shipped = 'shipped'

class Order(models.Model):
 status: str = models.CharField(
 max_length = 20,
 choices = ((e.value, e.name) for e in OrderStatus),
 )

Maintenant, vous pouvez réaliser une vérification d'exhaustivité avec une légère modification du code :

def handle_order(order: Order) -> None:
 status = OrderStatus(order.status)

 if status is OrderStatus.Pending:
 print('ship order')

 elif status is OrderStatus.Shipped:
 print('charge order')

 else:
 assert_never(status)

La partie délicate ici est que le champ de modèle status est en fait une chaîne, donc pour réaliser une vérification d'exhaustivité, vous devez transformer la valeur en une instance du OrderStatus énumération. Il y a deux inconvénients à cette approche :

  1. Vous devez lancer la valeur à chaque fois :Ce n'est pas très pratique. Cela peut éventuellement être résolu en implémentant un "champ Enum" personnalisé dans Django.

  2. Les descriptions de statut ne sont pas traduites :Auparavant, vous utilisiez gettext (_ ) pour traduire les valeurs d'énumération, mais maintenant vous venez d'utiliser la description de l'énumération.

Alors que le premier est toujours pénible, le deuxième problème a été résolu dans Django 3.1 avec l'ajout de types d'énumération Django :

from django.db import models

class OrderStatus(models.TextChoices):
 Ready = 'ready', _('Ready')
 Scheduled = 'scheduled', _('Scheduled')
 Shipped = 'shipped', _('Shipped')

class Order(models.Model):
 status: str = models.CharField(
 max_length = 20,
 choices = OrderStatus.choices,
 )

Remarquez comment vous avez remplacé l'énumération par un TextChoices . Le nouveau type d'énumération ressemble beaucoup à un Enum (il étend en fait Enum sous le capot), mais il vous permet de fournir un tuple avec une valeur et une description au lieu de simplement la valeur.

Mises à jour

Après avoir publié cet article, quelques lecteurs ont suggéré des moyens d'améliorer la mise en œuvre, j'ai donc apporté les modifications suivantes :

  1. 2020-12-09 :La version initiale de l'article avait assert_never prendre une valeur de type NoReturn . Un commentateur sur Lobsters a fait une excellente suggestion d'utiliser le Union[()] plus intuitif tapez à la place. Cela se traduit également par un meilleur message d'erreur.

  2. 2020-12-09 :La version initiale de l'article utilisait assert False, ... en assert_never au lieu de raise AssertionError(...) . Un commentateur sur Lobsters a mentionné que assert les instructions sont supprimées lorsque python est exécuté avec le -O drapeau. Depuis le assert en assert_never ne doit pas être supprimé, je l'ai changé en raise AssertionError à la place.

  3. 2020-12-10  :Après avoir cherché un peu plus, tmcb a trouvé que Union[()] n'est actuellement pas accepté par Python au moment de l'exécution , j'ai donc inversé l'argument en NoReturn à nouveau.