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

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 

17 

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

19 """Extract YAML frontmatter from content. 

20 

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. 

26 

27 Args: 

28 content: File content to parse 

29 coerce_types: If True, coerce digit strings to int 

30 

31 Returns: 

32 Dictionary of frontmatter fields, or empty dict 

33 """ 

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

35 return {} 

36 

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

38 if not end_match: 

39 return {} 

40 

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

42 

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 

85 

86 

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

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

89 

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

91 returns everything after the closing delimiter. 

92 

93 Args: 

94 content: File content possibly starting with frontmatter 

95 

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 

102 

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

104 if not end_match: 

105 return content 

106 

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

108 

109 

110def update_frontmatter(content: str, updates: dict[str, str | int]) -> str: 

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

112 

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. 

117 

118 Args: 

119 content: Full file content, possibly with existing frontmatter 

120 updates: Fields to add/update in frontmatter 

121 

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

129 

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