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

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 

11from typing import Any 

12 

13import yaml 

14 

15logger = logging.getLogger(__name__) 

16 

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} 

26 

27 

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

29 """Extract YAML frontmatter from content. 

30 

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. 

36 

37 Args: 

38 content: File content to parse 

39 coerce_types: If True, coerce digit strings to int 

40 

41 Returns: 

42 Dictionary of frontmatter fields, or empty dict 

43 """ 

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

45 return {} 

46 

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

48 if not end_match: 

49 return {} 

50 

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

52 

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 

97 

98 

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

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

101 

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

103 returns everything after the closing delimiter. 

104 

105 Args: 

106 content: File content possibly starting with frontmatter 

107 

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 

114 

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

116 if not end_match: 

117 return content 

118 

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

120 

121 

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

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

124 

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. 

129 

130 Args: 

131 content: Full file content, possibly with existing frontmatter 

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

133 

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}" 

141 

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() :]}"