pythonpython-typingmypypydantic

Mypy doesn't detect a type guard, why?


I am trying to teach myself how to use type guards in my new Python project in combination with pydantic-settings, and mypy doesn't seem to pick up on them. What am I doing wrong here?

Code:

import logging
from logging.handlers import SMTPHandler
from functools import lru_cache
from typing import Final, Literal, TypeGuard

from pydantic import EmailStr, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

SMTP_PORT: Final = 587


class Settings(BaseSettings):
    """
    Please make sure your .env contains the following variables:
    - BOT_TOKEN - an API token for your bot.
    - TOPIC_ID - an ID for your group chat topic.
    - GROUP_CHAT_ID - an ID for your group chat.
    - ENVIRONMENT - if you intend on running this script on a VPS, this improves logging
        information in your production system.

    Required only in production:

    - SMTP_HOST - SMTP server address (e.g., smtp.gmail.com)
    - SMTP_USER - Email username/address for SMTP authentication
    - SMTP_PASSWORD - Email password or app-specific password
    """

    ENVIRONMENT: Literal["production", "development"]

    # Telegram bot configuration
    BOT_TOKEN: SecretStr
    TOPIC_ID: int
    GROUP_CHAT_ID: int

    # Email configuration
    SMTP_HOST: str | None = None
    SMTP_USER: EmailStr | None = None
    # If you're using Gmail, this needs to be an app password
    SMTP_PASSWORD: SecretStr | None = None

    model_config = SettingsConfigDict(env_file="../.env", env_file_encoding="utf-8")


@lru_cache(maxsize=1)
def get_settings() -> Settings:
    """This needs to be lazily evaluated, otherwise pytest gets a circular import."""
    return Settings()


type DotEnvStrings = str | SecretStr | EmailStr


def is_all_email_settings_provided(
    host: DotEnvStrings | None,
    user: DotEnvStrings | None,
    password: DotEnvStrings | None,
) -> TypeGuard[DotEnvStrings]:
    """
    Type guard that checks if all email settings are provided.

    Returns:
        True if all email settings are provided as strings, False otherwise.
    """
    return all(isinstance(x, (str, SecretStr, EmailStr)) for x in (host, user, password))


def get_logger():
    ...
    settings = get_settings()
    if settings.ENVIRONMENT == "development":
        level = logging.INFO
    else:
        # # We only email logging information on failure in production.
        if not is_all_email_settings_provided(
            settings.SMTP_HOST, settings.SMTP_USER, settings.SMTP_PASSWORD
        ):
            raise ValueError("All email environment variables are required in production.")
        level = logging.ERROR
        email_handler = SMTPHandler(
            mailhost=(settings.SMTP_HOST, SMTP_PORT),
            fromaddr=settings.SMTP_USER,
            toaddrs=settings.SMTP_USER,
            subject="Application Error",
            credentials=(settings.SMTP_USER, settings.SMTP_PASSWORD.get_secret_value()),
            # This enables TLS - https://docs.python.org/3/library/logging.handlers.html#smtphandler
            secure=(),
        )

And here is what mypy is saying:

media_only_topic\media_only_topic.py:122: error: Argument "mailhost" to "SMTPHandler" has incompatible type "tuple[str | SecretStr, int]"; expected "str | tuple[str, int]"  [arg-type]
media_only_topic\media_only_topic.py:123: error: Argument "fromaddr" to "SMTPHandler" has incompatible type "str | None"; expected "str"  [arg-type]
media_only_topic\media_only_topic.py:124: error: Argument "toaddrs" to "SMTPHandler" has incompatible type "str | None"; expected "str | list[str]"  [arg-type]
media_only_topic\media_only_topic.py:126: error: Argument "credentials" to "SMTPHandler" has incompatible type "tuple[str | None, str | Any]"; expected "tuple[str, str] | None"  [arg-type]
media_only_topic\media_only_topic.py:126: error: Item "None" of "SecretStr | None" has no attribute "get_secret_value"  [union-attr]
Found 5 errors in 1 file (checked 1 source file)

I would expect mypy here to read up correctly that my variables can't even in theory be None, but type guards seem to change nothing here, no matter how many times I change the code here. Changing to Pyright doesn't make a difference. What would be the right approach here?


Solution

  • Make a separate ProdSettings class. ProdSettings will raise an error if any of those values are missing.

    class ProdSettings(BaseSettings):   
        ENVIRONMENT: Literal["production"]
        
        BOT_TOKEN: SecretStr
        TOPIC_ID: int
        GROUP_CHAT_ID: int
    
        SMTP_HOST: str
        SMTP_USER: EmailStr
        SMTP_PASSWORD: SecretStr
    
        model_config = SettingsConfigDict(env_file="../.env", env_file_encoding="utf-8")
    

    Change Settings to DevSettings and overwrite ProdSettings to allow for None in development.

    class DevSettings(ProdSettings):
        ENVIRONMENT: Literal["development"]
    
        SMTP_HOST: str | None = None
        SMTP_USER: EmailStr | None = None
        SMTP_PASSWORD: SecretStr | None = None
    

    Use a Discriminator to declare a Settings type, while asserting what makes them different.

    type Settings = Annotated[DevSettings | ProdSettings, Field(discriminator="ENVIRONMENT")]
    

    When you run get_settings, try dev first, then prod. Set the return type as Settings.

    @lru_cache(maxsize=1)
    def get_settings() -> Settings: # Alternatively, you could directly put `Annotated[DevSettings | ProdSettings, Field(discriminator="ENVIRONMENT")]` here.
        """This needs to be lazily evaluated, otherwise pytest gets a circular import."""
        try:
            return DevSettings()
        except ValidationError:
            return ProdSettings()
    

    Now when you have if settings.ENVIRONMENT == "development": this acts as a TypeGuard (You could do this explicitly as well). You'll see that mypy recognizes true as meaning settings is an instance of DevSettings and otherwise an instance of ProdSettings.

    def get_logger():
        settings = get_settings()
        if settings.ENVIRONMENT == "development":
            level = logging.INFO
        else:
            level = logging.ERROR
            email_handler = SMTPHandler(
                mailhost=(settings.SMTP_HOST, SMTP_PORT),
                fromaddr=settings.SMTP_USER,
                toaddrs=settings.SMTP_USER,
                subject="Application Error",
                credentials=(settings.SMTP_USER, settings.SMTP_PASSWORD.get_secret_value()),
                # This enables TLS - https://docs.python.org/3/library/logging.handlers.html#smtphandler
                secure=(),
            )