pytest_beehave.id_generator
ID assignment for pytest-beehave .feature files.
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 )
FEATURE_STAGES: tuple[str, ...] =
('backlog', 'in-progress', 'completed')
def
assign_ids(features_dir: pathlib._local.Path) -> list[str]:
188def assign_ids(features_dir: Path) -> list[str]: 189 """Assign @id tags to untagged Examples in all .feature files. 190 191 For writable files, inserts @id tags in-place. For read-only files, 192 returns error strings instead of modifying the file. 193 194 Args: 195 features_dir: Root directory containing backlog/, in-progress/, 196 and completed/ subdirectories with .feature files. 197 198 Returns: 199 List of error strings for read-only files with missing @id tags. 200 Empty list means all Examples are tagged or files are writable. 201 """ 202 return list( 203 itertools.chain.from_iterable( 204 _process_stage(features_dir, stage) for stage in FEATURE_STAGES 205 ) 206 )
Assign @id tags to untagged Examples in all .feature files.
For writable files, inserts @id tags in-place. For read-only files, returns error strings instead of modifying the file.
Args: features_dir: Root directory containing backlog/, in-progress/, and completed/ subdirectories with .feature files.
Returns: List of error strings for read-only files with missing @id tags. Empty list means all Examples are tagged or files are writable.