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
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
1"""Frontmatter read/write utilities for little-loops.
3Provides shared YAML-subset frontmatter parsing, stripping, and updating
4used by issue_parser, sync, and issue_history modules.
5"""
7from __future__ import annotations
9import logging
10import re
11import textwrap
12from typing import Any
14import yaml
16logger = logging.getLogger(__name__)
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}
30def parse_frontmatter(content: str, *, coerce_types: bool = False) -> dict[str, Any]:
31 """Extract YAML frontmatter from content.
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.
40 Args:
41 content: File content to parse
42 coerce_types: If True, coerce digit strings to int
44 Returns:
45 Dictionary of frontmatter fields, or empty dict
46 """
47 if not content or not content.startswith("---"):
48 return {}
50 end_match = re.search(r"\n---\s*\n", content[3:])
51 if not end_match:
52 return {}
54 frontmatter_text = content[4 : 3 + end_match.start()]
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
123def parse_skill_frontmatter(text: str) -> dict[str, str]:
124 """Extract flat key/value pairs from SKILL.md frontmatter.
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.
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.
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
168def strip_frontmatter(content: str) -> str:
169 """Remove YAML frontmatter from content, returning the body.
171 Strips the ``---`` delimited frontmatter block (if present) and
172 returns everything after the closing delimiter.
174 Args:
175 content: File content possibly starting with frontmatter
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
184 end_match = re.search(r"\n---\s*\n", content[3:])
185 if not end_match:
186 return content
188 return content[3 + end_match.end() :]
191def update_frontmatter(content: str, updates: dict[str, Any]) -> str:
192 """Update or add frontmatter fields in content.
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.
199 Args:
200 content: Full file content, possibly with existing frontmatter
201 updates: Fields to add/update in frontmatter; values may be nested dicts
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}"
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() :]}"