executor_config.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. """Executor configuration loader (P5-M2).
  2. Loads executor_config.json for the local headless executor. The goal is
  3. to make the packaged EXE configurable without re-building: web address,
  4. model path, logging, poll interval and instance count all come from a
  5. JSON sidecar that the operator can edit next to the EXE.
  6. Priority (highest first):
  7. 1. --config CLI path (explicit)
  8. 2. $EXECUTOR_CONFIG env var (explicit)
  9. 3. <exe_or_script_dir>/executor_config.json (sidecar next to EXE)
  10. 4. <repo_root>/executor_config.json (source-tree template)
  11. 5. built-in defaults
  12. Single fields may still be overridden by env vars (highest):
  13. WEB_BASE_URL / MOTORCAD_MODEL / EXECUTOR_INSTANCES /
  14. EXECUTOR_POLL_INTERVAL / EXECUTOR_LOG_DIR / EXECUTOR_LOG_LEVEL /
  15. EXECUTOR_TOOL / EXECUTOR_MOCK
  16. All code in this module is ASCII only (AGENTS.md constraint).
  17. """
  18. import json
  19. import os
  20. import sys
  21. DEFAULT_WEB_BASE_URL = "http://127.0.0.1:8000"
  22. DEFAULT_POLL_INTERVAL = 5
  23. DEFAULT_INSTANCES = 1
  24. DEFAULT_LOG_DIR = "output/executor_logs"
  25. DEFAULT_LOG_LEVEL = "INFO"
  26. DEFAULT_TOOL = "motorcad"
  27. DEFAULT_MODEL_REL = "models/MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot"
  28. VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
  29. def default_config():
  30. """Return the built-in default configuration dict."""
  31. return {
  32. "web_base_url": DEFAULT_WEB_BASE_URL,
  33. "model_path": DEFAULT_MODEL_REL,
  34. "poll_interval": DEFAULT_POLL_INTERVAL,
  35. "instances": DEFAULT_INSTANCES,
  36. "log_dir": DEFAULT_LOG_DIR,
  37. "log_level": DEFAULT_LOG_LEVEL,
  38. "tool": DEFAULT_TOOL,
  39. "enable_mock": False,
  40. "enable_thermal": False,
  41. "ambient_temperature": 25.0,
  42. }
  43. def _is_frozen():
  44. """True when running from a PyInstaller onefile bundle."""
  45. return bool(getattr(sys, "frozen", False))
  46. def _base_dir():
  47. """Directory that should host the sidecar config for this runtime.
  48. - EXE mode: the folder containing the executable.
  49. - Script mode: the repository root (parent of the scripts/ folder).
  50. """
  51. if _is_frozen():
  52. return os.path.dirname(os.path.abspath(sys.executable))
  53. here = os.path.dirname(os.path.abspath(__file__))
  54. return os.path.dirname(here)
  55. def repo_root():
  56. """Repository root (parent of scripts/) when running from source."""
  57. here = os.path.dirname(os.path.abspath(__file__))
  58. return os.path.dirname(here)
  59. def find_config_path(cli_path=None):
  60. """Locate a config file path, or None when none exists.
  61. Args:
  62. cli_path: optional explicit path from --config.
  63. Returns:
  64. str path of the first existing config file, else None.
  65. """
  66. candidates = []
  67. if cli_path:
  68. candidates.append(cli_path)
  69. env_path = os.environ.get("EXECUTOR_CONFIG")
  70. if env_path:
  71. candidates.append(env_path)
  72. candidates.append(os.path.join(_base_dir(), "executor_config.json"))
  73. candidates.append(os.path.join(repo_root(), "executor_config.json"))
  74. for cand in candidates:
  75. if cand and os.path.isfile(cand):
  76. return cand
  77. return None
  78. def _resolve_model_path(model_path, base_dir):
  79. """Turn a possibly-relative model_path into an absolute path.
  80. Relative paths resolve against the config base dir (EXE dir or repo
  81. root). Empty / None values stay empty (EXE may rely on the operator
  82. to set it; script mode fills a default).
  83. """
  84. if not model_path:
  85. return None
  86. if os.path.isabs(model_path):
  87. return os.path.normpath(model_path)
  88. return os.path.normpath(os.path.join(base_dir, model_path))
  89. def validate_config(cfg):
  90. """Validate a config dict; raise ValueError on any violation.
  91. Checks: types, ranges and known enum values. Empty model_path is
  92. allowed (EXE may be pointed at a model only at run time).
  93. """
  94. if not isinstance(cfg, dict):
  95. raise ValueError("config must be a dict")
  96. web = cfg.get("web_base_url")
  97. if not isinstance(web, str) or not web.startswith("http"):
  98. raise ValueError("web_base_url must be an http(s) URL string")
  99. instances = cfg.get("instances")
  100. if not isinstance(instances, int) or instances < 1:
  101. raise ValueError("instances must be an int >= 1")
  102. interval = cfg.get("poll_interval")
  103. if not isinstance(interval, (int, float)) or interval <= 0:
  104. raise ValueError("poll_interval must be a positive number")
  105. level = cfg.get("log_level")
  106. if level not in VALID_LOG_LEVELS:
  107. raise ValueError("log_level must be one of %s" % (VALID_LOG_LEVELS,))
  108. tool = cfg.get("tool")
  109. if not isinstance(tool, str) or not tool:
  110. raise ValueError("tool must be a non-empty string")
  111. mock = cfg.get("enable_mock")
  112. if not isinstance(mock, bool):
  113. raise ValueError("enable_mock must be a boolean")
  114. thermal = cfg.get("enable_thermal")
  115. if not isinstance(thermal, bool):
  116. raise ValueError("enable_thermal must be a boolean")
  117. ambient = cfg.get("ambient_temperature")
  118. if ambient is not None and not isinstance(ambient, (int, float)):
  119. raise ValueError("ambient_temperature must be a number or null")
  120. return cfg
  121. def load_config(cli_path=None, env_overrides=True):
  122. """Load and validate the effective configuration.
  123. Args:
  124. cli_path: optional --config path.
  125. env_overrides: when True, let env vars override file fields.
  126. Returns:
  127. dict with resolved absolute model_path/log_dir and the source
  128. string under key "config_source".
  129. """
  130. cfg = default_config()
  131. source = "defaults"
  132. cfg_path = find_config_path(cli_path)
  133. if cfg_path:
  134. try:
  135. with open(cfg_path, "r", encoding="utf-8") as fh:
  136. file_cfg = json.load(fh)
  137. if not isinstance(file_cfg, dict):
  138. raise ValueError("config file must contain a JSON object")
  139. cfg.update({k: v for k, v in file_cfg.items() if v is not None})
  140. source = cfg_path
  141. except (OSError, ValueError) as exc:
  142. # A malformed explicit config must not be silently ignored:
  143. # the operator asked for it, so surface the error.
  144. raise ValueError("failed to load config %s: %s" % (cfg_path, exc))
  145. if env_overrides:
  146. env_map = {
  147. "WEB_BASE_URL": "web_base_url",
  148. "MOTORCAD_MODEL": "model_path",
  149. "EXECUTOR_INSTANCES": "instances",
  150. "EXECUTOR_POLL_INTERVAL": "poll_interval",
  151. "EXECUTOR_LOG_DIR": "log_dir",
  152. "EXECUTOR_LOG_LEVEL": "log_level",
  153. "EXECUTOR_TOOL": "tool",
  154. "EXECUTOR_MOCK": "enable_mock",
  155. "EXECUTOR_THERMAL": "enable_thermal",
  156. "EXECUTOR_AMBIENT": "ambient_temperature",
  157. }
  158. for env_key, cfg_key in env_map.items():
  159. raw = os.environ.get(env_key)
  160. if raw is None or raw == "":
  161. continue
  162. if cfg_key in ("instances", "poll_interval", "ambient_temperature"):
  163. try:
  164. cfg[cfg_key] = float(raw) if "." in raw else int(raw)
  165. except ValueError:
  166. raise ValueError("env %s must be numeric, got %r" % (env_key, raw))
  167. elif cfg_key in ("enable_mock", "enable_thermal"):
  168. cfg[cfg_key] = raw.strip().lower() in ("1", "true", "yes", "on")
  169. else:
  170. cfg[cfg_key] = raw
  171. # Coerce numeric fields read from JSON (json gives int/float already).
  172. if not isinstance(cfg["instances"], int):
  173. cfg["instances"] = int(cfg["instances"])
  174. if not isinstance(cfg["poll_interval"], (int, float)):
  175. cfg["poll_interval"] = float(cfg["poll_interval"])
  176. base_dir = _base_dir() if _is_frozen() else repo_root()
  177. model_path = _resolve_model_path(cfg.get("model_path"), base_dir)
  178. log_dir = cfg.get("log_dir")
  179. if not log_dir:
  180. log_dir = DEFAULT_LOG_DIR
  181. if os.path.isabs(log_dir):
  182. log_dir = os.path.normpath(log_dir)
  183. else:
  184. log_dir = os.path.normpath(os.path.join(base_dir, log_dir))
  185. resolved = dict(cfg)
  186. resolved["model_path"] = model_path
  187. resolved["log_dir"] = log_dir
  188. resolved["config_source"] = source
  189. validate_config(resolved)
  190. return resolved