Skip to content

Commit e915046

Browse files
Pigbibiclaude
andauthored
feat(ci): add cross-platform consistency check and env var validation scripts (#132)
- validate_platform_consistency.py: checks strategy_registry vs shared catalog - check_required_env.py: pre-deploy required env var validation with JSON output Co-authored-by: Claude <noreply@anthropic.com>
1 parent a283561 commit e915046

2 files changed

Lines changed: 416 additions & 0 deletions

File tree

scripts/check_required_env.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#!/usr/bin/env python3
2+
"""Validate that required environment variables are set for the current platform.
3+
4+
Reads .env.example (or a JSON schema) to determine required env vars,
5+
then checks os.environ for their presence.
6+
7+
Usage:
8+
python scripts/check_required_env.py # check all vars
9+
python scripts/check_required_env.py --secrets # also check secret manager
10+
python scripts/check_required_env.py --json # output as JSON for CI
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
import os
17+
import re
18+
import sys
19+
from pathlib import Path
20+
from typing import Iterable
21+
22+
23+
REQUIRED_VARS_BY_PLATFORM: dict[str, list[str]] = {
24+
"schwab": [
25+
"RUNTIME_TARGET_JSON",
26+
"SCHWAB_API_KEY",
27+
"SCHWAB_APP_SECRET",
28+
"TELEGRAM_TOKEN",
29+
],
30+
"ibkr": [
31+
"RUNTIME_TARGET_JSON",
32+
"IBKR_ACCOUNT_IDS",
33+
"IB_GATEWAY_HOST",
34+
"IB_GATEWAY_PORT",
35+
"TELEGRAM_TOKEN",
36+
],
37+
"longbridge": [
38+
"RUNTIME_TARGET_JSON",
39+
"LONGPORT_SECRET_NAME",
40+
"TELEGRAM_TOKEN",
41+
],
42+
}
43+
44+
45+
def detect_platform() -> str | None:
46+
"""Heuristic: detect platform from environment."""
47+
if os.getenv("SCHWAB_API_KEY"):
48+
return "schwab"
49+
if os.getenv("IBKR_ACCOUNT_IDS") or os.getenv("IB_GATEWAY_HOST"):
50+
return "ibkr"
51+
if os.getenv("LONGPORT_SECRET_NAME"):
52+
return "longbridge"
53+
if os.getenv("K_SERVICE", "").startswith("charles-schwab"):
54+
return "schwab"
55+
if os.getenv("K_SERVICE", "").startswith("interactive-brokers"):
56+
return "ibkr"
57+
if os.getenv("K_SERVICE", "").startswith("longbridge"):
58+
return "longbridge"
59+
return None
60+
61+
62+
def parse_env_example(path: Path) -> dict[str, tuple[bool, str]]:
63+
"""Parse .env.example, return {VAR_NAME: (required, description)}."""
64+
if not path.exists():
65+
return {}
66+
result: dict[str, tuple[bool, str]] = {}
67+
for line in path.read_text().splitlines():
68+
line = line.strip()
69+
if not line or line.startswith("#"):
70+
continue
71+
# Extract description from preceding comment
72+
is_required = "REQUIRED" in line.upper() if "#" in line else False
73+
match = re.match(r"^(\w+)=(.*)", line)
74+
if match:
75+
name = match.group(1)
76+
value = match.group(2)
77+
desc = ""
78+
if "<" in value:
79+
is_required = True
80+
desc = f"must not be template value: {value}"
81+
result[name] = (is_required, desc)
82+
return result
83+
84+
85+
def check_env(
86+
platform_id: str | None = None,
87+
*,
88+
env: dict | None = None,
89+
) -> tuple[list[str], list[str]]:
90+
"""Returns (missing_required, warnings)."""
91+
env = env or os.environ
92+
platform_id = platform_id or detect_platform()
93+
94+
if platform_id is None:
95+
return (
96+
["Cannot detect platform; set RUNTIME_TARGET_JSON or pass --platform"],
97+
[],
98+
)
99+
100+
required_vars = REQUIRED_VARS_BY_PLATFORM.get(platform_id, [])
101+
if not required_vars:
102+
return ([f"No required var list for platform '{platform_id}'"], [])
103+
104+
missing = [v for v in required_vars if not env.get(v)]
105+
warnings = []
106+
107+
# Also check .env.example if present
108+
repo_root = Path(__file__).resolve().parents[1]
109+
example_file = repo_root / ".env.example"
110+
parsed = parse_env_example(example_file)
111+
for name, (is_required, desc) in parsed.items():
112+
if is_required and not env.get(name) and name not in required_vars:
113+
missing.append(f"{name} ({desc or 'from .env.example'})")
114+
115+
# Check that RUNTIME_TARGET_JSON is valid JSON
116+
target_json = env.get("RUNTIME_TARGET_JSON")
117+
if target_json:
118+
try:
119+
json.loads(target_json)
120+
except json.JSONDecodeError as exc:
121+
missing.append(f"RUNTIME_TARGET_JSON is invalid JSON: {exc}")
122+
123+
return missing, warnings
124+
125+
126+
def main() -> int:
127+
json_output = "--json" in sys.argv
128+
platform_id = None
129+
130+
for arg in sys.argv[1:]:
131+
if arg.startswith("--platform="):
132+
platform_id = arg.split("=", 1)[1]
133+
134+
platform_id = platform_id or detect_platform()
135+
136+
if platform_id is None:
137+
if json_output:
138+
print(json.dumps({"ok": False, "error": "Cannot detect platform"}))
139+
else:
140+
print("ERROR: Cannot detect platform. Set RUNTIME_TARGET_JSON or use --platform=<id>")
141+
return 2
142+
143+
missing, warnings = check_env(platform_id)
144+
145+
if json_output:
146+
result = {
147+
"platform": platform_id,
148+
"ok": len(missing) == 0,
149+
"missing_required": missing,
150+
"warnings": warnings,
151+
"required_count": len(REQUIRED_VARS_BY_PLATFORM.get(platform_id, [])),
152+
"missing_count": len(missing),
153+
}
154+
print(json.dumps(result, indent=2))
155+
return 1 if missing else 0
156+
157+
print(f"Platform: {platform_id}")
158+
print(f"Required vars: {len(REQUIRED_VARS_BY_PLATFORM.get(platform_id, []))}")
159+
160+
for w in warnings:
161+
print(f" ⚠ {w}")
162+
for m in missing:
163+
print(f" ✗ MISSING: {m}")
164+
165+
if missing:
166+
print(f"\n{len(missing)} required variable(s) missing!")
167+
return 1
168+
169+
print("\nAll required environment variables present.")
170+
return 0
171+
172+
173+
if __name__ == "__main__":
174+
raise SystemExit(main())

0 commit comments

Comments
 (0)