Question: 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?

Answer: 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=(), )