Coverage for little_loops / frontmatter.py: 11%

114 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:54 -0500

1"""Frontmatter read/write utilities for little-loops. 

2 

3Provides shared YAML-subset frontmatter parsing, stripping, and updating 

4used by issue_parser, sync, and issue_history modules. 

5""" 

6 

7from __future__ import annotations 

8 

9import logging 

10import re 

11import textwrap 

12from typing import Any 

13 

14import yaml 

15 

16logger = logging.getLogger(__name__) 

17 

18STATUS_SYNONYMS: dict[str, str] = { 

19 "complete": "done", 

20 "completed": "done", 

21 "finished": "done", 

22 "closed": "done", 

23 "in-progress": "in_progress", 

24 "in progress": "in_progress", 

25 "wip": "in_progress", 

26 "pending": "open", 

27} 

28 

29 

30def parse_frontmatter(content: str, *, coerce_types: bool = False) -> dict[str, Any]: 

31 """Extract YAML frontmatter from content. 

32 

33 Looks for content between opening and closing '---' markers. 

34 Parses a subset of YAML: simple ``key: value`` pairs, YAML block 

35 sequences (``key:`` followed by ``- item`` lines), and block scalars 

36 (``key: |`` or ``key: >`` followed by indented lines). Nested 

37 structures are not supported and will emit a ``logging.WARNING``. 

38 Returns empty dict if no frontmatter found. 

39 

40 Args: 

41 content: File content to parse 

42 coerce_types: If True, coerce digit strings to int 

43 

44 Returns: 

45 Dictionary of frontmatter fields, or empty dict 

46 """ 

47 if not content or not content.startswith("---"): 

48 return {} 

49 

50 end_match = re.search(r"\n---\s*\n", content[3:]) 

51 if not end_match: 

52 return {} 

53 

54 frontmatter_text = content[4 : 3 + end_match.start()] 

55 

56 result: dict[str, Any] = {} 

57 current_list_key: str | None = None 

58 lines = frontmatter_text.split("\n") 

59 i = 0 

60 while i < len(lines): 

61 line = lines[i].strip() 

62 i += 1 

63 if not line or line.startswith("#"): 

64 continue 

65 if line.startswith("- "): 

66 if current_list_key is not None: 

67 result[current_list_key].append(line[2:].strip()) 

68 else: 

69 logger.warning("Unsupported YAML list syntax in frontmatter: %r", line) 

70 continue 

71 # Non-list line: finalize any in-progress empty list, then reset 

72 if current_list_key is not None and result[current_list_key] == []: 

73 result[current_list_key] = None 

74 current_list_key = None 

75 if ":" in line: 

76 key, value = line.split(":", 1) 

77 key = key.strip() 

78 value = value.strip() 

79 if value.startswith("|") or value.startswith(">"): 

80 # Block scalar: collect indented continuation lines 

81 block_type = value[0] 

82 block_lines: list[str] = [] 

83 while i < len(lines): 

84 next_line = lines[i] 

85 if next_line and (next_line[0] == " " or next_line[0] == "\t"): 

86 block_lines.append(next_line) 

87 i += 1 

88 else: 

89 break 

90 if block_lines: 

91 dedented = textwrap.dedent("\n".join(block_lines)) 

92 if block_type == ">": 

93 dedented = re.sub(r"\s+", " ", dedented).strip() 

94 result[key] = dedented 

95 else: 

96 result[key] = "" 

97 continue 

98 if value.startswith("[") and value.endswith("]"): 

99 inner = value[1:-1].strip() 

100 result[key] = [item.strip() for item in inner.split(",")] if inner else [] 

101 continue 

102 if value.lower() in ("null", "~", ""): 

103 if value == "": 

104 result[key] = [] 

105 current_list_key = key 

106 else: 

107 result[key] = None 

108 elif coerce_types and value.isdigit(): 

109 result[key] = int(value) 

110 else: 

111 # Strip surrounding YAML string quotes (single or double) 

112 if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): 

113 value = value[1:-1] 

114 result[key] = value 

115 # Finalize any trailing empty list key 

116 if current_list_key is not None and result[current_list_key] == []: 

117 result[current_list_key] = None 

118 if "status" in result and isinstance(result["status"], str): 

119 result["status"] = STATUS_SYNONYMS.get(result["status"], result["status"]) 

120 return result 

121 

122 

123def parse_skill_frontmatter(text: str) -> dict[str, str]: 

124 """Extract flat key/value pairs from SKILL.md frontmatter. 

125 

126 Uses ``yaml.safe_load`` so YAML block scalars (e.g. ``description: |``) 

127 are resolved to their string content instead of the indicator literal. 

128 Non-string scalar values are stringified; nested structures are dropped. 

129 

130 If the frontmatter is not valid YAML (e.g. unquoted colons in values), 

131 falls back to a permissive line-based scan — top-level ``key: value`` 

132 pairs only, block scalars are not resolved in that path. 

133 

134 This is the canonical SKILL.md frontmatter parser. Prefer it over the 

135 general ``parse_frontmatter`` for SKILL.md files: ``parse_frontmatter`` 

136 deliberately drops block scalars (logs a warning, sets value to 

137 ``None``) which loses the description body for skills that use 

138 ``description: |``. 

139 """ 

140 if not text.startswith("---"): 

141 return {} 

142 end = text.find("---", 3) 

143 if end == -1: 

144 return {} 

145 fm_text = text[3:end] 

146 try: 

147 loaded = yaml.safe_load(fm_text) 

148 except yaml.YAMLError: 

149 loaded = None 

150 if isinstance(loaded, dict): 

151 fm: dict[str, str] = {} 

152 for key, value in loaded.items(): 

153 if value is None: 

154 fm[str(key)] = "" 

155 elif isinstance(value, str): 

156 fm[str(key)] = value 

157 elif isinstance(value, bool | int | float): 

158 fm[str(key)] = str(value).lower() if isinstance(value, bool) else str(value) 

159 return fm 

160 fm = {} 

161 for line in fm_text.splitlines(): 

162 if line and not line.startswith(" ") and not line.startswith("\t") and ":" in line: 

163 key, _, val = line.partition(":") 

164 fm[key.strip()] = val.strip() 

165 return fm 

166 

167 

168def strip_frontmatter(content: str) -> str: 

169 """Remove YAML frontmatter from content, returning the body. 

170 

171 Strips the ``---`` delimited frontmatter block (if present) and 

172 returns everything after the closing delimiter. 

173 

174 Args: 

175 content: File content possibly starting with frontmatter 

176 

177 Returns: 

178 Content with frontmatter removed. Returns original content 

179 unchanged if no valid frontmatter block is found. 

180 """ 

181 if not content or not content.startswith("---"): 

182 return content 

183 

184 end_match = re.search(r"\n---\s*\n", content[3:]) 

185 if not end_match: 

186 return content 

187 

188 return content[3 + end_match.end() :] 

189 

190 

191def update_frontmatter(content: str, updates: dict[str, Any]) -> str: 

192 """Update or add frontmatter fields in content. 

193 

194 Merges ``updates`` into an existing ``---`` delimited YAML frontmatter 

195 block, preserving other fields and their order. If no frontmatter block 

196 exists, a new one is prepended. Existing keys are overwritten with the 

197 new values. 

198 

199 Args: 

200 content: Full file content, possibly with existing frontmatter 

201 updates: Fields to add/update in frontmatter; values may be nested dicts 

202 

203 Returns: 

204 Content with updated frontmatter block 

205 """ 

206 fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) 

207 if not fm_match: 

208 fm_text = yaml.dump(dict(updates), default_flow_style=False, sort_keys=False).strip() 

209 return f"---\n{fm_text}\n---\n{content}" 

210 

211 existing: dict[str, Any] = yaml.safe_load(fm_match.group(1)) or {} 

212 existing.update(updates) 

213 fm_text = yaml.dump(existing, default_flow_style=False, sort_keys=False).strip() 

214 return f"---\n{fm_text}\n---{content[fm_match.end() :]}"