executor_config.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  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. }
  42. def _is_frozen():
  43. """True when running from a PyInstaller onefile bundle."""
  44. return bool(getattr(sys, "frozen", False))
  45. def _base_dir():
  46. """Directory that should host the sidecar config for this runtime.
  47. - EXE mode: the folder containing the executable.
  48. - Script mode: the repository root (parent of the scripts/ folder).
  49. """
  50. if _is_frozen():
  51. return os.path.dirname(os.path.abspath(sys.executable))
  52. here = os.path.dirname(os.path.abspath(__file__))
  53. return os.path.dirname(here)
  54. def repo_root():
  55. """Repository root (parent of scripts/) when running from source."""
  56. here = os.path.dirname(os.path.abspath(__file__))
  57. return os.path.dirname(here)
  58. def find_config_path(cli_path=None):
  59. """Locate a config file path, or None when none exists.
  60. Args:
  61. cli_path: optional explicit path from --config.
  62. Returns:
  63. str path of the first existing config file, else None.
  64. """
  65. candidates = []
  66. if cli_path:
  67. candidates.append(cli_path)
  68. env_path = os.environ.get("EXECUTOR_CONFIG")
  69. if env_path:
  70. candidates.append(env_path)
  71. candidates.append(os.path.join(_base_dir(), "executor_config.json"))
  72. candidates.append(os.path.join(repo_root(), "executor_config.json"))
  73. for cand in candidates:
  74. if cand and os.path.isfile(cand):
  75. return cand
  76. return None
  77. def _resolve_model_path(model_path, base_dir):
  78. """Turn a possibly-relative model_path into an absolute path.
  79. Relative paths resolve against the config base dir (EXE dir or repo
  80. root). Empty / None values stay empty (EXE may rely on the operator
  81. to set it; script mode fills a default).
  82. """
  83. if not model_path:
  84. return None
  85. if os.path.isabs(model_path):
  86. return os.path.normpath(model_path)
  87. return os.path.normpath(os.path.join(base_dir, model_path))
  88. def validate_config(cfg):
  89. """Validate a config dict; raise ValueError on any violation.
  90. Checks: types, ranges and known enum values. Empty model_path is
  91. allowed (EXE may be pointed at a model only at run time).
  92. """
  93. if not isinstance(cfg, dict):
  94. raise ValueError("config must be a dict")
  95. web = cfg.get("web_base_url")
  96. if not isinstance(web, str) or not web.startswith("http"):
  97. raise ValueError("web_base_url must be an http(s) URL string")
  98. instances = cfg.get("instances")
  99. if not isinstance(instances, int) or instances < 1:
  100. raise ValueError("instances must be an int >= 1")
  101. interval = cfg.get("poll_interval")
  102. if not isinstance(interval, (int, float)) or interval <= 0:
  103. raise ValueError("poll_interval must be a positive number")
  104. level = cfg.get("log_level")
  105. if level not in VALID_LOG_LEVELS:
  106. raise ValueError("log_level must be one of %s" % (VALID_LOG_LEVELS,))
  107. tool = cfg.get("tool")
  108. if not isinstance(tool, str) or not tool:
  109. raise ValueError("tool must be a non-empty string")
  110. mock = cfg.get("enable_mock")
  111. if not isinstance(mock, bool):
  112. raise ValueError("enable_mock must be a boolean")
  113. thermal = cfg.get("enable_thermal")
  114. if not isinstance(thermal, bool):
  115. raise ValueError("enable_thermal must be a boolean")
  116. return cfg
  117. def load_config(cli_path=None, env_overrides=True):
  118. """Load and validate the effective configuration.
  119. Args:
  120. cli_path: optional --config path.
  121. env_overrides: when True, let env vars override file fields.
  122. Returns:
  123. dict with resolved absolute model_path/log_dir and the source
  124. string under key "config_source".
  125. """
  126. cfg = default_config()
  127. source = "defaults"
  128. cfg_path = find_config_path(cli_path)
  129. if cfg_path:
  130. try:
  131. with open(cfg_path, "r", encoding="utf-8") as fh:
  132. file_cfg = json.load(fh)
  133. if not isinstance(file_cfg, dict):
  134. raise ValueError("config file must contain a JSON object")
  135. cfg.update({k: v for k, v in file_cfg.items() if v is not None})
  136. source = cfg_path
  137. except (OSError, ValueError) as exc:
  138. # A malformed explicit config must not be silently ignored:
  139. # the operator asked for it, so surface the error.
  140. raise ValueError("failed to load config %s: %s" % (cfg_path, exc))
  141. if env_overrides:
  142. env_map = {
  143. "WEB_BASE_URL": "web_base_url",
  144. "MOTORCAD_MODEL": "model_path",
  145. "EXECUTOR_INSTANCES": "instances",
  146. "EXECUTOR_POLL_INTERVAL": "poll_interval",
  147. "EXECUTOR_LOG_DIR": "log_dir",
  148. "EXECUTOR_LOG_LEVEL": "log_level",
  149. "EXECUTOR_TOOL": "tool",
  150. "EXECUTOR_MOCK": "enable_mock",
  151. "EXECUTOR_THERMAL": "enable_thermal",
  152. }
  153. for env_key, cfg_key in env_map.items():
  154. raw = os.environ.get(env_key)
  155. if raw is None or raw == "":
  156. continue
  157. if cfg_key in ("instances", "poll_interval"):
  158. try:
  159. cfg[cfg_key] = float(raw) if "." in raw else int(raw)
  160. except ValueError:
  161. raise ValueError("env %s must be numeric, got %r" % (env_key, raw))
  162. elif cfg_key in ("enable_mock", "enable_thermal"):
  163. cfg[cfg_key] = raw.strip().lower() in ("1", "true", "yes", "on")
  164. else:
  165. cfg[cfg_key] = raw
  166. # Coerce numeric fields read from JSON (json gives int/float already).
  167. if not isinstance(cfg["instances"], int):
  168. cfg["instances"] = int(cfg["instances"])
  169. if not isinstance(cfg["poll_interval"], (int, float)):
  170. cfg["poll_interval"] = float(cfg["poll_interval"])
  171. base_dir = _base_dir() if _is_frozen() else repo_root()
  172. model_path = _resolve_model_path(cfg.get("model_path"), base_dir)
  173. log_dir = cfg.get("log_dir")
  174. if not log_dir:
  175. log_dir = DEFAULT_LOG_DIR
  176. if os.path.isabs(log_dir):
  177. log_dir = os.path.normpath(log_dir)
  178. else:
  179. log_dir = os.path.normpath(os.path.join(base_dir, log_dir))
  180. resolved = dict(cfg)
  181. resolved["model_path"] = model_path
  182. resolved["log_dir"] = log_dir
  183. resolved["config_source"] = source
  184. validate_config(resolved)
  185. return resolved