Add app_config.py: config.json loader, validation, env-precedence resolvers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPNF37MP9Kuv62B1VBrZ1b
This commit is contained in:
Harwinder Sekhon
2026-07-21 12:47:48 -04:00
parent 8de77deadd
commit 2c070fb9b3
2 changed files with 376 additions and 0 deletions
+136
View File
@@ -18,6 +18,20 @@ import pandas as pd
sys.path.insert(0, os.path.dirname(__file__))
# ── Hermetic app-config: the suite must not depend on the developer's
# local config.json or TELEGRAM_*/TWELVE_DATA_API_KEY env vars. The
# dummy TD key mirrors the pre-config.json behavior where a key was
# always present (TDClient built, no network at construction).
import json
import app_config
for _k in ("TELEGRAM_TOKEN", "TELEGRAM_CHAT_ID", "TELEGRAM_CHANNEL_ID",
"TWELVE_DATA_API_KEY"):
os.environ.pop(_k, None)
_TEST_APP = app_config.AppConfig(
provider_settings={"twelve_data": {"api_key": "test-key"}}
)
app_config.set_app_config(_TEST_APP)
PASS = 0
FAIL = 0
ERRORS = []
@@ -2525,6 +2539,128 @@ def _():
assert args2.cp is False
# ══════════════════════════════════════════════════════════════════════
# MODULE 13: app_config.py (config.json overlay)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 13: app_config.py ═══")
import tempfile
from app_config import (
AppConfig, AppConfigError, load_app_config, set_app_config,
telegram_token, telegram_chat_id, telegram_channel_id,
provider_api_key, apply_pair_overrides,
)
def _write_json(payload) -> str:
fh = tempfile.NamedTemporaryFile(
"w", suffix=".json", delete=False, encoding="utf-8")
if isinstance(payload, str):
fh.write(payload)
else:
json.dump(payload, fh)
fh.close()
return fh.name
_FULL_CFG = {
"telegram": {"token": "t0k", "chat_id": "111", "channel_id": "-100222",
"alert_min_quality": "B"},
"data_providers": {"active": "twelve_data",
"twelve_data": {"api_key": "json-key"}},
"scanning": {"pairs": ["XAU/USD"], "timeframes": ["M15", "H1"]},
"pairs": {"XAU/USD": {"blocked_hours_utc": [3, 4]}},
"log_level": "WARNING",
}
@test("app_config: valid config.json maps every field")
def _():
app = load_app_config(_write_json(_FULL_CFG))
assert app.telegram_token == "t0k"
assert app.telegram_chat_id == "111"
assert app.telegram_channel_id == "-100222"
assert app.alert_min_quality == "B"
assert app.active_provider == "twelve_data"
assert app.provider_settings["twelve_data"]["api_key"] == "json-key"
assert app.scan_pairs == [XAUUSD]
assert app.scan_timeframes == [Timeframe.M15, Timeframe.H1]
assert app.pair_overrides[XAUUSD]["blocked_hours_utc"] == [3, 4]
assert app.log_level == "WARNING"
@test("app_config: missing file returns pure defaults")
def _():
app = load_app_config("/nonexistent/config.json")
assert app == AppConfig(), "absent config.json must yield pure defaults"
@test("app_config: malformed JSON raises AppConfigError")
def _():
path = _write_json("{not json")
try:
load_app_config(path)
assert False, "should have raised"
except AppConfigError as e:
assert "not valid JSON" in str(e)
@test("app_config: unknown keys raise AppConfigError")
def _():
for payload in (
{"telgram": {}}, # top-level typo
{"telegram": {"tokn": "x"}}, # section typo
{"pairs": {"XAU/USD": {"blocked_hours": [1]}}}, # pair-block typo
{"scanning": {"pair": ["XAU/USD"]}}, # scanning typo
):
try:
load_app_config(_write_json(payload))
assert False, f"should have raised for {payload}"
except AppConfigError as e:
assert "unknown key" in str(e).lower()
@test("app_config: invalid values raise AppConfigError")
def _():
bad_payloads = [
{"pairs": {"XAU/USD": {"blocked_hours_utc": [24]}}}, # hour range
{"pairs": {"XAU/USD": {"blocked_hours_utc": "3"}}}, # not a list
{"pairs": {"EUR/USD": {"blocked_hours_utc": [3]}}}, # unknown pair
{"scanning": {"pairs": ["EUR/USD"]}}, # unknown pair
{"scanning": {"timeframes": ["M7"]}}, # bad TF name
{"scanning": {"timeframes": ["W1"]}}, # unscannable TF
{"data_providers": {"active": "polygon",
"twelve_data": {"api_key": "k"}}}, # no block
{"telegram": {"alert_min_quality": "Z"}}, # bad quality
{"log_level": "CHATTY"}, # bad level
]
for payload in bad_payloads:
try:
load_app_config(_write_json(payload))
assert False, f"should have raised for {payload}"
except AppConfigError:
pass
@test("app_config: env precedence env → config.json → default")
def _():
app = load_app_config(_write_json(_FULL_CFG))
with patch.dict(os.environ, {}, clear=True):
assert telegram_token(app) == "t0k"
assert telegram_chat_id(app) == "111"
assert telegram_channel_id(app) == "-100222"
assert provider_api_key(app) == "json-key"
with patch.dict(os.environ, {"TELEGRAM_TOKEN": "env-tok",
"TELEGRAM_CHAT_ID": "999",
"TWELVE_DATA_API_KEY": "env-key"}):
assert telegram_token(app) == "env-tok"
assert telegram_chat_id(app) == "999"
assert provider_api_key(app) == "env-key"
# channel: env set-but-empty means "disable feed" and must beat JSON
with patch.dict(os.environ, {"TELEGRAM_CHANNEL_ID": ""}):
assert telegram_channel_id(app) == ""
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════