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.