Intro

If you are already familiar with Pydantic, one of the useful components of Pydantic is the Settings Management. This will allow you to read settings variables from different sources and parse and validate them into class(es) using Pydantic.

Let’s see a minimal example. First we need to set up the variable:

$ export API_KEY=xxx

And then we can read it into the Settings class with:

from pydantic import BaseSettings


class Settings(BaseSettings):
    api_key: str


print(Settings().dict())
"""
{'api_key': 'xxx'}
"""

Different data sources are available to read variables from, currently the options include:

  • Environment variables
  • Dotenv file
  • Docker secrets

But what if you want to add new data sources? For example if you want to read it from a JSON file or AWS Secrets Manager?

Luckily Pydantic Settings allows you to customize the settings resources easily. Checking here you can add or remove resources or change their priorities.

So let’s see how we can add a custom resource to read secrets from AWS Secrets Manager.

AWS Secrets Manager

Note: If you are familiar with AWS Secrets Manager or already have access to an existing one with some secrets defined you can skip to the next section.

Secrets Manager is a service which allows you to store any sensitive information like database passwords, API Keys, etc and retrieve them in your code without worrying about environment variables or dotenv files being passed around in your source code. Obviously your secrets are encrypted in AWS and decrypted in two steps when you want to retrieve them.

On top of this, you also get other benefits like secret rotation, logging and managing permissions based on AWS IAM policies.

If you already have an AWS account and credentials you can move to the next step and use the example code, otherwise I will use localstack to create a local version of all AWS services and test this without actually working with an AWS account.

You can install localstack easily with:

$ pip install localstack

After it’s installed, you can start localstack to run using Docker:

$ localstack start -d

AWS services should now be available on your local at http://localhost:4566.

If you alrady have aws CLI installed, you can try using the localstack with:

$ export AWS_ACCESS_KEY_ID="test"
$ export AWS_SECRET_ACCESS_KEY="test"
$ export AWS_DEFAULT_REGION="us-east-1"

$ aws --endpoint-url=http://localhost:4566 secretsmanager list-secrets

Or you can install awslocal, which is a wrapper around aws CLI and you don’t need to use the --endpoint-url anymore.

$ pip install awscli-local

$ awslocal secretsmanager list-secrets

This will probably get you some empty response because we haven’t defined any secrets yet.

Now let’s try to define two secrets:

$ awslocal secretsmanager create-secret \
    --name example_secret \
    --secret-string "SECRET_VALUE"

$ awslocal secretsmanager create-secret \
    --name example_complex \
    --secret-string "{\"user\":\"diegor\",\"password\":\"EXAMPLE-PASSWORD\"}"

Next let’s try to list the secrets again with awslocal secretsmanager list-secrets which will give a similiar response to this:

{
    "SecretList": [
        {
            "ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:example_secret-xxxx",
            "Name": "example_secret",
            "LastChangedDate": xxxxxxxxxxxxxxxxx,
            "SecretVersionsToStages": {
                "e49ae6a9-201b-45a8-93ea-a474cf3427cb": [
                    "AWSCURRENT"
                ]
            },
            "CreatedDate": xxxxxxxxxxxxxxxxx
        },
        {
            "ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:example_complex-xxxx",
            "Name": "example_complex",
            "LastChangedDate": xxxxxxxxxxxxxxxxx,
            "SecretVersionsToStages": {
                "87b93858-f03a-431e-8560-2c1ec836945f": [
                    "AWSCURRENT"
                ]
            },
            "CreatedDate": xxxxxxxxxxxxxxxxx
        }
    ]
}

Now we can move to our implementation code.

Pydantic Settings with Secrets Manager

The first step is to connect to the AWS secrets manager service using boto3 and create a client. In case of using localstack you can use the following credentials to connect to it:

from boto3 import Session


session = Session(
    aws_access_key_id="test",
    aws_secret_access_key="test",
    region_name="us-east-1",
)

client = session.client(
    endpoint_url="http://localhost:4566",
    service_name="secretsmanager",
    region_name="us-east-1",
)

Pydantic BaseSettings class can use a nested Config class which does extra configurations for the settings management. Next we define a SecretManagerConfig class which will modify Config’s behaviour:

import json
from typing import Any

from pydantic.env_settings import SettingsSourceCallable


class SecretManagerConfig:
    @classmethod
    def _get_secret(cls, secret_name: str) -> str | dict[str, Any]:
        # Get secret string value
        secret_string = client.get_secret_value(SecretId=secret_name)["SecretString"]
        try:
            # try to decode it into a dict if it was JSON encoded
            return json.loads(secret_string)
        except json.decoder.JSONDecodeError:
            return secret_string
        

    @classmethod
    def get_secrets(cls, settings: BaseSettings) -> dict[str, Any]:
        # We go through the fields and try to fetch a secret with that field name
        return {
            name: cls._get_secret(name) for name, _ in settings.__fields__.items()
        }

    @classmethod
    def customise_sources(
        cls,
        init_settings: SettingsSourceCallable,
        env_settings: SettingsSourceCallable,
        file_secret_settings: SettingsSourceCallable,
    ):
        # Here we add the `cls.get_secrets` method as an extra data source
        return (
            init_settings,
            cls.get_secrets,
            env_settings,
            file_secret_settings,
        )

The method customise_sources is the method that Pydantic Settings allows you to add or delete new data sources. So it’s overriden to add our custom data source get_secrets there.

In get_secrets we go through the Settings.__fields__ and get the secret details by using this name. For each secret string returned, we can try to load it into a dict using json.loads in case our secret was JSON encoded.

And we define our settings classes next:

from pydantic import BaseSettings, BaseModel


class DatabaseSettings(BaseModel):
    user: str
    password: str


class Settings(BaseSettings):
    example_secret: str
    example_complex: DatabaseSettings

    class Config(SecretManagerConfig):
        ...

And finally we can load our settings with:

print(Settings().dict())
"""
{'example_secret': 'SECRET_VALUE', 'example_complex': {'user': 'diegor', 'password': 'EXAMPLE-PASSWORD'}}
"""

This allows us to load both simple key, value secrets and JSON encoded ones into sub-settings which Pydantic handles very nicely.

Obviously this can be improved to handle more cases or even be written in better ways, but this will give you the idea how you can do continue from here.

The complete code:

import json
from typing import Any

from boto3 import Session
from pydantic import BaseSettings, BaseModel
from pydantic.env_settings import SettingsSourceCallable


session = Session(
    aws_access_key_id="test",
    aws_secret_access_key="test",
    region_name="us-east-1",
)

client = session.client(
    endpoint_url="http://localhost:4566",
    service_name="secretsmanager",
    region_name="us-east-1",
)


class SecretManagerConfig:
    @classmethod
    def _get_secret(cls, secret_name: str) -> str | dict[str, Any]:
        secret_string = client.get_secret_value(SecretId=secret_name)["SecretString"]
        try:
            return json.loads(secret_string)
        except json.decoder.JSONDecodeError:
            return secret_string
        

    @classmethod
    def get_secrets(cls, settings: BaseSettings) -> dict[str, Any]:
        return {
            name: cls._get_secret(name) for name, _ in settings.__fields__.items()
        }

    @classmethod
    def customise_sources(
        cls,
        init_settings: SettingsSourceCallable,
        env_settings: SettingsSourceCallable,
        file_secret_settings: SettingsSourceCallable,
    ):
        return (
            init_settings,
            cls.get_secrets,
            env_settings,
            file_secret_settings,
        )


class DatabaseSettings(BaseModel):
    user: str
    password: str


class Settings(BaseSettings):
    example_secret: str
    example_complex: DatabaseSettings

    class Config(SecretManagerConfig):
        ...


print(Settings().dict())
"""
{'example_secret': 'SECRET_VALUE', 'example_complex': {'user': 'diegor', 'password': 'EXAMPLE-PASSWORD'}}
"""