Post

Understanding a Python Configuration Class From Environment Variables to Frozen Dataclasses

Understanding a Python Configuration Class From Environment Variables to Frozen Dataclasses

When you see code like this for the first time:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from __future__ import annotations
from dataclasses import dataclass
import os
from dotenv import load_dotenv

DEFAULT_MODEL = "llama-3.3-70b-versatile"

@dataclass(frozen=True)
class PlannerSettings:
    """Runtime configuration for the planner and its research workers."""

    model_name: str = DEFAULT_MODEL
    groq_api_key: str | None = None
    tavily_api_key: str | None = None

    @classmethod
    def from_environment(cls) -> "PlannerSettings":
        load_dotenv()
        return cls(
            model_name=os.getenv("PLANNER_MODEL", DEFAULT_MODEL),
            groq_api_key=os.getenv("GROQ_API_KEY"),
            tavily_api_key=os.getenv("TAVILY_API_KEY"),
        )

it can feel like a lot of unrelated Python features thrown together.

But there is actually a simple idea behind the whole code:

We want one clean, reliable object that contains all the configuration our application needs.

Let’s understand how we arrive at that solution.


1. First, what problem are we trying to solve?

Imagine our application needs three things:

1
2
3
Model name
Groq API key
Tavily API key

We could write:

1
2
3
model_name = "llama-3.3-70b-versatile"
groq_api_key = "..."
tavily_api_key = "..."

But there are problems.

Where should these values come from?

Should we hardcode them?

1
groq_api_key = "gsk_abc123..."

Definitely not. 😬

API keys are secrets and shouldn’t normally be hardcoded into our source code.

So we need a better way.


2. Where should configuration come from?

A common solution is to put configuration in environment variables.

For example, we might have a .env file:

1
2
3
GROQ_API_KEY=gsk_xxxxxxxxx
TAVILY_API_KEY=tvly_xxxxxxxxx
PLANNER_MODEL=llama-3.3-70b-versatile

Now our Python application can read these values instead of putting secrets directly into the code.

So we now have a new question:

How can Python read environment variables?

That’s where os comes in.


3. What is import os doing?

1
import os

Python’s os module gives us tools for interacting with the operating system.

One of those tools is:

1
os.getenv()

For example:

1
os.getenv("GROQ_API_KEY")

means:

“Operating system, do you have an environment variable called GROQ_API_KEY?”

If it exists, Python gets its value.

If it doesn’t exist, os.getenv() returns:

1
None

4. But what about the .env file?

Here’s the catch.

We might have:

1
.env

containing:

1
GROQ_API_KEY=gsk_xxxxxxxxx

But Python’s os.getenv() doesn’t automatically read .env files.

So we need something that loads the .env values into the environment.

That’s what python-dotenv provides.

We write:

1
from dotenv import load_dotenv

Then:

1
load_dotenv()

This basically says:

“Read the .env file and load those variables so my Python program can access them.”

Now this works:

1
2
3
load_dotenv()

api_key = os.getenv("GROQ_API_KEY")

5. Now we have another problem

We could simply do this:

1
2
3
model_name = os.getenv("PLANNER_MODEL")
groq_api_key = os.getenv("GROQ_API_KEY")
tavily_api_key = os.getenv("TAVILY_API_KEY")

But imagine a bigger application.

Soon we might have:

1
2
3
4
5
6
7
8
9
model
API keys
temperature
max tokens
database URL
logging level
timeout
worker count
etc.

If these variables are scattered throughout our application, things become messy.

So we ask:

“Can we put all our configuration in one place?”

Yes.

And that’s why we create a configuration class.


6. Let’s create a dataclass

We write:

1
2
3
4
5
6
7
from dataclasses import dataclass

@dataclass(frozen=True)
class PlannerSettings:
    model_name: str
    groq_api_key: str | None
    tavily_api_key: str | None

Now we have one object representing our application’s configuration.

For example:

1
2
3
4
5
settings = PlannerSettings(
    model_name="llama-3.3-70b-versatile",
    groq_api_key="gsk_xxx",
    tavily_api_key="tvly_xxx"
)

Instead of passing three separate variables everywhere, we can simply pass:

1
settings

Much cleaner.


7. Why @dataclass?

Remember what a dataclass does.

Without it, we’d have to write something like:

1
2
3
4
5
6
7
8
9
10
11
class PlannerSettings:

    def __init__(
        self,
        model_name,
        groq_api_key,
        tavily_api_key
    ):
        self.model_name = model_name
        self.groq_api_key = groq_api_key
        self.tavily_api_key = tavily_api_key

That’s mostly boilerplate.

The class is primarily there to hold data.

So:

1
@dataclass

tells Python:

“This class is mainly a container for data. Handle the common boilerplate for me.”


8. Why frozen=True?

Now look at this:

1
@dataclass(frozen=True)

Why freeze the configuration?

Think about what configuration represents.

When our application starts, we might load:

1
2
3
Model → llama-3.3-70b-versatile
Groq API key → xxx
Tavily API key → xxx

We probably don’t want some random part of the application to suddenly do:

1
settings.model_name = "some-other-model"

That could create confusing behavior.

So we freeze the object.

1
@dataclass(frozen=True)

means:

“Once the settings object is created, don’t allow its fields to be reassigned.”

This makes our configuration object safer and more predictable.


9. What is DEFAULT_MODEL?

Now we have:

1
DEFAULT_MODEL = "llama-3.3-70b-versatile"

Why do we need this?

Because maybe the user doesn’t specify a model in the .env file.

For example:

1
2
GROQ_API_KEY=...
TAVILY_API_KEY=...

There is no:

1
PLANNER_MODEL=...

So what should our application use?

We need a fallback.

That’s what this constant provides:

1
DEFAULT_MODEL

Then:

1
model_name: str = DEFAULT_MODEL

means:

“If nobody provides a model name, use the default model.”


10. Understanding str | None

Look at:

1
groq_api_key: str | None = None

This looks complicated, but it’s simple.

It means:

“The value can either be a string or None.”

Why?

Because the API key might exist:

1
"gsk_xxxxxxxxx"

or it might not exist:

1
None

So:

1
str | None

describes both possibilities.

And:

1
= None

provides the default value.


11. Now comes the interesting part: from_environment()

Instead of forcing the developer to manually create:

1
2
3
4
5
PlannerSettings(
    model_name=...,
    groq_api_key=...,
    tavily_api_key=...
)

we can make the class create itself from environment variables.

That’s what this method does:

1
2
@classmethod
def from_environment(cls) -> "PlannerSettings":

Think of it as:

“Create a PlannerSettings object using whatever configuration is available in the environment.”


12. Why @classmethod?

Normally, we call a method on an object:

1
settings.some_method()

But here we don’t have a settings object yet!

We’re trying to create the settings object.

So calling:

1
PlannerSettings.from_environment()

makes more sense.

@classmethod allows the method to work with the class itself.

Inside the method:

1
cls

refers to:

1
PlannerSettings

So:

1
return cls(...)

is essentially creating:

1
PlannerSettings(...)

13. What does -> "PlannerSettings" mean?

We have:

1
def from_environment(cls) -> "PlannerSettings":

We’re saying:

“This method returns a PlannerSettings object.”

But notice the quotes:

1
"PlannerSettings"

Why?

This relates to something we discussed earlier.

The class is still being defined when this method is being defined.

So Python might not be able to resolve PlannerSettings immediately.

The annotation is therefore written as a forward reference.

And because we have:

1
from __future__ import annotations

we can even write:

1
-> PlannerSettings

without the quotes in modern Python.


14. Now let’s follow the method step by step

Here’s the method:

1
2
3
4
5
6
7
8
9
10
@classmethod
def from_environment(cls) -> "PlannerSettings":

    load_dotenv()

    return cls(
        model_name=os.getenv("PLANNER_MODEL", DEFAULT_MODEL),
        groq_api_key=os.getenv("GROQ_API_KEY"),
        tavily_api_key=os.getenv("TAVILY_API_KEY"),
    )

Let’s follow the flow.

Step 1: Load .env

1
load_dotenv()

Python reads the .env file.

For example:

1
2
3
PLANNER_MODEL=llama-3.3-70b-versatile
GROQ_API_KEY=gsk_xxx
TAVILY_API_KEY=tvly_xxx

Step 2: Get the model

1
os.getenv("PLANNER_MODEL", DEFAULT_MODEL)

This means:

“Look for PLANNER_MODEL.”

If it exists:

1
PLANNER_MODEL → its value

If it doesn’t:

1
→ DEFAULT_MODEL

So we always have a model name.


Step 3: Get the Groq key

1
os.getenv("GROQ_API_KEY")

If the variable exists:

1
→ "gsk_xxx"

If it doesn’t:

1
→ None

Step 4: Get the Tavily key

Same idea:

1
os.getenv("TAVILY_API_KEY")

It either gives us the key or:

1
None

Step 5: Build the configuration object

Finally:

1
return cls(...)

creates:

1
2
3
4
5
PlannerSettings(
    model_name="llama-3.3-70b-versatile",
    groq_api_key="gsk_xxx",
    tavily_api_key="tvly_xxx"
)

And returns it.


15. So the entire design makes sense now

Let’s connect everything.

We started with a problem:

Our application needs configuration.

We don’t want secrets hardcoded in Python.

So we put configuration in environment variables / .env.

We need to read those values.

os.getenv() reads environment variables.

.env isn’t automatically loaded.

load_dotenv() loads .env.

Now we have several configuration values.

Scattering them throughout the application would become messy.

So we create one configuration object.

@dataclass removes boilerplate.

frozen=True prevents accidental modification.

DEFAULT_MODEL gives us a fallback.

@classmethod from_environment() gives us a convenient way to construct the object.

And finally:

1
settings = PlannerSettings.from_environment()

gives the application one clean source of configuration.


16. The complete mental model

Think of PlannerSettings as a configuration box.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
              .env / Environment
                     │
                     ▼
              load_dotenv()
                     │
                     ▼
               os.getenv()
                     │
                     ▼
          ┌─────────────────────┐
          │   PlannerSettings   │
          │                     │
          │ model_name           │
          │ groq_api_key         │
          │ tavily_api_key       │
          └─────────────────────┘
                     │
                     ▼
              Rest of the app

The application doesn’t need to worry about where the configuration came from.

It simply gets:

1
settings = PlannerSettings.from_environment()

and then uses:

1
2
3
settings.model_name
settings.groq_api_key
settings.tavily_api_key

That’s the real purpose of this code.

It separates configuration loading from the rest of the application and puts all runtime settings into one clean, immutable object.

This post is licensed under CC BY 4.0 by the author.