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