Skip to content

Commit ea83373

Browse files
committed
ref: centralize env parsing
Add a lazy env module with shared parsers for booleans, integers, floats, and strings. Use centralized BraintrustEnv definitions for existing logger, context, and ID generation env reads while keeping defaults at call sites. Tests cover parser behavior, lazy reads, and call-site defaults.
1 parent 8928640 commit ea83373

7 files changed

Lines changed: 182 additions & 129 deletions

File tree

py/src/braintrust/context.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Abstract context management interface for Braintrust."""
22

33
import logging
4-
import os
54
from abc import ABC, abstractmethod
65
from contextvars import ContextVar
76
from dataclasses import dataclass
87
from typing import Any
98

9+
from .env import BraintrustEnv
10+
1011

1112
@dataclass
1213
class SpanInfo:
@@ -120,7 +121,7 @@ def get_context_manager() -> ContextManager:
120121
"""
121122

122123
# Check if OTEL should be explicitly enabled via environment variable
123-
if os.environ.get("BRAINTRUST_OTEL_COMPAT", "").lower() in ("1", "true", "yes"):
124+
if BraintrustEnv.OTEL_COMPAT.get(False):
124125
try:
125126
from braintrust.otel.context import ContextManager as OtelContextManager
126127

py/src/braintrust/env.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import math
2+
import os
3+
from collections.abc import Callable
4+
from dataclasses import dataclass
5+
from enum import Enum
6+
from typing import TypeVar
7+
8+
9+
T = TypeVar("T")
10+
EnvValue = bool | float | int | str
11+
_Parser = Callable[[str], EnvValue | None]
12+
13+
14+
def parse_float(value: str) -> float | None:
15+
"""Parse a finite float from a string."""
16+
try:
17+
result = float(value)
18+
except (ValueError, TypeError):
19+
return None
20+
if math.isnan(result) or math.isinf(result):
21+
return None
22+
return result
23+
24+
25+
def parse_int(value: str) -> int | None:
26+
"""Parse an integer from a string."""
27+
try:
28+
return int(value)
29+
except (ValueError, TypeError):
30+
return None
31+
32+
33+
def parse_bool(value: str) -> bool | None:
34+
"""Parse common boolean environment variable values.
35+
36+
Accepted true values: true, 1, yes, y, on.
37+
Accepted false values: false, 0, no, n, off.
38+
Empty or unrecognized values are invalid and fall back to the EnvVar default.
39+
"""
40+
normalized = value.strip().lower()
41+
if normalized in ("true", "1", "yes", "y", "on"):
42+
return True
43+
if normalized in ("false", "0", "no", "n", "off"):
44+
return False
45+
return None
46+
47+
48+
def parse_string(value: str) -> str | None:
49+
"""Parse a string environment variable.
50+
51+
Empty strings are treated as unset so callers fall back to their default.
52+
"""
53+
return value or None
54+
55+
56+
class EnvParser(Enum):
57+
FLOAT = (parse_float,)
58+
INT = (parse_int,)
59+
BOOL = (parse_bool,)
60+
STRING = (parse_string,)
61+
62+
def __init__(self, parser: _Parser):
63+
self.parser = parser
64+
65+
66+
@dataclass(frozen=True)
67+
class EnvVar:
68+
name: str
69+
parser: EnvParser
70+
71+
def get(self, default: T) -> T:
72+
value = os.environ.get(self.name)
73+
if value is None:
74+
return default
75+
76+
parsed = self.parser.parser(value)
77+
if parsed is None:
78+
return default
79+
return cast(T, parsed)
80+
81+
82+
class BraintrustEnv:
83+
HTTP_TIMEOUT = EnvVar("BRAINTRUST_HTTP_TIMEOUT", EnvParser.FLOAT)
84+
SYNC_FLUSH = EnvVar("BRAINTRUST_SYNC_FLUSH", EnvParser.BOOL)
85+
MAX_REQUEST_SIZE = EnvVar("BRAINTRUST_MAX_REQUEST_SIZE", EnvParser.INT)
86+
DEFAULT_BATCH_SIZE = EnvVar("BRAINTRUST_DEFAULT_BATCH_SIZE", EnvParser.INT)
87+
NUM_RETRIES = EnvVar("BRAINTRUST_NUM_RETRIES", EnvParser.INT)
88+
QUEUE_SIZE = EnvVar("BRAINTRUST_QUEUE_SIZE", EnvParser.INT)
89+
QUEUE_DROP_LOGGING_PERIOD = EnvVar("BRAINTRUST_QUEUE_DROP_LOGGING_PERIOD", EnvParser.FLOAT)
90+
FAILED_PUBLISH_PAYLOADS_DIR = EnvVar("BRAINTRUST_FAILED_PUBLISH_PAYLOADS_DIR", EnvParser.STRING)
91+
ALL_PUBLISH_PAYLOADS_DIR = EnvVar("BRAINTRUST_ALL_PUBLISH_PAYLOADS_DIR", EnvParser.STRING)
92+
DISABLE_ATEXIT_FLUSH = EnvVar("BRAINTRUST_DISABLE_ATEXIT_FLUSH", EnvParser.BOOL)
93+
OTEL_COMPAT = EnvVar("BRAINTRUST_OTEL_COMPAT", EnvParser.BOOL)

py/src/braintrust/id_gen.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
import os
21
import secrets
32
import uuid
43
from abc import ABC, abstractmethod
54

5+
from .env import BraintrustEnv
6+
67

78
def get_id_generator():
89
"""Factory function that creates a new ID generator instance each time.
910
1011
This eliminates global state and makes tests parallelizable.
1112
Each caller gets their own generator instance.
1213
"""
13-
use_otel = os.getenv("BRAINTRUST_OTEL_COMPAT", "false").lower() == "true"
14+
use_otel = BraintrustEnv.OTEL_COMPAT.get(False)
1415
return OTELIDGenerator() if use_otel else UUIDGenerator()
1516

1617

py/src/braintrust/logger.py

Lines changed: 14 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
TRANSACTION_ID_FIELD,
5353
VALID_SOURCES,
5454
)
55+
from .env import BraintrustEnv
5556
from .generated_types import (
5657
AttachmentReference,
5758
AttachmentStatus,
@@ -91,7 +92,6 @@
9192
get_signature,
9293
mask_api_key,
9394
merge_dicts,
94-
parse_env_var_float,
9595
response_raise_for_status,
9696
)
9797
from .xact_ids import prettify_xact
@@ -147,7 +147,7 @@ class ParametersRef(TypedDict, total=False):
147147

148148
def _get_exporter():
149149
"""Return the active exporter (e.g. the version of SpanComponentsv*)"""
150-
use_v4 = os.getenv("BRAINTRUST_OTEL_COMPAT", "false").lower() == "true"
150+
use_v4 = BraintrustEnv.OTEL_COMPAT.get(False)
151151
return SpanComponentsV4 if use_v4 else SpanComponentsV3
152152

153153

@@ -746,7 +746,7 @@ def ping(self) -> bool:
746746

747747
def make_long_lived(self) -> None:
748748
if not self.adapter:
749-
timeout_secs = parse_env_var_float("BRAINTRUST_HTTP_TIMEOUT", 60.0)
749+
timeout_secs = BraintrustEnv.HTTP_TIMEOUT.get(60.0)
750750
self.adapter = RetryRequestExceptionsAdapter(
751751
base_num_retries=10, backoff_factor=0.5, default_timeout_secs=timeout_secs
752752
)
@@ -1013,52 +1013,19 @@ def __init__(self, api_conn: LazyValue[HTTPConnection]):
10131013
self._max_request_size_result: dict[str, Any] | None = None
10141014
self._max_request_size_lock = threading.Lock()
10151015

1016-
try:
1017-
self.sync_flush = bool(int(os.environ["BRAINTRUST_SYNC_FLUSH"]))
1018-
except:
1019-
self.sync_flush = False
1020-
1021-
try:
1022-
self._max_request_size_override = int(os.environ["BRAINTRUST_MAX_REQUEST_SIZE"])
1023-
except:
1024-
pass
1025-
1026-
try:
1027-
self.default_batch_size = int(os.environ["BRAINTRUST_DEFAULT_BATCH_SIZE"])
1028-
except:
1029-
self.default_batch_size = 100
1030-
1031-
try:
1032-
self.num_tries = int(os.environ["BRAINTRUST_NUM_RETRIES"]) + 1
1033-
except:
1034-
self.num_tries = 3
1035-
1036-
try:
1037-
self.queue_maxsize = int(os.environ["BRAINTRUST_QUEUE_SIZE"])
1038-
except:
1039-
self.queue_maxsize = DEFAULT_QUEUE_SIZE
1040-
1041-
try:
1042-
self.queue_drop_logging_period = float(os.environ["BRAINTRUST_QUEUE_DROP_LOGGING_PERIOD"])
1043-
except:
1044-
self.queue_drop_logging_period = 60
1016+
self.sync_flush = BraintrustEnv.SYNC_FLUSH.get(False)
1017+
self._max_request_size_override = BraintrustEnv.MAX_REQUEST_SIZE.get(None)
1018+
self.default_batch_size = BraintrustEnv.DEFAULT_BATCH_SIZE.get(100)
1019+
self.num_tries = BraintrustEnv.NUM_RETRIES.get(2) + 1
1020+
queue_maxsize = BraintrustEnv.QUEUE_SIZE.get(None)
1021+
self.queue_maxsize = DEFAULT_QUEUE_SIZE if queue_maxsize is None else queue_maxsize
1022+
self.queue_drop_logging_period = BraintrustEnv.QUEUE_DROP_LOGGING_PERIOD.get(60.0)
10451023

10461024
self._queue_drop_logging_state = dict(lock=threading.Lock(), num_dropped=0, last_logged_timestamp=0)
10471025

1048-
try:
1049-
self.failed_publish_payloads_dir = os.environ["BRAINTRUST_FAILED_PUBLISH_PAYLOADS_DIR"]
1050-
except:
1051-
self.failed_publish_payloads_dir = None
1052-
1053-
try:
1054-
self.all_publish_payloads_dir = os.environ["BRAINTRUST_ALL_PUBLISH_PAYLOADS_DIR"]
1055-
except:
1056-
self.all_publish_payloads_dir = None
1057-
1058-
try:
1059-
disable_atexit_flush = os.environ["BRAINTRUST_DISABLE_ATEXIT_FLUSH"].lower() in ("true", "1", "yes")
1060-
except:
1061-
disable_atexit_flush = False
1026+
self.failed_publish_payloads_dir = BraintrustEnv.FAILED_PUBLISH_PAYLOADS_DIR.get(None)
1027+
self.all_publish_payloads_dir = BraintrustEnv.ALL_PUBLISH_PAYLOADS_DIR.get(None)
1028+
disable_atexit_flush = BraintrustEnv.DISABLE_ATEXIT_FLUSH.get(False)
10621029

10631030
self.start_thread_lock = threading.RLock()
10641031
self.thread = threading.Thread(target=self._publisher, daemon=True)
@@ -4406,7 +4373,7 @@ def export(self) -> str:
44064373
compute_object_metadata_args = None
44074374

44084375
# Choose SpanComponents version based on BRAINTRUST_OTEL_COMPAT env var
4409-
use_v4 = os.getenv("BRAINTRUST_OTEL_COMPAT", "false").lower() == "true"
4376+
use_v4 = BraintrustEnv.OTEL_COMPAT.get(False)
44104377
span_components_class = SpanComponentsV4 if use_v4 else SpanComponentsV3
44114378

44124379
# Disable span cache since remote function spans won't be in the local cache

py/src/braintrust/test_env.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from .env import BraintrustEnv, EnvParser, EnvVar, parse_bool, parse_float, parse_int, parse_string
2+
3+
4+
class TestEnvParsers:
5+
def test_parse_float(self):
6+
assert parse_float("123.45") == 123.45
7+
assert parse_float("nan") is None
8+
assert parse_float("inf") is None
9+
assert parse_float("") is None
10+
assert parse_float("not_a_number") is None
11+
12+
def test_parse_int(self):
13+
assert parse_int("123") == 123
14+
assert parse_int("-5") == -5
15+
assert parse_int("") is None
16+
assert parse_int("1.2") is None
17+
assert parse_int("not_an_int") is None
18+
19+
def test_parse_bool(self):
20+
for value in ("true", "True", "1", "yes", "y", "on"):
21+
assert parse_bool(value) is True
22+
for value in ("false", "False", "0", "no", "n", "off"):
23+
assert parse_bool(value) is False
24+
assert parse_bool("") is None
25+
assert parse_bool("maybe") is None
26+
27+
def test_parse_string(self):
28+
assert parse_string("value") == "value"
29+
assert parse_string("") is None
30+
31+
32+
class TestEnvVar:
33+
def test_returns_default_when_env_not_set(self, monkeypatch):
34+
monkeypatch.delenv("TEST_ENV_VAR", raising=False)
35+
assert EnvVar("TEST_ENV_VAR", EnvParser.INT).get(42) == 42
36+
37+
def test_returns_default_when_env_invalid(self, monkeypatch):
38+
monkeypatch.setenv("TEST_ENV_VAR", "invalid")
39+
assert EnvVar("TEST_ENV_VAR", EnvParser.INT).get(42) == 42
40+
41+
def test_reads_environment_lazily(self, monkeypatch):
42+
env_var = EnvVar("TEST_ENV_VAR", EnvParser.INT)
43+
monkeypatch.setenv("TEST_ENV_VAR", "1")
44+
assert env_var.get(42) == 1
45+
monkeypatch.setenv("TEST_ENV_VAR", "2")
46+
assert env_var.get(42) == 2
47+
48+
def test_default_is_supplied_by_call_site(self, monkeypatch):
49+
env_var = EnvVar("TEST_ENV_VAR", EnvParser.INT)
50+
monkeypatch.delenv("TEST_ENV_VAR", raising=False)
51+
assert env_var.get(1) == 1
52+
assert env_var.get(2) == 2
53+
54+
55+
class TestBraintrustEnv:
56+
def test_centralized_env_definitions_are_lazy(self, monkeypatch):
57+
monkeypatch.delenv("BRAINTRUST_HTTP_TIMEOUT", raising=False)
58+
assert BraintrustEnv.HTTP_TIMEOUT.get(60.0) == 60.0
59+
monkeypatch.setenv("BRAINTRUST_HTTP_TIMEOUT", "0.2")
60+
assert BraintrustEnv.HTTP_TIMEOUT.get(60.0) == 0.2
61+
62+
def test_otel_compat_uses_shared_bool_parser(self, monkeypatch):
63+
for value in ("true", "1", "yes"):
64+
monkeypatch.setenv("BRAINTRUST_OTEL_COMPAT", value)
65+
assert BraintrustEnv.OTEL_COMPAT.get(False) is True
66+
67+
monkeypatch.setenv("BRAINTRUST_OTEL_COMPAT", "false")
68+
assert BraintrustEnv.OTEL_COMPAT.get(True) is False

py/src/braintrust/test_util.py

Lines changed: 1 addition & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,8 @@
1-
import os
21
import unittest
32

43
import pytest
54

6-
from .util import LazyValue, mask_api_key, merge_dicts_with_paths, parse_env_var_float
7-
8-
9-
class TestParseEnvVarFloat:
10-
"""Tests for parse_env_var_float helper."""
11-
12-
def test_returns_default_when_env_not_set(self):
13-
assert parse_env_var_float("NONEXISTENT_VAR_12345", 42.0) == 42.0
14-
15-
def test_parses_valid_float(self):
16-
os.environ["TEST_FLOAT"] = "123.45"
17-
try:
18-
assert parse_env_var_float("TEST_FLOAT", 0.0) == 123.45
19-
finally:
20-
del os.environ["TEST_FLOAT"]
21-
22-
def test_returns_default_for_nan(self):
23-
os.environ["TEST_FLOAT"] = "nan"
24-
try:
25-
assert parse_env_var_float("TEST_FLOAT", 99.0) == 99.0
26-
finally:
27-
del os.environ["TEST_FLOAT"]
28-
29-
def test_returns_default_for_inf(self):
30-
os.environ["TEST_FLOAT"] = "inf"
31-
try:
32-
assert parse_env_var_float("TEST_FLOAT", 99.0) == 99.0
33-
finally:
34-
del os.environ["TEST_FLOAT"]
35-
36-
def test_returns_default_for_negative_inf(self):
37-
os.environ["TEST_FLOAT"] = "-inf"
38-
try:
39-
assert parse_env_var_float("TEST_FLOAT", 99.0) == 99.0
40-
finally:
41-
del os.environ["TEST_FLOAT"]
42-
43-
def test_returns_default_for_empty_string(self):
44-
os.environ["TEST_FLOAT"] = ""
45-
try:
46-
assert parse_env_var_float("TEST_FLOAT", 99.0) == 99.0
47-
finally:
48-
del os.environ["TEST_FLOAT"]
49-
50-
def test_returns_default_for_invalid_string(self):
51-
os.environ["TEST_FLOAT"] = "not_a_number"
52-
try:
53-
assert parse_env_var_float("TEST_FLOAT", 99.0) == 99.0
54-
finally:
55-
del os.environ["TEST_FLOAT"]
56-
57-
def test_allows_negative_values(self):
58-
os.environ["TEST_FLOAT"] = "-5.5"
59-
try:
60-
assert parse_env_var_float("TEST_FLOAT", 0.0) == -5.5
61-
finally:
62-
del os.environ["TEST_FLOAT"]
5+
from .util import LazyValue, mask_api_key, merge_dicts_with_paths
636

647

658
class TestLazyValue(unittest.TestCase):

0 commit comments

Comments
 (0)