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
« prev ^ index » next coverage.py v7.8.0, created at 2026-04-21 04:49 +0000
1"""ID assignment for pytest-beehave .feature files."""
3import itertools
4import os
5import re
6import secrets
7from collections.abc import Iterator
8from pathlib import Path
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}")
15def _collect_existing_ids(content: str) -> set[str]:
16 """Collect all @id hex values already present in file content.
18 Args:
19 content: Full text of a .feature file.
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))
27def _candidate_stream() -> Iterator[str]:
28 """Yield an infinite stream of random 8-char hex candidates.
30 Yields:
31 Random 8-char hex strings.
32 """
33 while True:
34 yield secrets.token_hex(4)
37def _generate_unique_id(existing_ids: set[str]) -> str:
38 """Generate a unique 8-char hex ID not already in existing_ids.
40 Args:
41 existing_ids: Set of IDs already used in the current file.
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)
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.
52 Mutates result and existing_ids in-place.
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")
70def _insert_id_before_example(content: str, existing_ids: set[str]) -> str:
71 """Insert @id tags before each untagged Example line.
73 Args:
74 content: Full text of a .feature file.
75 existing_ids: Set of IDs already present in the file.
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)
88def _id_tag_precedes(lines: list[str]) -> bool:
89 """Check if the last non-empty line is an @id tag.
91 Args:
92 lines: Lines accumulated so far.
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))
101def _process_writable_file(feature_path: Path) -> None:
102 """Insert @id tags into a writable .feature file for untagged Examples.
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")
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.
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.
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"
135def _check_readonly_file(feature_path: Path) -> list[str]:
136 """Collect error messages for untagged Examples in a read-only file.
138 Args:
139 feature_path: Path to the read-only .feature file.
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]
152def _process_feature_file(feature_path: Path) -> list[str]:
153 """Process a single .feature file: write IDs or collect errors.
155 Args:
156 feature_path: Path to the .feature file.
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)
167def _process_stage(features_dir: Path, stage: str) -> list[str]:
168 """Process all .feature files in a single stage directory.
170 Args:
171 features_dir: Root features directory.
172 stage: Stage subdirectory name (e.g. "in-progress").
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 )
187def assign_ids(features_dir: Path) -> list[str]:
188 """Assign @id tags to untagged Examples in all .feature files.
190 For writable files, inserts @id tags in-place. For read-only files,
191 returns error strings instead of modifying the file.
193 Args:
194 features_dir: Root directory containing backlog/, in-progress/,
195 and completed/ subdirectories with .feature files.
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 )