Coverage for little_loops / frontmatter.py: 11%
96 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -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
11from typing import Any
13import yaml
15logger = logging.getLogger(__name__)
17STATUS_SYNONYMS: dict[str, str] = {
18 "complete": "done",
19 "completed": "done",
20 "finished": "done",
21 "closed": "done",
22 "in-progress": "in_progress",
23 "in progress": "in_progress",
24 "wip": "in_progress",
25}
28def parse_frontmatter(content: str, *, coerce_types: bool = False) -> dict[str, Any]:
29 """Extract YAML frontmatter from content.
31 Looks for content between opening and closing '---' markers.
32 Parses a subset of YAML: simple ``key: value`` pairs and YAML block
33 sequences (``key:`` followed by ``- item`` lines). Block scalars and
34 nested structures are not supported and will emit a ``logging.WARNING``.
35 Returns empty dict if no frontmatter found.
37 Args:
38 content: File content to parse
39 coerce_types: If True, coerce digit strings to int
41 Returns:
42 Dictionary of frontmatter fields, or empty dict
43 """
44 if not content or not content.startswith("---"):
45 return {}
47 end_match = re.search(r"\n---\s*\n", content[3:])
48 if not end_match:
49 return {}
51 frontmatter_text = content[4 : 3 + end_match.start()]
53 result: dict[str, Any] = {}
54 current_list_key: str | None = None
55 for line in frontmatter_text.split("\n"):
56 line = line.strip()
57 if not line or line.startswith("#"):
58 continue
59 if line.startswith("- "):
60 if current_list_key is not None:
61 result[current_list_key].append(line[2:].strip())
62 else:
63 logger.warning("Unsupported YAML list syntax in frontmatter: %r", line)
64 continue
65 # Non-list line: finalize any in-progress empty list, then reset
66 if current_list_key is not None and result[current_list_key] == []:
67 result[current_list_key] = None
68 current_list_key = None
69 if ":" in line:
70 key, value = line.split(":", 1)
71 key = key.strip()
72 value = value.strip()
73 if value.startswith("|") or value.startswith(">"):
74 logger.warning("Unsupported YAML block scalar in frontmatter: %r", line)
75 result[key] = None
76 continue
77 if value.startswith("[") and value.endswith("]"):
78 inner = value[1:-1].strip()
79 result[key] = [item.strip() for item in inner.split(",")] if inner else []
80 continue
81 if value.lower() in ("null", "~", ""):
82 if value == "":
83 result[key] = []
84 current_list_key = key
85 else:
86 result[key] = None
87 elif coerce_types and value.isdigit():
88 result[key] = int(value)
89 else:
90 result[key] = value
91 # Finalize any trailing empty list key
92 if current_list_key is not None and result[current_list_key] == []:
93 result[current_list_key] = None
94 if "status" in result and isinstance(result["status"], str):
95 result["status"] = STATUS_SYNONYMS.get(result["status"], result["status"])
96 return result
99def parse_skill_frontmatter(text: str) -> dict[str, str]:
100 """Extract flat key/value pairs from SKILL.md frontmatter.
102 Uses ``yaml.safe_load`` so YAML block scalars (e.g. ``description: |``)
103 are resolved to their string content instead of the indicator literal.
104 Non-string scalar values are stringified; nested structures are dropped.
106 If the frontmatter is not valid YAML (e.g. unquoted colons in values),
107 falls back to a permissive line-based scan — top-level ``key: value``
108 pairs only, block scalars are not resolved in that path.
110 This is the canonical SKILL.md frontmatter parser. Prefer it over the
111 general ``parse_frontmatter`` for SKILL.md files: ``parse_frontmatter``
112 deliberately drops block scalars (logs a warning, sets value to
113 ``None``) which loses the description body for skills that use
114 ``description: |``.
115 """
116 if not text.startswith("---"):
117 return {}
118 end = text.find("---", 3)
119 if end == -1:
120 return {}
121 fm_text = text[3:end]
122 try:
123 loaded = yaml.safe_load(fm_text)
124 except yaml.YAMLError:
125 loaded = None
126 if isinstance(loaded, dict):
127 fm: dict[str, str] = {}
128 for key, value in loaded.items():
129 if value is None:
130 fm[str(key)] = ""
131 elif isinstance(value, str):
132 fm[str(key)] = value
133 elif isinstance(value, bool | int | float):
134 fm[str(key)] = str(value).lower() if isinstance(value, bool) else str(value)
135 return fm
136 fm = {}
137 for line in fm_text.splitlines():
138 if line and not line.startswith(" ") and not line.startswith("\t") and ":" in line:
139 key, _, val = line.partition(":")
140 fm[key.strip()] = val.strip()
141 return fm
144def strip_frontmatter(content: str) -> str:
145 """Remove YAML frontmatter from content, returning the body.
147 Strips the ``---`` delimited frontmatter block (if present) and
148 returns everything after the closing delimiter.
150 Args:
151 content: File content possibly starting with frontmatter
153 Returns:
154 Content with frontmatter removed. Returns original content
155 unchanged if no valid frontmatter block is found.
156 """
157 if not content or not content.startswith("---"):
158 return content
160 end_match = re.search(r"\n---\s*\n", content[3:])
161 if not end_match:
162 return content
164 return content[3 + end_match.end() :]
167def update_frontmatter(content: str, updates: dict[str, Any]) -> str:
168 """Update or add frontmatter fields in content.
170 Merges ``updates`` into an existing ``---`` delimited YAML frontmatter
171 block, preserving other fields and their order. If no frontmatter block
172 exists, a new one is prepended. Existing keys are overwritten with the
173 new values.
175 Args:
176 content: Full file content, possibly with existing frontmatter
177 updates: Fields to add/update in frontmatter; values may be nested dicts
179 Returns:
180 Content with updated frontmatter block
181 """
182 fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
183 if not fm_match:
184 fm_text = yaml.dump(dict(updates), default_flow_style=False, sort_keys=False).strip()
185 return f"---\n{fm_text}\n---\n{content}"
187 existing: dict[str, Any] = yaml.safe_load(fm_match.group(1)) or {}
188 existing.update(updates)
189 fm_text = yaml.dump(existing, default_flow_style=False, sort_keys=False).strip()
190 return f"---\n{fm_text}\n---{content[fm_match.end() :]}"