Grajdeanu Alex
Grajdeanu Alex

Reputation: 418

FastAPI: Loading multiple environments within the same settings class

I've been struggling to achieve this for a while now and it seems that I can't find my way around this. I have the following main entry point for my FastAPI project:

from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import RedirectResponse

from app.core.config import get_api_settings
from app.api.api import api_router


def get_app() -> FastAPI:
    api_settings = get_api_settings()

    server = FastAPI(**api_settings.fastapi_kwargs)
    server.add_middleware(
        CORSMiddleware,
        allow_origins=["*"],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )
    server.include_router(api_router, prefix="/api")

    @server.get("/", include_in_schema=False)
    def redirect_to_docs() -> RedirectResponse:
        return RedirectResponse(api_settings.docs_url)

    return server


app = get_app()

Nothing too fancy so far. As you can see, I'm importing get_api_settings which holds my entire service config and it looks like this:

from functools import lru_cache
from typing import Any, Dict

from pydantic import BaseSettings


class APISettings(BaseSettings):
    """This class enables the configuration of your FastAPI instance
    through the use of environment variables.

    Any of the instance attributes can be overridden upon instantiation by
    either passing the desired value to the initializer, or by setting the
    corresponding environment variable.

    Attribute `xxx_yyy` corresponds to environment variable `API_XXX_YYY`.
    So, for example, to override `api_prefix`, you would set the environment
    variable `API_PREFIX`.

    Note that assignments to variables are also validated, ensuring that
    even if you make runtime-modifications to the config, they should have
    the correct types.
    """

    # fastapi.applications.FastAPI initializer kwargs
    debug: bool = False
    docs_url: str = "/docs"
    openapi_prefix: str = ""
    openapi_url: str = "/openapi.json"
    redoc_url: str = "/redoc"
    title: str = "Api Backend"
    version: str = "0.1.0"

    # Custom settings
    disable_docs: bool = False
    environment: str

    @property
    def fastapi_kwargs(self) -> Dict[str, Any]:
        """This returns a dictionary of the most commonly used keyword
        arguments when initializing a FastAPI instance.

        If `self.disable_docs` is True, the various docs-related arguments
        are disabled, preventing spec from being published.
        """
        fastapi_kwargs: Dict[str, Any] = {
            "debug": self.debug,
            "docs_url": self.docs_url,
            "openapi_prefix": self.openapi_prefix,
            "openapi_url": self.openapi_url,
            "redoc_url": self.redoc_url,
            "title": self.title,
            "version": self.version

        }
        if self.disable_docs:
            fastapi_kwargs.update({
                "docs_url": None,
                "openapi_url": None,
                "redoc_url": None
            })
        return fastapi_kwargs

    class Config:
        case_sensitive = True
        # env_file should be dynamic depending on the 
        # `environment` env variable
        env_file = ""
        env_prefix = ""
        validate_assignment = True


@lru_cache()
def get_api_settings() -> APISettings:
    """This function returns a cached instance of the APISettings object.

    Caching is used to prevent re-reading the environment every time the API
    settings are used in an endpoint.
    If you want to change an environment variable and reset the cache
    (e.g., during testing), this can be done using the `lru_cache` instance
    method `get_api_settings.cache_clear()`.
    """
    return APISettings()

I'm trying to prepare this service for multiple environments:

For each of the above, I have three different .env files as follow:

As an example, here is how a .env file looks like:

environment=dev
frontend_service_url=http://localhost:3000

What I can't get my head around is how to dynamically set the env_file = "" in my Config class based on the environment attribute in my APISettings BaseSettings class.

Reading through Pydantic's docs I thought I can use the customise_sources classmethod to do something like this:

def load_envpath_settings(settings: BaseSettings):
    environment =  # not sure how to access it here
    for env in ("dev", "stage", "prod"):
        if environment == env:
            return f"app/configs/{environment}.env"


class APISettings(BaseSettings):
    # ...

    class Config:
        case_sensitive = True
        # env_file = "app/configs/dev.env"
        env_prefix = ""
        validate_assignment = True

        @classmethod
        def customise_sources(cls, init_settings, env_settings, file_secret_settings):
            return (
                init_settings,
                load_envpath_settings,
                env_settings,
                file_secret_settings,
            )

but I couldn't find a way to access the environment in my load_envpath_settings. Any idea how to solve this? Or if there's another way to do it? I've also tried creating another @property in my APISettings class which which would basically be the same as the load_envpath_settings but I couldn't refer it back in the Config class.

Upvotes: 1

Views: 7455

Answers (1)

MatsLindh
MatsLindh

Reputation: 52862

First; usually you'd copy the file you want to have active into the .env file, and then just load that. If you however want to let that .env file control which of the configurations that are active:

You can have two sets of configuration - one that loads the initial configuration (i.e. which environment is the active one) from .env, and one that loads the actual application settings from the core/configs/<environment>.env file.

class AppSettings(BaseSettings):
    environment:str = 'development'

This would be affected by the configuration given in .env (which is the default file name). You'd then use this value to load the API configuration by using the _env_file parameter, which is support on all BaseSettings instances.

def get_app_settings() -> AppSettings:
    return AppSettings()

def get_api_settings() -> APISettings:
    app_settings = get_app_settings()
    return APISettings(_env_file=f'core/configs/{app_settings.environment}.env')  # or os.path.join() and friends

Upvotes: 1

Related Questions