Coverage for pytest_beehave/id_generator.py: 100%

65 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2026-04-21 04:49 +0000

1"""ID assignment for pytest-beehave .feature files.""" 

2 

3import itertools 

4import os 

5import re 

6import secrets 

7from collections.abc import Iterator 

8from pathlib import Path 

9 

10FEATURE_STAGES: tuple[str, ...] = ("backlog", "in-progress", "completed") 

11_EXAMPLE_LINE_RE: re.Pattern[str] = re.compile(r"^(\s+)Example:", re.MULTILINE) 

12_ID_TAG_RE: re.Pattern[str] = re.compile(r"@id:[a-f0-9]{8}") 

13 

14 

15def _collect_existing_ids(content: str) -> set[str]: 

16 """Collect all @id hex values already present in file content. 

17 

18 Args: 

19 content: Full text of a .feature file. 

20 

21 Returns: 

22 Set of 8-char hex strings found in @id tags. 

23 """ 

24 return set(re.findall(r"@id:([a-f0-9]{8})", content)) 

25 

26 

27def _candidate_stream() -> Iterator[str]: 

28 """Yield an infinite stream of random 8-char hex candidates. 

29 

30 Yields: 

31 Random 8-char hex strings. 

32 """ 

33 while True: 

34 yield secrets.token_hex(4) 

35 

36 

37def _generate_unique_id(existing_ids: set[str]) -> str: 

38 """Generate a unique 8-char hex ID not already in existing_ids. 

39 

40 Args: 

41 existing_ids: Set of IDs already used in the current file. 

42 

43 Returns: 

44 A new unique 8-char hex string. 

45 """ 

46 return next(c for c in _candidate_stream() if c not in existing_ids) 

47 

48 

49def _prepend_id_tag(line: str, result: list[str], existing_ids: set[str]) -> None: 

50 """Prepend an @id tag line before an Example line if not already tagged. 

51 

52 Mutates result and existing_ids in-place. 

53 

54 Args: 

55 line: The current line being processed. 

56 result: Accumulated output lines so far. 

57 existing_ids: Set of IDs already used (mutated when a new ID is added). 

58 """ 

59 match = _EXAMPLE_LINE_RE.match(line) 

60 if match is None: 

61 return 

62 if _id_tag_precedes(result): 

63 return 

64 new_id = _generate_unique_id(existing_ids) 

65 existing_ids.add(new_id) 

66 indent = match.group(1) 

67 result.append(f"{indent}@id:{new_id}\n") 

68 

69 

70def _insert_id_before_example(content: str, existing_ids: set[str]) -> str: 

71 """Insert @id tags before each untagged Example line. 

72 

73 Args: 

74 content: Full text of a .feature file. 

75 existing_ids: Set of IDs already present in the file. 

76 

77 Returns: 

78 Updated file content with @id tags inserted. 

79 """ 

80 lines = content.splitlines(keepends=True) 

81 result: list[str] = [] 

82 for line in lines: 

83 _prepend_id_tag(line, result, existing_ids) 

84 result.append(line) 

85 return "".join(result) 

86 

87 

88def _id_tag_precedes(lines: list[str]) -> bool: 

89 """Check if the last non-empty line is an @id tag. 

90 

91 Args: 

92 lines: Lines accumulated so far. 

93 

94 Returns: 

95 True if the previous non-empty line contains an @id tag. 

96 """ 

97 last_non_empty = next((ln.strip() for ln in reversed(lines) if ln.strip()), "") 

98 return bool(_ID_TAG_RE.search(last_non_empty)) 

99 

100 

101def _process_writable_file(feature_path: Path) -> None: 

102 """Insert @id tags into a writable .feature file for untagged Examples. 

103 

104 Args: 

105 feature_path: Path to the .feature file to process. 

106 """ 

107 content = feature_path.read_text(encoding="utf-8") 

108 existing_ids = _collect_existing_ids(content) 

109 updated = _insert_id_before_example(content, existing_ids) 

110 if updated != content: 

111 feature_path.write_text(updated, encoding="utf-8") 

112 

113 

114def _missing_id_error( 

115 feature_path: Path, line: str, preceding: list[str] 

116) -> str | None: 

117 """Return an error string if this Example line has no preceding @id tag. 

118 

119 Args: 

120 feature_path: Path to the feature file (used in error message). 

121 line: The current line from the feature file. 

122 preceding: All lines before this one. 

123 

124 Returns: 

125 Error string if an @id tag is missing, or None. 

126 """ 

127 if not _EXAMPLE_LINE_RE.match(line): 

128 return None 

129 if _id_tag_precedes(preceding): 

130 return None 

131 title = line.strip().removeprefix("Example:").strip() 

132 return f"{feature_path}: Example '{title}' has no @id" 

133 

134 

135def _check_readonly_file(feature_path: Path) -> list[str]: 

136 """Collect error messages for untagged Examples in a read-only file. 

137 

138 Args: 

139 feature_path: Path to the read-only .feature file. 

140 

141 Returns: 

142 List of error strings, one per untagged Example. 

143 """ 

144 lines = feature_path.read_text(encoding="utf-8").splitlines() 

145 errors = [ 

146 _missing_id_error(feature_path, line, lines[:index]) 

147 for index, line in enumerate(lines) 

148 ] 

149 return [e for e in errors if e is not None] 

150 

151 

152def _process_feature_file(feature_path: Path) -> list[str]: 

153 """Process a single .feature file: write IDs or collect errors. 

154 

155 Args: 

156 feature_path: Path to the .feature file. 

157 

158 Returns: 

159 List of error strings (empty if writable or no untagged Examples). 

160 """ 

161 if os.access(feature_path, os.W_OK): 

162 _process_writable_file(feature_path) 

163 return [] 

164 return _check_readonly_file(feature_path) 

165 

166 

167def _process_stage(features_dir: Path, stage: str) -> list[str]: 

168 """Process all .feature files in a single stage directory. 

169 

170 Args: 

171 features_dir: Root features directory. 

172 stage: Stage subdirectory name (e.g. "in-progress"). 

173 

174 Returns: 

175 List of error strings from read-only files with missing @id tags. 

176 """ 

177 stage_dir = features_dir / stage 

178 if not stage_dir.exists(): 

179 return [] 

180 return list( 

181 itertools.chain.from_iterable( 

182 _process_feature_file(p) for p in sorted(stage_dir.rglob("*.feature")) 

183 ) 

184 ) 

185 

186 

187def assign_ids(features_dir: Path) -> list[str]: 

188 """Assign @id tags to untagged Examples in all .feature files. 

189 

190 For writable files, inserts @id tags in-place. For read-only files, 

191 returns error strings instead of modifying the file. 

192 

193 Args: 

194 features_dir: Root directory containing backlog/, in-progress/, 

195 and completed/ subdirectories with .feature files. 

196 

197 Returns: 

198 List of error strings for read-only files with missing @id tags. 

199 Empty list means all Examples are tagged or files are writable. 

200 """ 

201 return list( 

202 itertools.chain.from_iterable( 

203 _process_stage(features_dir, stage) for stage in FEATURE_STAGES 

204 ) 

205 )