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:
+136
@@ -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
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user