Coverage for plugin/scripts/_rhiza_yaml_parse.py: 100%

140 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +0000

1#!/usr/bin/env python3 

2"""The hand-rolled YAML subset parser — `load_yaml`'s fallback when PyYAML is absent. 

3 

4Split from `_rhiza_yaml` because the two answer different questions: that module owns the 

5*public* read/write API and the emitter, while everything here exists only to read a file 

6with no third-party parser available. Keeping them together meant one module was 

7simultaneously a reader, a writer, and a parser. 

8 

9**This is the reference implementation, not the fallback's poor cousin.** PyYAML applies 

10YAML **1.1** implicit resolution; this applies something close to YAML 1.2 core, and where 

11they disagreed it is PyYAML that was normalised down to match — see `_rhiza_yaml`'s 

12`_build_loader`. So the coercion rules here (`scalar` and friends) define what a rhiza 

13config *means*: scalars are strings unless they are plainly a bool, an int or null. 

14 

15The subset covered: nested mappings, block and inline (`[a, b]`) sequences, inline 

16(`{source: x, dest: y}`) mappings, block scalars (`key: |`), quoted/bare scalars, and `#` 

17comments. Deliberately **not** anchors, aliases, or multiple documents — none of which 

18appear in rhiza template files. 

19 

20Lenient by design. A malformed line is skipped rather than raised on, because the CLI 

21reader behaves that way and a damaged lock must degrade rather than crash. 

22""" 

23 

24from __future__ import annotations 

25 

26import re 

27from typing import Any 

28 

29_BLOCK_SCALAR_INDICATORS = {"|", ">", "|-", ">-", "|+", ">+"} 

30 

31 

32def parse_subset(text: str) -> dict[str, Any]: 

33 """Parse the nested scalar/list/mapping YAML subset rhiza files use.""" 

34 lines = text.splitlines() 

35 value, _ = _parse_map(lines, _next_content(lines, 0), 0) 

36 return value 

37 

38 

39def _strip_comment(value: str) -> str: 

40 """Drop a trailing ``# comment`` that sits outside any quotes.""" 

41 quote: str | None = None 

42 for i, ch in enumerate(value): 

43 if quote: 

44 if ch == quote: 

45 quote = None 

46 elif ch in ("'", '"'): 

47 quote = ch 

48 elif ch == "#" and (i == 0 or value[i - 1] in " \t"): 

49 return value[:i] 

50 return value 

51 

52 

53def _split_flow(inner: str) -> list[str]: 

54 """Split the body of an inline ``[a, b, c]`` list on top-level commas.""" 

55 items: list[str] = [] 

56 buf = "" 

57 quote: str | None = None 

58 for ch in inner: 

59 if quote: 

60 buf += ch 

61 if ch == quote: 

62 quote = None 

63 elif ch in ("'", '"'): 

64 quote = ch 

65 buf += ch 

66 elif ch == ",": 

67 items.append(buf) 

68 buf = "" 

69 else: 

70 buf += ch 

71 if buf.strip(): 

72 items.append(buf) 

73 return items 

74 

75 

76def _flow_map(inner: str) -> dict[str, Any]: 

77 """Parse the body of an inline ``{a: x, b: y}`` mapping into a dict.""" 

78 result: dict[str, Any] = {} 

79 for part in _split_flow(inner): 

80 key, sep, rest = part.partition(":") 

81 if sep: 

82 result[key.strip()] = scalar(rest.strip()) 

83 return result 

84 

85 

86def _is_quoted(s: str) -> bool: 

87 """Is *s* wrapped in a matching pair of quotes? *s* must be non-empty. 

88 

89 A lone quote character satisfies this — ``s[0]`` and ``s[-1]`` are then the same 

90 character — which is deliberate: it is malformed YAML either way, and 

91 :func:`_needs_quote` relies on the resulting round-trip mismatch to quote it. 

92 """ 

93 return (s[0] == '"' and s[-1] == '"') or (s[0] == "'" and s[-1] == "'") 

94 

95 

96def _flow_list(body: str) -> list[Any]: 

97 """Parse the already-stripped body of an inline ``[a, b, c]`` list.""" 

98 return [scalar(x) for x in _split_flow(body)] if body else [] 

99 

100 

101def _plain_scalar(s: str) -> Any: 

102 """Coerce an unquoted, non-flow token: null, bool, int, else the string itself.""" 

103 low = s.lower() 

104 if low in ("null", "~"): 

105 return None 

106 if low in ("true", "false"): 

107 return low == "true" 

108 try: 

109 return int(s) 

110 except ValueError: 

111 return s 

112 

113 

114def scalar(raw: str) -> Any: 

115 """Coerce a scalar token to str/int/bool/None/list/dict, honouring quotes. 

116 

117 Shape first — quoted, then the two flow collections — and only then the unquoted 

118 keyword/int/string coercions, which :func:`_plain_scalar` owns. The order matters: 

119 a quoted ``'true'`` is the string, not the boolean. 

120 """ 

121 s = raw.strip() 

122 if not s: 

123 return None 

124 if _is_quoted(s): 

125 return s[1:-1] 

126 if s.startswith("[") and s.endswith("]"): 

127 return _flow_list(s[1:-1].strip()) 

128 if s.startswith("{") and s.endswith("}"): 

129 return _flow_map(s[1:-1].strip()) 

130 return _plain_scalar(s) 

131 

132 

133def _indent_of(line: str) -> int: 

134 """Return the number of leading spaces on *line*.""" 

135 return len(line) - len(line.lstrip(" ")) 

136 

137 

138def _next_content(lines: list[str], i: int) -> int: 

139 """Return the index of the next non-blank, non-comment line at or after *i*.""" 

140 while i < len(lines): 

141 stripped = lines[i].strip() 

142 if stripped and not stripped.startswith("#"): 

143 return i 

144 i += 1 

145 return len(lines) 

146 

147 

148def _parse_map(lines: list[str], i: int, indent: int) -> tuple[dict[str, Any], int]: 

149 """Parse a block mapping whose keys sit at *indent*, returning it and the next index.""" 

150 data: dict[str, Any] = {} 

151 while True: 

152 i = _next_content(lines, i) 

153 if i >= len(lines) or _indent_of(lines[i]) < indent: 

154 break 

155 stripped = lines[i].strip() 

156 if stripped.startswith("- ") or stripped == "-": 

157 break # a sequence at this level is not part of a mapping 

158 if ":" not in stripped: 

159 i += 1 # tolerate a stray non-mapping line, as the CLI reader does 

160 continue 

161 key, _, rest = stripped.partition(":") 

162 key = key.strip() 

163 rest = _strip_comment(rest).strip() 

164 i += 1 

165 if rest in _BLOCK_SCALAR_INDICATORS: 

166 data[key], i = _parse_block_scalar(lines, i, indent) 

167 elif rest == "": 

168 data[key], i = _parse_child(lines, i, indent) 

169 else: 

170 data[key] = scalar(rest) 

171 return data, i 

172 

173 

174def _parse_child(lines: list[str], i: int, parent_indent: int) -> tuple[Any, int]: 

175 """Parse the value introduced by a bare ``key:`` line, or ``None`` when absent.""" 

176 j = _next_content(lines, i) 

177 if j >= len(lines): 

178 return None, i 

179 child_indent = _indent_of(lines[j]) 

180 child = lines[j].strip() 

181 is_seq = child.startswith("- ") or child == "-" 

182 # Block sequences may sit at the parent's indent (zero-indent style); block 

183 # mappings must be strictly deeper. 

184 if is_seq and child_indent >= parent_indent: 

185 return _parse_seq(lines, j, child_indent) 

186 if not is_seq and child_indent > parent_indent: 

187 return _parse_map(lines, j, child_indent) 

188 return None, i 

189 

190 

191def _parse_seq(lines: list[str], i: int, indent: int) -> tuple[list[Any], int]: 

192 """Parse a block sequence whose ``- `` items sit at *indent*.""" 

193 items: list[Any] = [] 

194 while True: 

195 i = _next_content(lines, i) 

196 if i >= len(lines) or _indent_of(lines[i]) < indent: 

197 break 

198 stripped = lines[i].strip() 

199 if not (stripped.startswith("- ") or stripped == "-"): 

200 break 

201 item = "" if stripped == "-" else stripped[2:] 

202 item = _strip_comment(item).strip() 

203 if item and item[0] not in "[{'\"" and re.match(r"[^:\s]+:(\s|$)", item): 

204 # A block mapping under this item: reparse from the "- " column. 

205 lines[i] = lines[i].replace("- ", " ", 1) 

206 value, i = _parse_map(lines, i, _indent_of(lines[i])) 

207 items.append(value) 

208 else: 

209 items.append(scalar(item)) 

210 i += 1 

211 return items, i 

212 

213 

214def _parse_block_scalar(lines: list[str], i: int, parent_indent: int) -> tuple[str, int]: 

215 """Consume the indented body of a ``key: |`` block scalar into a string.""" 

216 body: list[str] = [] 

217 while i < len(lines): 

218 line = lines[i] 

219 if line.strip() and _indent_of(line) <= parent_indent: 

220 break 

221 body.append(line.strip()) 

222 i += 1 

223 return "\n".join(body).strip(), i