pytest_beehave.stub_reader

Test stub reader for pytest-beehave.

  1"""Test stub reader for pytest-beehave."""
  2
  3from __future__ import annotations
  4
  5import re
  6from dataclasses import dataclass
  7from pathlib import Path
  8
  9from pytest_beehave.models import ExampleId, FeatureSlug
 10
 11_ID_SUFFIX_RE: re.Pattern[str] = re.compile(r"test_\w+_([a-f0-9]{8})$")
 12_FUNC_RE: re.Pattern[str] = re.compile(
 13    r"^( {0,4})def (test_[a-z0-9_]+_([a-f0-9]{8}))\(",
 14    re.MULTILINE,
 15)
 16_CLASS_RE: re.Pattern[str] = re.compile(r"^class (Test\w+)", re.MULTILINE)
 17
 18
 19@dataclass(frozen=True, slots=True)
 20class ExistingStub:
 21    """Represents an existing test stub function found in a test file.
 22
 23    Attributes:
 24        function_name: The full function name.
 25        example_id: The ExampleId extracted from the function name.
 26        feature_slug: The FeatureSlug inferred from the function name.
 27        class_name: The enclosing class name, or None for top-level.
 28        file_path: Path to the file containing this stub.
 29        markers: Tuple of decorator strings present on the function.
 30        docstring: The docstring body, or empty string.
 31    """
 32
 33    function_name: str
 34    example_id: ExampleId
 35    feature_slug: FeatureSlug
 36    class_name: str | None
 37    file_path: Path
 38    markers: tuple[str, ...]
 39    docstring: str
 40
 41
 42def extract_example_id_from_name(name: str) -> ExampleId | None:
 43    """Extract the ExampleId from a test function name.
 44
 45    Args:
 46        name: The test function name.
 47
 48    Returns:
 49        ExampleId if found, else None.
 50    """
 51    match = _ID_SUFFIX_RE.search(name)
 52    if match:
 53        return ExampleId(match.group(1))
 54    return None
 55
 56
 57def _extract_feature_slug_from_name(name: str) -> FeatureSlug:
 58    """Extract the FeatureSlug from a test function name.
 59
 60    Args:
 61        name: The test function name like 'test_my_feature_aabbccdd'.
 62
 63    Returns:
 64        FeatureSlug for 'my_feature'.
 65    """
 66    # Strip 'test_' prefix and '_<8hex>' suffix
 67    without_prefix = name[len("test_") :]
 68    # remove '_aabbccdd' (9 chars: underscore + 8hex)
 69    without_suffix = without_prefix[:-9]
 70    return FeatureSlug(without_suffix)
 71
 72
 73def _find_triple_quote_end(content: str, start: int, quote: str) -> int:
 74    """Find the end position of a triple-quoted string.
 75
 76    Args:
 77        content: Full file content.
 78        start: Start position of the opening triple-quote.
 79        quote: The triple-quote delimiter.
 80
 81    Returns:
 82        Position after the closing triple-quote.
 83    """
 84    end = content.find(quote, start + 3)
 85    if end == -1:
 86        return len(content)
 87    return end + 3
 88
 89
 90def _find_string_ranges(content: str) -> list[tuple[int, int]]:
 91    """Find all triple-quoted string ranges in content.
 92
 93    Args:
 94        content: Full file content.
 95
 96    Returns:
 97        List of (start, end) ranges.
 98    """
 99    ranges: list[tuple[int, int]] = []
100    pos = 0
101    while pos < len(content):
102        matched = _try_triple_quote(content, pos, ranges)
103        pos = matched if matched is not None else pos + 1
104    return ranges
105
106
107def _try_triple_quote(
108    content: str, pos: int, ranges: list[tuple[int, int]]
109) -> int | None:
110    """Try to match a triple-quoted string at pos.
111
112    Args:
113        content: Full file content.
114        pos: Current position.
115        ranges: List to append (start, end) to.
116
117    Returns:
118        New position after the string, or None.
119    """
120    for quote in ('"""', "'''"):
121        if content[pos : pos + 3] == quote:
122            end = _find_triple_quote_end(content, pos, quote)
123            ranges.append((pos, end))
124            return end
125    return None
126
127
128def _in_string(pos: int, string_ranges: list[tuple[int, int]]) -> bool:
129    """Check if a position is inside a triple-quoted string.
130
131    Args:
132        pos: Character position.
133        string_ranges: List of (start, end) ranges.
134
135    Returns:
136        True if pos is inside any string range.
137    """
138    return any(start < pos < end for start, end in string_ranges)
139
140
141def _extract_docstring(content: str, func_start: int) -> str:
142    """Extract the docstring body from a function at the given position.
143
144    Args:
145        content: Full file content.
146        func_start: Start position of the def line.
147
148    Returns:
149        Docstring body string, or empty string.
150    """
151    # Find the first triple-quote after the def line
152    def_end = content.find("\n", func_start)
153    if def_end == -1:
154        return ""
155    after_def = content[def_end:]
156    stripped = after_def.lstrip("\n")
157    if not stripped.startswith('    """'):
158        return ""
159    open_pos = after_def.find('"""')
160    close_pos = after_def.find('"""', open_pos + 3)
161    if close_pos == -1:
162        return ""
163    return after_def[open_pos + 3 : close_pos]
164
165
166def _is_decorator_line(stripped: str) -> bool:
167    """Return True if a stripped line is a decorator.
168
169    Args:
170        stripped: A stripped source line.
171
172    Returns:
173        True if the line starts with '@'.
174    """
175    return stripped.startswith("@")
176
177
178def _is_blank_or_comment(stripped: str) -> bool:
179    """Return True if a stripped line is blank or a comment.
180
181    Args:
182        stripped: A stripped source line.
183
184    Returns:
185        True if the line is blank or starts with '#'.
186    """
187    return stripped == "" or stripped.startswith("#")
188
189
190def _collect_markers_reversed(lines: list[str]) -> list[str]:
191    """Collect decorator strings in reverse order from a list of source lines.
192
193    Scans backwards, accumulating decorator lines until a non-decorator,
194    non-blank, non-comment line is found.
195
196    Args:
197        lines: Source lines before a function definition.
198
199    Returns:
200        Decorator strings in reverse order (innermost first).
201    """
202    markers: list[str] = []
203    for line in reversed(lines):
204        stripped = line.strip()
205        if _is_decorator_line(stripped):
206            markers.append(stripped[1:])
207            continue
208        if not _is_blank_or_comment(stripped):
209            break
210    return markers
211
212
213def _extract_markers(content: str, func_start: int) -> tuple[str, ...]:
214    """Extract decorator strings before a function.
215
216    Args:
217        content: Full file content.
218        func_start: Start position of the def line.
219
220    Returns:
221        Tuple of decorator strings (without @ prefix).
222    """
223    lines = content[:func_start].splitlines()
224    return tuple(reversed(_collect_markers_reversed(lines)))
225
226
227def _extract_class_name(content: str, func_start: int, indent: str) -> str | None:
228    """Extract the enclosing class name for an indented method.
229
230    Args:
231        content: Full file content.
232        func_start: Start position of the def line.
233        indent: Leading whitespace of the def line.
234
235    Returns:
236        Class name string, or None for module-level functions.
237    """
238    if not indent:
239        return None
240    before = content[:func_start]
241    class_matches = list(_CLASS_RE.finditer(before))
242    if not class_matches:
243        return None
244    return class_matches[-1].group(1)
245
246
247def _build_stub(
248    content: str,
249    match: re.Match[str],
250    path: Path,
251) -> ExistingStub:
252    """Build an ExistingStub from a regex match in file content.
253
254    Args:
255        content: Full file content.
256        match: Regex match for the function definition.
257        path: Path to the file.
258
259    Returns:
260        ExistingStub for the matched function.
261    """
262    indent = match.group(1)
263    func_name = match.group(2)
264    example_id = ExampleId(match.group(3))
265    return ExistingStub(
266        function_name=func_name,
267        example_id=example_id,
268        feature_slug=_extract_feature_slug_from_name(func_name),
269        class_name=_extract_class_name(content, match.start(), indent),
270        file_path=path,
271        markers=_extract_markers(content, match.start()),
272        docstring=_extract_docstring(content, match.start()),
273    )
274
275
276def read_stubs_from_file(path: Path) -> list[ExistingStub]:
277    """Read all test stub functions from a test file.
278
279    Args:
280        path: Path to the test file.
281
282    Returns:
283        List of ExistingStub objects found in the file.
284    """
285    if not path.exists():
286        return []
287    content = path.read_text(encoding="utf-8")
288    string_ranges = _find_string_ranges(content)
289    return [
290        _build_stub(content, match, path)
291        for match in _FUNC_RE.finditer(content)
292        if not _in_string(match.start(), string_ranges)
293    ]
@dataclass(frozen=True, slots=True)
class ExistingStub:
20@dataclass(frozen=True, slots=True)
21class ExistingStub:
22    """Represents an existing test stub function found in a test file.
23
24    Attributes:
25        function_name: The full function name.
26        example_id: The ExampleId extracted from the function name.
27        feature_slug: The FeatureSlug inferred from the function name.
28        class_name: The enclosing class name, or None for top-level.
29        file_path: Path to the file containing this stub.
30        markers: Tuple of decorator strings present on the function.
31        docstring: The docstring body, or empty string.
32    """
33
34    function_name: str
35    example_id: ExampleId
36    feature_slug: FeatureSlug
37    class_name: str | None
38    file_path: Path
39    markers: tuple[str, ...]
40    docstring: str

Represents an existing test stub function found in a test file.

Attributes: function_name: The full function name. example_id: The ExampleId extracted from the function name. feature_slug: The FeatureSlug inferred from the function name. class_name: The enclosing class name, or None for top-level. file_path: Path to the file containing this stub. markers: Tuple of decorator strings present on the function. docstring: The docstring body, or empty string.

ExistingStub( function_name: str, example_id: pytest_beehave.models.ExampleId, feature_slug: pytest_beehave.models.FeatureSlug, class_name: str | None, file_path: pathlib._local.Path, markers: tuple[str, ...], docstring: str)
function_name: str
class_name: str | None
file_path: pathlib._local.Path
markers: tuple[str, ...]
docstring: str
def extract_example_id_from_name(name: str) -> pytest_beehave.models.ExampleId | None:
43def extract_example_id_from_name(name: str) -> ExampleId | None:
44    """Extract the ExampleId from a test function name.
45
46    Args:
47        name: The test function name.
48
49    Returns:
50        ExampleId if found, else None.
51    """
52    match = _ID_SUFFIX_RE.search(name)
53    if match:
54        return ExampleId(match.group(1))
55    return None

Extract the ExampleId from a test function name.

Args: name: The test function name.

Returns: ExampleId if found, else None.

def read_stubs_from_file( path: pathlib._local.Path) -> list[ExistingStub]:
277def read_stubs_from_file(path: Path) -> list[ExistingStub]:
278    """Read all test stub functions from a test file.
279
280    Args:
281        path: Path to the test file.
282
283    Returns:
284        List of ExistingStub objects found in the file.
285    """
286    if not path.exists():
287        return []
288    content = path.read_text(encoding="utf-8")
289    string_ranges = _find_string_ranges(content)
290    return [
291        _build_stub(content, match, path)
292        for match in _FUNC_RE.finditer(content)
293        if not _in_string(match.start(), string_ranges)
294    ]

Read all test stub functions from a test file.

Args: path: Path to the test file.

Returns: List of ExistingStub objects found in the file.