Coverage for little_loops / frontmatter.py: 14%
66 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-11 00:29 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-11 00:29 -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__)
18def parse_frontmatter(content: str, *, coerce_types: bool = False) -> dict[str, Any]:
19 """Extract YAML frontmatter from content.
21 Looks for content between opening and closing '---' markers.
22 Parses a subset of YAML: simple ``key: value`` pairs and YAML block
23 sequences (``key:`` followed by ``- item`` lines). Block scalars and
24 nested structures are not supported and will emit a ``logging.WARNING``.
25 Returns empty dict if no frontmatter found.
27 Args:
28 content: File content to parse
29 coerce_types: If True, coerce digit strings to int
31 Returns:
32 Dictionary of frontmatter fields, or empty dict
33 """
34 if not content or not content.startswith("---"):
35 return {}
37 end_match = re.search(r"\n---\s*\n", content[3:])
38 if not end_match:
39 return {}
41 frontmatter_text = content[4 : 3 + end_match.start()]
43 result: dict[str, Any] = {}
44 current_list_key: str | None = None
45 for line in frontmatter_text.split("\n"):
46 line = line.strip()
47 if not line or line.startswith("#"):
48 continue
49 if line.startswith("- "):
50 if current_list_key is not None:
51 result[current_list_key].append(line[2:].strip())
52 else:
53 logger.warning("Unsupported YAML list syntax in frontmatter: %r", line)
54 continue
55 # Non-list line: finalize any in-progress empty list, then reset
56 if current_list_key is not None and result[current_list_key] == []:
57 result[current_list_key] = None
58 current_list_key = None
59 if ":" in line:
60 key, value = line.split(":", 1)
61 key = key.strip()
62 value = value.strip()
63 if value.startswith("|") or value.startswith(">"):
64 logger.warning("Unsupported YAML block scalar in frontmatter: %r", line)
65 result[key] = None
66 continue
67 if value.startswith("[") and value.endswith("]"):
68 inner = value[1:-1].strip()
69 result[key] = [item.strip() for item in inner.split(",")] if inner else []
70 continue
71 if value.lower() in ("null", "~", ""):
72 if value == "":
73 result[key] = []
74 current_list_key = key
75 else:
76 result[key] = None
77 elif coerce_types and value.isdigit():
78 result[key] = int(value)
79 else:
80 result[key] = value
81 # Finalize any trailing empty list key
82 if current_list_key is not None and result[current_list_key] == []:
83 result[current_list_key] = None
84 return result
87def strip_frontmatter(content: str) -> str:
88 """Remove YAML frontmatter from content, returning the body.
90 Strips the ``---`` delimited frontmatter block (if present) and
91 returns everything after the closing delimiter.
93 Args:
94 content: File content possibly starting with frontmatter
96 Returns:
97 Content with frontmatter removed. Returns original content
98 unchanged if no valid frontmatter block is found.
99 """
100 if not content or not content.startswith("---"):
101 return content
103 end_match = re.search(r"\n---\s*\n", content[3:])
104 if not end_match:
105 return content
107 return content[3 + end_match.end() :]
110def update_frontmatter(content: str, updates: dict[str, str | int]) -> str:
111 """Update or add frontmatter fields in content.
113 Merges ``updates`` into an existing ``---`` delimited YAML frontmatter
114 block, preserving other fields and their order. If no frontmatter block
115 exists, a new one is prepended. Existing keys are overwritten with the
116 new values.
118 Args:
119 content: Full file content, possibly with existing frontmatter
120 updates: Fields to add/update in frontmatter
122 Returns:
123 Content with updated frontmatter block
124 """
125 fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
126 if not fm_match:
127 fm_text = yaml.dump(dict(updates), default_flow_style=False, sort_keys=False).strip()
128 return f"---\n{fm_text}\n---\n{content}"
130 existing: dict[str, Any] = yaml.safe_load(fm_match.group(1)) or {}
131 existing.update(updates)
132 fm_text = yaml.dump(existing, default_flow_style=False, sort_keys=False).strip()
133 return f"---\n{fm_text}\n---{content[fm_match.end() :]}"