trellis_config.py 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. #!/usr/bin/env python3
  2. """
  3. Standalone reader for .trellis/config.yaml.
  4. Mirrors a minimal subset of common.config so callers (hooks, workflow_phase)
  5. can read configuration without importing the full task/repo helpers. Returns
  6. an empty dict on missing/malformed files so callers stay simple.
  7. """
  8. from __future__ import annotations
  9. from pathlib import Path
  10. from typing import Optional
  11. CONFIG_REL_PATH = ".trellis/config.yaml"
  12. def _unquote(value: str) -> str:
  13. if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
  14. return value[1:-1]
  15. return value
  16. def _strip_inline_comment(value: str) -> str:
  17. """Strip ` # …` inline comments while preserving `#` inside quoted strings.
  18. YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token
  19. is part of the value. Quoted strings are immune.
  20. """
  21. in_quote: str | None = None
  22. for idx, ch in enumerate(value):
  23. if in_quote:
  24. if ch == in_quote:
  25. in_quote = None
  26. continue
  27. if ch in ('"', "'"):
  28. in_quote = ch
  29. continue
  30. if ch == "#" and (idx == 0 or value[idx - 1].isspace()):
  31. return value[:idx]
  32. return value
  33. def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
  34. i = start
  35. while i < len(lines):
  36. stripped = lines[i].strip()
  37. if stripped and not stripped.startswith("#"):
  38. return i, lines[i]
  39. i += 1
  40. return i, ""
  41. def _parse_yaml_block(
  42. lines: list[str], start: int, min_indent: int, target: dict
  43. ) -> int:
  44. i = start
  45. current_list: list | None = None
  46. while i < len(lines):
  47. line = lines[i]
  48. stripped = line.strip()
  49. if not stripped or stripped.startswith("#"):
  50. i += 1
  51. continue
  52. indent = len(line) - len(line.lstrip())
  53. if indent < min_indent:
  54. break
  55. if stripped.startswith("- "):
  56. if current_list is not None:
  57. current_list.append(_unquote(stripped[2:].strip()))
  58. i += 1
  59. elif ":" in stripped:
  60. key, _, value = stripped.partition(":")
  61. key = key.strip()
  62. value = _strip_inline_comment(value).strip()
  63. value = _unquote(value)
  64. current_list = None
  65. if value:
  66. target[key] = value
  67. i += 1
  68. else:
  69. next_i, next_line = _next_content_line(lines, i + 1)
  70. if next_i >= len(lines):
  71. target[key] = {}
  72. i = next_i
  73. elif next_line.strip().startswith("- "):
  74. current_list = []
  75. target[key] = current_list
  76. i += 1
  77. else:
  78. next_indent = len(next_line) - len(next_line.lstrip())
  79. if next_indent > indent:
  80. nested: dict = {}
  81. target[key] = nested
  82. i = _parse_yaml_block(lines, i + 1, next_indent, nested)
  83. else:
  84. target[key] = {}
  85. i += 1
  86. else:
  87. i += 1
  88. return i
  89. def parse_simple_yaml(content: str) -> dict:
  90. """Parse a small subset of YAML. See common.config for full doc."""
  91. lines = content.splitlines()
  92. result: dict = {}
  93. _parse_yaml_block(lines, 0, 0, result)
  94. return result
  95. def read_trellis_config(repo_root: Optional[Path] = None) -> dict:
  96. """Read .trellis/config.yaml. Returns {} on missing or malformed file."""
  97. root = repo_root or Path.cwd()
  98. config_file = root / CONFIG_REL_PATH
  99. try:
  100. content = config_file.read_text(encoding="utf-8")
  101. except (FileNotFoundError, OSError):
  102. return {}
  103. try:
  104. parsed = parse_simple_yaml(content)
  105. except Exception:
  106. return {}
  107. return parsed if isinstance(parsed, dict) else {}