Skip to content

Commit ffc5459

Browse files
authored
refactor: replace YAML config loading with pydantic-settings for env var support (#6)
* refactor: implement Pydantic-based configuration management - Create `config.py` with Pydantic models for structured settings validation - Replace manual YAML parsing and dotenv loading with `BaseSettings` - Update `api_url` in `braintest.yaml` to the staging internal endpoint - Integrate `load_config` helper across `main.py`, `evaltest`, `loadtest`, and `functional_test` - Add `pydantic-settings[yaml]` to project dependencies The configuration logic is centralized and typed using Pydantic, replacing fragmented YAML loading across the codebase. This ensures schema validation, supports environment variable overrides with nested delimiters, and updates the target Braintrust API URL for the staging environment. * chore: remove python-dotenv dependency - Remove `python-dotenv` from `pyproject.toml` - Delete `load_dotenv` import in `mock_conversation_task.py` - Remove `load_dotenv()` call from main execution block Redundant dependency and logic removed as environment variables are expected to be managed externally or by the runtime environment. * refactor(config): update braintrust api endpoint - Replace internal staging URL with official production API URL in `braintest.yaml` * docs: update README installation and configuration guide - Correct typo from "virutal" to "virtual" - Update environment variable instructions to be platform agnostic - Add Configuration section describing Pydantic settings behavior - Include table and examples for environment variable overrides Standardize environment variable documentation and introduce guidance on nested configuration overrides while fixing typographical errors. * docs: update environment variable setup instructions in README - Replace manual environment variable export step with .env file creation - Add reference to example.env for configuration template Updates the setup documentation to recommend using a .env file instead of manual exports for easier environment configuration management. * build: add python-dotenv dependency and initialize in scripts - Add `python-dotenv` to `pyproject.toml` dependencies - Import and call `load_dotenv()` in `evaltest/run.py` - Import and call `load_dotenv()` in `functional_test/run.py` - Import and call `load_dotenv()` in `loadtest/mock_conversation_task.py` - Import and call `load_dotenv()` in `loadtest/run.py` Enable automatic loading of environment variables from .env files across all test suites and scripts. This ensures that sensitive configurations and API keys are consistently available in local and CI environments without manual exports. * refactor: centralize configuration loading logic - Remove redundant `load_config` definitions from task files - Import `load_config` from shared `config` module - Remove unused `yaml` import in load test tasks Shared configuration logic reduces code duplication and ensures consistent loading of the braintest.yaml file across different mock task modules.
1 parent 8183d93 commit ffc5459

10 files changed

Lines changed: 256 additions & 54 deletions

File tree

README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Each test is highly configurable via the `braintest.yaml` config file. The tests
2727
```bash
2828
uv sync
2929
```
30-
3. Activate the virutal env uv creates if it isn't already activated
30+
3. Activate the virtual env uv creates if it isn't already activated
3131
```bash
3232
source .venv/bin/activate
3333
```
@@ -50,5 +50,24 @@ Each test is highly configurable via the `braintest.yaml` config file. The tests
5050
nohup python main.py > loadtest.out 2>&1 &
5151
```
5252

53+
## Configuration
54+
55+
Configuration is loaded from `braintest.yaml` using [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/). Environment variables take priority over YAML values.
56+
57+
To override any config value via environment variable, use `__` (double underscore) as the nested separator. For example:
58+
59+
| YAML path | Environment variable |
60+
|---|---|
61+
| `braintrust.api_url` | `BRAINTRUST__API_URL` |
62+
| `braintrust.project_name` | `BRAINTRUST__PROJECT_NAME` |
63+
| `loadtest.processes` | `LOADTEST__PROCESSES` |
64+
| `evaltest.trial_count` | `EVALTEST__TRIAL_COUNT` |
65+
| `functionaltest.name_prefix` | `FUNCTIONALTEST__NAME_PREFIX` |
66+
67+
Example:
68+
```bash
69+
BRAINTRUST__API_URL=https://my-api.example.com LOADTEST__PROCESSES=8 python main.py
70+
```
71+
5372
## Important Notes
5473
- No actual LLM calls are made in any of these tests. Everything is mocked. The purpose is to load test Braintrust infra, not the LLM provider.

config.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from pydantic import BaseModel, Field
2+
from pydantic_settings import BaseSettings, SettingsConfigDict, YamlConfigSettingsSource
3+
4+
5+
class BraintrustConfig(BaseModel):
6+
project_name: str = "load-testing-project"
7+
api_url: str = ""
8+
9+
10+
class FunctionalTestConfig(BaseModel):
11+
run: bool = False
12+
name_prefix: str = "functional-test"
13+
14+
15+
class DatasetConfig(BaseModel):
16+
name: str = "test-large-dataset"
17+
description: str = ""
18+
size: int = 100
19+
flush_batch_size: int = 25
20+
21+
22+
class EvalTestConfig(BaseModel):
23+
run: bool = False
24+
project_id: str | None = None
25+
name: str = "test-large"
26+
trial_count: int = 1
27+
dataset: DatasetConfig = DatasetConfig()
28+
29+
30+
class WaitTimeConfig(BaseModel):
31+
min: int = 5
32+
max: int = 10
33+
34+
35+
class ReadTrafficConfig(BaseModel):
36+
peak_concurrency: int = 2
37+
btql_calls_per_min: float = 10
38+
39+
40+
class LoadTestParams(BaseModel):
41+
faker_pool_size: int = 20
42+
max_tokens: int = 1000
43+
peak_concurrency: int = 20
44+
ramp_up: int = 2
45+
run_time: str = "1m"
46+
wait_time: WaitTimeConfig = WaitTimeConfig()
47+
read_traffic: ReadTrafficConfig = ReadTrafficConfig()
48+
49+
50+
class BraintrustLoggerConfig(BaseModel):
51+
flush_size: int = 100
52+
queue_size: int = 25000
53+
54+
55+
class LogsConfig(BaseModel):
56+
model_config = {"populate_by_name": True}
57+
58+
html: bool = True
59+
csv: bool = False
60+
json_log: bool = Field(False, alias="json")
61+
62+
63+
class LoadTestConfig(BaseModel):
64+
run: bool = False
65+
locustfile_path: str = "loadtest/run.py"
66+
headless: bool = False
67+
web_ui_port: int = 8089
68+
processes: int = 4
69+
connection_pool_size: int = 10
70+
braintrust_logger: BraintrustLoggerConfig = BraintrustLoggerConfig()
71+
params: LoadTestParams = LoadTestParams()
72+
logs: LogsConfig = LogsConfig()
73+
74+
75+
class Settings(BaseSettings):
76+
model_config = SettingsConfigDict(
77+
yaml_file="braintest.yaml",
78+
env_nested_delimiter="__",
79+
)
80+
81+
braintrust: BraintrustConfig = BraintrustConfig()
82+
functionaltest: FunctionalTestConfig = FunctionalTestConfig()
83+
evaltest: EvalTestConfig = EvalTestConfig()
84+
loadtest: LoadTestConfig = LoadTestConfig()
85+
86+
@classmethod
87+
def settings_customise_sources(cls, settings_cls, **kwargs):
88+
return (
89+
kwargs["env_settings"],
90+
YamlConfigSettingsSource(settings_cls),
91+
kwargs["init_settings"],
92+
)
93+
94+
95+
def load_config() -> dict:
96+
return Settings().model_dump(by_alias=True)

evaltest/run.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,14 @@
22
from braintrust import init_logger, init_dataset, Eval
33
from autoevals import Levenshtein, ExactMatch
44
from dotenv import load_dotenv
5-
import yaml
65
from faker import Faker
76
import random
7+
from config import load_config
88
from util import http_client
99

10+
load_dotenv()
1011
fake = Faker()
1112

12-
13-
def load_config() -> dict:
14-
load_dotenv()
15-
with open("./braintest.yaml", "r") as f:
16-
config = yaml.safe_load(f)
17-
18-
return config
19-
20-
2113
config = load_config()
2214

2315

functional_test/run.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
from urllib.parse import urlencode
66

77
import requests
8-
import yaml
98
from dotenv import load_dotenv
109

10+
from config import load_config
1111
from util import http_client
1212

1313

@@ -905,15 +905,6 @@ def _unique_env_var_name(self, prefix: str) -> str:
905905
)
906906

907907

908-
def load_config() -> dict[str, Any]:
909-
with open("./braintest.yaml", "r") as file_handle:
910-
loaded = yaml.safe_load(file_handle)
911-
if not isinstance(loaded, dict):
912-
print(
913-
"[functionaltest] braintest.yaml did not parse to an object. Using empty config."
914-
)
915-
return {}
916-
return loaded
917908

918909

919910
def run() -> bool:

loadtest/mock_conversation_task.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import json
22
import os
33
import random
4-
import yaml
54
from dotenv import load_dotenv
65
from faker import Faker
76
from braintrust import traced, current_span, start_span, JSONAttachment, init_logger
7+
from config import load_config
88

99
fake = Faker()
1010

@@ -62,12 +62,6 @@
6262
]
6363

6464

65-
def load_config() -> dict:
66-
with open("./braintest.yaml", "r") as f:
67-
config = yaml.safe_load(f)
68-
return config
69-
70-
7165
config = load_config()
7266

7367

loadtest/mock_default_task.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,14 @@
22
import time
33
from braintrust import traced, current_span, JSONAttachment, init_logger
44
from faker import Faker
5-
import yaml
5+
from config import load_config
66

77
fake = Faker()
88

99
MAX_SPAN_SIZE = 5 * 1024 * 1024 # 5MB
1010
QUERY_TYPES = ["factual", "coding", "analytical", "creative", "conversational"]
1111

1212

13-
def load_config() -> dict:
14-
with open("./braintest.yaml", "r") as f:
15-
config = yaml.safe_load(f)
16-
17-
return config
18-
19-
2013
config = load_config()
2114

2215

loadtest/run.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
import requests
33
import os
44
import random
5-
import yaml
65
from faker import Faker
76
from loadtest.mock_conversation_task import mock_multiturn_conversation
87
from loadtest.braintrust_http_metrics import BraintrustMetricsAdapter, BraintrustMetricsEmitter
8+
from config import load_config
99
from util import http_client
1010
from dotenv import load_dotenv
1111
from urllib.parse import urlparse
@@ -16,12 +16,6 @@
1616
fake = Faker()
1717

1818

19-
def load_config():
20-
with open("./braintest.yaml", "r") as f:
21-
config = yaml.safe_load(f)
22-
return config
23-
24-
2519
config = load_config()
2620
_LOGGER_INITIALIZED = False
2721
_BT_METRICS_EMITTER = None

main.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,13 @@
33
Main script to orchestrate functionaltest, evaltest, and loadtest execution
44
based on braintest.yaml config.
55
"""
6-
import yaml
76
import subprocess
87
import sys
98
import os
109
import signal
1110
from datetime import datetime
1211

13-
14-
def load_config(config_path="braintest.yaml"):
15-
with open(config_path, "r") as f:
16-
config = yaml.safe_load(f)
17-
return config
12+
from config import load_config
1813

1914

2015
def run_evaltest(config):
@@ -252,9 +247,6 @@ def main():
252247
except FileNotFoundError as e:
253248
print(f"Error: Configuration file not found - {e}")
254249
sys.exit(1)
255-
except yaml.YAMLError as e:
256-
print(f"Error: Failed to parse YAML configuration - {e}")
257-
sys.exit(1)
258250
except Exception as e:
259251
print(f"Fatal error: {e}")
260252
sys.exit(1)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ dependencies = [
99
"braintrust>=0.10.0",
1010
"faker>=40.1.2",
1111
"locust>=2.43.2",
12+
"pydantic-settings[yaml]>=2.6.0",
1213
"python-dotenv>=1.2.1",
1314
"pyyaml>=6.0.3",
1415
]

0 commit comments

Comments
 (0)