config.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. #!/usr/bin/env python3
  2. """
  3. Trellis configuration reader.
  4. Reads settings from .trellis/config.yaml with sensible defaults.
  5. """
  6. from __future__ import annotations
  7. import sys
  8. from pathlib import Path
  9. from .paths import DIR_WORKFLOW, get_repo_root
  10. # =============================================================================
  11. # YAML Simple Parser (no dependencies)
  12. # =============================================================================
  13. def _unquote(s: str) -> str:
  14. """Remove exactly one layer of matching surrounding quotes.
  15. Unlike str.strip('"'), this only removes the outermost pair,
  16. preserving any nested quotes inside the value.
  17. Examples:
  18. _unquote('"hello"') -> 'hello'
  19. _unquote("'hello'") -> 'hello'
  20. _unquote('"echo \\'hi\\'"') -> "echo 'hi'"
  21. _unquote('hello') -> 'hello'
  22. _unquote('"hello\\'') -> '"hello\\'' (mismatched, unchanged)
  23. """
  24. if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"):
  25. return s[1:-1]
  26. return s
  27. def _strip_inline_comment(value: str) -> str:
  28. """Strip ` # …` inline comments while preserving `#` inside quoted strings.
  29. YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token
  30. is part of the value. Quoted strings are immune.
  31. Mirrors :func:`common.trellis_config._strip_inline_comment` so both
  32. parsers handle ``key: value # comment`` identically.
  33. """
  34. in_quote: str | None = None
  35. for idx, ch in enumerate(value):
  36. if in_quote:
  37. if ch == in_quote:
  38. in_quote = None
  39. continue
  40. if ch in ('"', "'"):
  41. in_quote = ch
  42. continue
  43. if ch == "#" and (idx == 0 or value[idx - 1].isspace()):
  44. return value[:idx]
  45. return value
  46. def parse_simple_yaml(content: str) -> dict:
  47. """Parse simple YAML with nested dict support (no dependencies).
  48. Supports:
  49. - key: value (string)
  50. - key: (followed by list items)
  51. - item1
  52. - item2
  53. - key: (followed by nested dict)
  54. nested_key: value
  55. nested_key2:
  56. - item
  57. Uses indentation to detect nesting (2+ spaces deeper = child).
  58. Args:
  59. content: YAML content string.
  60. Returns:
  61. Parsed dict (values can be str, list[str], or dict).
  62. """
  63. lines = content.splitlines()
  64. result: dict = {}
  65. _parse_yaml_block(lines, 0, 0, result)
  66. return result
  67. def _parse_yaml_block(
  68. lines: list[str], start: int, min_indent: int, target: dict
  69. ) -> int:
  70. """Parse a YAML block into target dict, returning next line index."""
  71. i = start
  72. current_list: list | None = None
  73. while i < len(lines):
  74. line = lines[i]
  75. stripped = line.strip()
  76. # Skip empty lines and comments
  77. if not stripped or stripped.startswith("#"):
  78. i += 1
  79. continue
  80. # Calculate indentation
  81. indent = len(line) - len(line.lstrip())
  82. # If dedented past our block, we're done
  83. if indent < min_indent:
  84. break
  85. if stripped.startswith("- "):
  86. if current_list is not None:
  87. current_list.append(_unquote(stripped[2:].strip()))
  88. i += 1
  89. elif ":" in stripped:
  90. key, _, value = stripped.partition(":")
  91. key = key.strip()
  92. value = _strip_inline_comment(value).strip()
  93. value = _unquote(value)
  94. current_list = None
  95. if value:
  96. # key: value
  97. target[key] = value
  98. i += 1
  99. else:
  100. # key: (no value) — peek ahead to determine list vs nested dict
  101. next_i, next_line = _next_content_line(lines, i + 1)
  102. if next_i >= len(lines):
  103. target[key] = {}
  104. i = next_i
  105. elif next_line.strip().startswith("- "):
  106. # It's a list
  107. current_list = []
  108. target[key] = current_list
  109. i += 1
  110. else:
  111. next_indent = len(next_line) - len(next_line.lstrip())
  112. if next_indent > indent:
  113. # It's a nested dict
  114. nested: dict = {}
  115. target[key] = nested
  116. i = _parse_yaml_block(lines, i + 1, next_indent, nested)
  117. else:
  118. # Empty value, same or less indent follows
  119. target[key] = {}
  120. i += 1
  121. else:
  122. i += 1
  123. return i
  124. def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
  125. """Find the next non-empty, non-comment line."""
  126. i = start
  127. while i < len(lines):
  128. stripped = lines[i].strip()
  129. if stripped and not stripped.startswith("#"):
  130. return i, lines[i]
  131. i += 1
  132. return i, ""
  133. # Defaults
  134. DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal"
  135. DEFAULT_MAX_JOURNAL_LINES = 2000
  136. DEFAULT_SESSION_AUTO_COMMIT = True
  137. CONFIG_FILE = "config.yaml"
  138. def _is_true_config_value(value: object) -> bool:
  139. """Return True when a config value represents an enabled flag."""
  140. if isinstance(value, bool):
  141. return value
  142. if isinstance(value, str):
  143. return value.strip().lower() == "true"
  144. return False
  145. def _get_config_path(repo_root: Path | None = None) -> Path:
  146. """Get path to config.yaml."""
  147. root = repo_root or get_repo_root()
  148. return root / DIR_WORKFLOW / CONFIG_FILE
  149. def _load_config(repo_root: Path | None = None) -> dict:
  150. """Load and parse config.yaml. Returns empty dict on any error."""
  151. config_file = _get_config_path(repo_root)
  152. try:
  153. content = config_file.read_text(encoding="utf-8")
  154. return parse_simple_yaml(content)
  155. except (OSError, IOError):
  156. return {}
  157. def get_session_commit_message(repo_root: Path | None = None) -> str:
  158. """Get the commit message for auto-committing session records."""
  159. config = _load_config(repo_root)
  160. return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE)
  161. def get_max_journal_lines(repo_root: Path | None = None) -> int:
  162. """Get the maximum lines per journal file."""
  163. config = _load_config(repo_root)
  164. value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES)
  165. try:
  166. return int(value)
  167. except (ValueError, TypeError):
  168. return DEFAULT_MAX_JOURNAL_LINES
  169. def get_session_auto_commit(repo_root: Path | None = None) -> bool:
  170. """Whether scripts should auto-stage + auto-commit session/task changes.
  171. Governs both ``add_session.py:_auto_commit_workspace`` and
  172. ``task_store.py:_auto_commit_archive``.
  173. Default: ``True`` (existing behavior — auto-stage + auto-commit).
  174. Set ``session_auto_commit: false`` in ``.trellis/config.yaml`` to skip
  175. auto-staging entirely; the journal/archive files are still written to
  176. disk, but the user manages ``git add`` / ``git commit`` themselves.
  177. Accepts native YAML booleans (``true`` / ``false``) and the string
  178. aliases ``true / false / yes / no / 1 / 0 / on / off`` (case-insensitive).
  179. Invalid values fall back to ``True`` with a stderr warning.
  180. """
  181. config = _load_config(repo_root)
  182. raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT)
  183. if isinstance(raw, bool):
  184. return raw
  185. s = str(raw).strip().lower()
  186. if s in ("true", "yes", "1", "on"):
  187. return True
  188. if s in ("false", "no", "0", "off"):
  189. return False
  190. print(
  191. f"[WARN] invalid session_auto_commit value: {raw!r}; using true (default)",
  192. file=sys.stderr,
  193. )
  194. return DEFAULT_SESSION_AUTO_COMMIT
  195. def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
  196. """Get hook commands for a lifecycle event.
  197. Args:
  198. event: Event name (e.g. "after_create", "after_archive").
  199. repo_root: Repository root path.
  200. Returns:
  201. List of shell commands to execute, empty if none configured.
  202. """
  203. config = _load_config(repo_root)
  204. hooks = config.get("hooks")
  205. if not isinstance(hooks, dict):
  206. return []
  207. commands = hooks.get(event)
  208. if isinstance(commands, list):
  209. return [str(c) for c in commands]
  210. return []
  211. # =============================================================================
  212. # Monorepo / Packages
  213. # =============================================================================
  214. def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None:
  215. """Get monorepo package declarations.
  216. Returns:
  217. Dict mapping package name to its config (path, type, etc.),
  218. or None if not configured (single-repo mode).
  219. Example return:
  220. {"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}}
  221. """
  222. config = _load_config(repo_root)
  223. packages = config.get("packages")
  224. if not isinstance(packages, dict):
  225. return None
  226. # Ensure each value is a dict (filter out scalar entries)
  227. filtered = {k: v for k, v in packages.items() if isinstance(v, dict)}
  228. if not filtered:
  229. return None
  230. return filtered
  231. def get_default_package(repo_root: Path | None = None) -> str | None:
  232. """Get the default package name from config.
  233. Returns:
  234. Package name string, or None if not configured.
  235. """
  236. config = _load_config(repo_root)
  237. value = config.get("default_package")
  238. return str(value) if value else None
  239. def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]:
  240. """Get packages that are git submodules.
  241. Returns:
  242. Dict mapping package name to its path for submodule-type packages.
  243. Empty dict if none configured.
  244. Example return:
  245. {"docs-site": "docs-site"}
  246. """
  247. packages = get_packages(repo_root)
  248. if packages is None:
  249. return {}
  250. return {
  251. name: cfg.get("path", name)
  252. for name, cfg in packages.items()
  253. if cfg.get("type") == "submodule"
  254. }
  255. def get_git_packages(repo_root: Path | None = None) -> dict[str, str]:
  256. """Get packages that have their own independent git repository.
  257. These are sub-directories with their own .git (not submodules),
  258. marked with ``git: true`` in config.yaml.
  259. Returns:
  260. Dict mapping package name to its path for git-repo packages.
  261. Empty dict if none configured.
  262. Example config::
  263. packages:
  264. backend:
  265. path: iqs
  266. git: true
  267. Example return::
  268. {"backend": "iqs"}
  269. """
  270. packages = get_packages(repo_root)
  271. if packages is None:
  272. return {}
  273. return {
  274. name: cfg.get("path", name)
  275. for name, cfg in packages.items()
  276. if _is_true_config_value(cfg.get("git"))
  277. }
  278. def is_monorepo(repo_root: Path | None = None) -> bool:
  279. """Check if the project is configured as a monorepo (has packages in config)."""
  280. return get_packages(repo_root) is not None
  281. def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str:
  282. """Get the spec directory base path relative to .trellis/.
  283. Single-repo: returns "spec"
  284. Monorepo with package: returns "spec/<package>"
  285. Monorepo without package: returns "spec" (caller should specify package)
  286. """
  287. if package and is_monorepo(repo_root):
  288. return f"spec/{package}"
  289. return "spec"
  290. def validate_package(package: str, repo_root: Path | None = None) -> bool:
  291. """Check if a package name is valid in this project.
  292. Single-repo (no packages configured): always returns True.
  293. Monorepo: returns True only if package exists in config.yaml packages.
  294. """
  295. packages = get_packages(repo_root)
  296. if packages is None:
  297. return True # Single-repo, no validation needed
  298. return package in packages
  299. def resolve_package(
  300. task_package: str | None = None,
  301. repo_root: Path | None = None,
  302. ) -> str | None:
  303. """Resolve package from inferred sources with validation.
  304. Checks in order: task_package → default_package.
  305. Invalid inferred values print a warning to stderr and are skipped.
  306. Returns:
  307. Resolved package name, or None if no valid package found.
  308. Note:
  309. CLI --package should be validated separately by the caller
  310. (fail-fast with available packages list on error).
  311. """
  312. packages = get_packages(repo_root)
  313. if packages is None:
  314. return None # Single-repo, no package needed
  315. # Try task_package (guard against non-string values from malformed JSON)
  316. if task_package and isinstance(task_package, str):
  317. if task_package in packages:
  318. return task_package
  319. print(
  320. f"Warning: task.json package '{task_package}' not found in config, skipping",
  321. file=sys.stderr,
  322. )
  323. # Try default_package
  324. default = get_default_package(repo_root)
  325. if default:
  326. if default in packages:
  327. return default
  328. print(
  329. f"Warning: default_package '{default}' not found in config, skipping",
  330. file=sys.stderr,
  331. )
  332. return None
  333. def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None:
  334. """Get session.spec_scope configuration.
  335. Returns:
  336. list[str]: Package names to include in spec scanning.
  337. str: "active_task" to use current task's package.
  338. None: No scope configured (scan all packages).
  339. """
  340. config = _load_config(repo_root)
  341. session = config.get("session")
  342. if not isinstance(session, dict):
  343. return None
  344. scope = session.get("spec_scope")
  345. if scope is None:
  346. return None
  347. if isinstance(scope, str):
  348. return scope # e.g. "active_task"
  349. if isinstance(scope, list):
  350. return [str(s) for s in scope]
  351. return None