Coverage for little_loops / frontmatter.py: 100%
69 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -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 strip_frontmatter(content: str) -> str:
100 """Remove YAML frontmatter from content, returning the body.
102 Strips the ``---`` delimited frontmatter block (if present) and
103 returns everything after the closing delimiter.
105 Args:
106 content: File content possibly starting with frontmatter
108 Returns:
109 Content with frontmatter removed. Returns original content
110 unchanged if no valid frontmatter block is found.
111 """
112 if not content or not content.startswith("---"):
113 return content
115 end_match = re.search(r"\n---\s*\n", content[3:])
116 if not end_match:
117 return content
119 return content[3 + end_match.end() :]
122def update_frontmatter(content: str, updates: dict[str, Any]) -> str:
123 """Update or add frontmatter fields in content.
125 Merges ``updates`` into an existing ``---`` delimited YAML frontmatter
126 block, preserving other fields and their order. If no frontmatter block
127 exists, a new one is prepended. Existing keys are overwritten with the
128 new values.
130 Args:
131 content: Full file content, possibly with existing frontmatter
132 updates: Fields to add/update in frontmatter; values may be nested dicts
134 Returns:
135 Content with updated frontmatter block
136 """
137 fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
138 if not fm_match:
139 fm_text = yaml.dump(dict(updates), default_flow_style=False, sort_keys=False).strip()
140 return f"---\n{fm_text}\n---\n{content}"
142 existing: dict[str, Any] = yaml.safe_load(fm_match.group(1)) or {}
143 existing.update(updates)
144 fm_text = yaml.dump(existing, default_flow_style=False, sort_keys=False).strip()
145 return f"---\n{fm_text}\n---{content[fm_match.end() :]}"