pytest_beehave.sync_engine

Sync engine for pytest-beehave — orchestrates stub creation and updates.

  1"""Sync engine for pytest-beehave — orchestrates stub creation and updates."""
  2
  3from __future__ import annotations
  4
  5from dataclasses import dataclass
  6from pathlib import Path
  7from typing import Protocol
  8
  9from pytest_beehave.config import StubFormat
 10from pytest_beehave.feature_parser import (
 11    ParsedExample,
 12    ParsedFeature,
 13    ParsedRule,
 14    parse_feature,
 15)
 16from pytest_beehave.models import ExampleId, FeatureStage
 17from pytest_beehave.stub_reader import ExistingStub, read_stubs_from_file
 18from pytest_beehave.stub_writer import (
 19    StubSpec,
 20    SyncAction,
 21    build_class_name,
 22    build_docstring,
 23    build_function_name,
 24    mark_non_conforming,
 25    mark_orphan,
 26    remove_orphan_marker,
 27    toggle_deprecated_marker,
 28    update_docstring,
 29    write_stub_to_file,
 30)
 31
 32_FEATURE_STAGES = (
 33    FeatureStage.BACKLOG,
 34    FeatureStage.IN_PROGRESS,
 35    FeatureStage.COMPLETED,
 36)
 37
 38
 39class FileSystemProtocol(Protocol):
 40    """Protocol for filesystem operations needed by the sync engine."""
 41
 42    def list_feature_files(self, stage_dir: Path) -> list[Path]:  # pragma: no cover
 43        """List all .feature files recursively under stage_dir."""
 44        ...
 45
 46    def list_test_files(self, tests_dir: Path) -> list[Path]:  # pragma: no cover
 47        """List all *_test.py files recursively under tests_dir."""
 48        ...
 49
 50
 51@dataclass(frozen=True, slots=True)
 52class _RealFileSystem:
 53    """Concrete filesystem adapter using pathlib."""
 54
 55    def list_feature_files(self, stage_dir: Path) -> list[Path]:
 56        """List all .feature files recursively under stage_dir.
 57
 58        Args:
 59            stage_dir: Root directory to search.
 60
 61        Returns:
 62            Sorted list of .feature file paths.
 63        """
 64        return sorted(stage_dir.rglob("*.feature"))
 65
 66    def list_test_files(self, tests_dir: Path) -> list[Path]:
 67        """List all *_test.py files recursively under tests_dir.
 68
 69        Args:
 70            tests_dir: Root directory to search.
 71
 72        Returns:
 73            Sorted list of test file paths.
 74        """
 75        return sorted(tests_dir.rglob("*_test.py"))
 76
 77
 78@dataclass(frozen=True, slots=True)
 79class SyncResult:
 80    """Result of a sync operation.
 81
 82    Attributes:
 83        actions: Tuple of SyncAction objects describing what was done.
 84    """
 85
 86    actions: tuple[SyncAction, ...]
 87
 88    @property
 89    def is_noop(self) -> bool:
 90        """Return True if no actions were taken."""
 91        return len(self.actions) == 0
 92
 93
 94def _collect_all_ids(
 95    features_dir: Path,
 96    filesystem: FileSystemProtocol,
 97) -> frozenset[ExampleId]:
 98    """Collect all example IDs from all .feature files.
 99
100    Args:
101        features_dir: Root of the features directory.
102        filesystem: Filesystem adapter.
103
104    Returns:
105        Frozenset of ExampleId objects.
106    """
107    import re
108
109    id_re = re.compile(r"@id:([a-f0-9]{8})")
110    ids: set[ExampleId] = set()
111    for stage in _FEATURE_STAGES:
112        stage_dir = features_dir / stage.value
113        if not stage_dir.exists():
114            continue
115        for path in filesystem.list_feature_files(stage_dir):
116            text = path.read_text(encoding="utf-8")
117            ids.update(ExampleId(match.group(1)) for match in id_re.finditer(text))
118    return frozenset(ids)
119
120
121def _add_rule_locations(
122    feature_dir: Path,
123    feature: ParsedFeature,
124    locations: dict[ExampleId, tuple[Path, str | None]],
125) -> None:
126    """Add expected locations for all rule-based examples of a feature.
127
128    Args:
129        feature_dir: The test directory for this feature.
130        feature: The parsed feature.
131        locations: Dict to populate with ExampleId -> (file, class) entries.
132    """
133    for rule in feature.rules:
134        test_file = feature_dir / f"{rule.rule_slug}_test.py"
135        class_name = build_class_name(rule.rule_slug)
136        for example in rule.examples:
137            locations[example.example_id] = (test_file, class_name)
138
139
140def _build_expected_locations(
141    feature_stage_pairs: list[tuple[ParsedFeature, FeatureStage]],
142    tests_dir: Path,
143) -> dict[ExampleId, tuple[Path, str | None]]:
144    """Build a map of ExampleId to (expected_test_file, expected_class_name).
145
146    Args:
147        feature_stage_pairs: All parsed feature/stage tuples.
148        tests_dir: Root of the tests/features/ directory.
149
150    Returns:
151        Dict mapping ExampleId to (file_path, class_name_or_None).
152    """
153    locations: dict[ExampleId, tuple[Path, str | None]] = {}
154    for feature, _stage in feature_stage_pairs:
155        feature_dir = tests_dir / str(feature.feature_slug)
156        _add_rule_locations(feature_dir, feature, locations)
157        top_level_file = feature_dir / "examples_test.py"
158        for example in feature.top_level_examples:
159            locations[example.example_id] = (top_level_file, None)
160    return locations
161
162
163def _orphan_action(
164    test_file: Path,
165    stub: ExistingStub,
166    all_ids: frozenset[ExampleId],
167) -> SyncAction | None:
168    """Compute the orphan sync action for a single stub.
169
170    Args:
171        test_file: The test file containing the stub.
172        stub: The existing stub.
173        all_ids: All known example IDs.
174
175    Returns:
176        SyncAction or None.
177    """
178    if stub.example_id not in all_ids:
179        return mark_orphan(test_file, stub.function_name)
180    return remove_orphan_marker(test_file, stub.function_name)
181
182
183def _orphan_actions_for_file(
184    test_file: Path,
185    all_ids: frozenset[ExampleId],
186) -> list[SyncAction]:
187    """Compute orphan sync actions for all stubs in a single test file.
188
189    Args:
190        test_file: The test file to scan.
191        all_ids: All known example IDs.
192
193    Returns:
194        List of SyncAction objects.
195    """
196    return [
197        action
198        for stub in read_stubs_from_file(test_file)
199        for action in [_orphan_action(test_file, stub, all_ids)]
200        if action is not None
201    ]
202
203
204def _sync_orphans(
205    tests_dir: Path,
206    all_ids: frozenset[ExampleId],
207    filesystem: FileSystemProtocol,
208) -> list[SyncAction]:
209    """Add or remove orphan markers for test functions without matching @id.
210
211    Args:
212        tests_dir: Root of the tests/features/ directory.
213        all_ids: All known example IDs.
214        filesystem: Filesystem adapter.
215
216    Returns:
217        List of SyncAction objects.
218    """
219    actions: list[SyncAction] = []
220    for test_file in filesystem.list_test_files(tests_dir):
221        actions.extend(_orphan_actions_for_file(test_file, all_ids))
222    return actions
223
224
225def _check_stub_conformity(
226    test_file: Path,
227    stub: ExistingStub,
228    expected_locations: dict[ExampleId, tuple[Path, str | None]],
229) -> SyncAction | None:
230    """Check if a single stub is in the correct file and mark it if not.
231
232    Args:
233        test_file: The file the stub was found in.
234        stub: The existing stub.
235        expected_locations: Map of ExampleId to (expected_file, expected_class).
236
237    Returns:
238        SyncAction if non-conforming, else None.
239    """
240    if stub.example_id not in expected_locations:
241        return None
242    expected_file, expected_class = expected_locations[stub.example_id]
243    if expected_class is None or test_file == expected_file:
244        return None
245    return mark_non_conforming(
246        test_file, stub.function_name, expected_file, expected_class
247    )
248
249
250def _non_conforming_actions_for_file(
251    test_file: Path,
252    expected_locations: dict[ExampleId, tuple[Path, str | None]],
253) -> list[SyncAction]:
254    """Collect non-conforming actions for all stubs in one test file.
255
256    Args:
257        test_file: Path to the test file.
258        expected_locations: Map of ExampleId to (expected_file, expected_class).
259
260    Returns:
261        List of SyncAction objects for non-conforming stubs.
262    """
263    return [
264        action
265        for stub in read_stubs_from_file(test_file)
266        for action in [_check_stub_conformity(test_file, stub, expected_locations)]
267        if action is not None
268    ]
269
270
271def _sync_non_conforming(
272    tests_dir: Path,
273    expected_locations: dict[ExampleId, tuple[Path, str | None]],
274    filesystem: FileSystemProtocol,
275) -> list[SyncAction]:
276    """Mark test stubs found in the wrong file as non-conforming.
277
278    Only applies to Rule-based stubs (those with an expected class). Top-level
279    examples without a class context are skipped — orphan detection handles
280    unrecognised test files.
281
282    Args:
283        tests_dir: Root of the tests/features/ directory.
284        expected_locations: Map of ExampleId to (expected_file, expected_class).
285        filesystem: Filesystem adapter.
286
287    Returns:
288        List of SyncAction objects.
289    """
290    actions: list[SyncAction] = []
291    for test_file in filesystem.list_test_files(tests_dir):
292        actions.extend(_non_conforming_actions_for_file(test_file, expected_locations))
293    return actions
294
295
296def _sync_active_feature(
297    feature: ParsedFeature,
298    tests_dir: Path,
299    stub_format: StubFormat = "functions",
300) -> list[SyncAction]:
301    """Sync stubs for an active (backlog/in-progress) feature.
302
303    Args:
304        feature: The parsed feature.
305        tests_dir: Root of the tests/features/ directory.
306        stub_format: The output format for new stubs.
307
308    Returns:
309        List of SyncAction objects.
310    """
311    actions: list[SyncAction] = []
312    feature_test_dir = tests_dir / str(feature.feature_slug)
313
314    if feature.rules:
315        for rule in feature.rules:
316            actions.extend(
317                _sync_rule_stubs(feature, rule, feature_test_dir, stub_format)
318            )
319    elif feature.top_level_examples:
320        actions.extend(_sync_top_level_stubs(feature, feature_test_dir))
321
322    return actions
323
324
325def _sync_rule_stubs(
326    feature: ParsedFeature,
327    rule: ParsedRule,
328    feature_test_dir: Path,
329    stub_format: StubFormat = "functions",
330) -> list[SyncAction]:
331    """Sync stubs for a single rule block.
332
333    Args:
334        feature: The parsed feature.
335        rule: The parsed rule.
336        feature_test_dir: Directory for this feature's tests.
337        stub_format: The output format for new stubs.
338
339    Returns:
340        List of SyncAction objects.
341    """
342    if not rule.examples:
343        return []
344    test_file = feature_test_dir / f"{rule.rule_slug}_test.py"
345    existing = {s.example_id: s for s in read_stubs_from_file(test_file)}
346    actions: list[SyncAction] = []
347    for example in rule.examples:
348        action = _sync_one_example(
349            feature, rule, example, test_file, existing, stub_format
350        )
351        if action is not None:
352            actions.append(action)
353    actions.extend(_sync_deprecated_in_rule(feature, rule, test_file))
354    return actions
355
356
357def _update_existing_stub(
358    feature: ParsedFeature,
359    rule: ParsedRule | None,
360    example: ParsedExample,
361    test_file: Path,
362    stub: ExistingStub,
363) -> SyncAction | None:
364    """Update an existing stub's docstring and/or name.
365
366    Args:
367        feature: The parsed feature.
368        rule: The parsed rule (or None for top-level).
369        example: The parsed example.
370        test_file: Path to the test file.
371        stub: The existing stub to update.
372
373    Returns:
374        SyncAction or None.
375    """
376    return update_docstring(
377        test_file,
378        stub.function_name,
379        build_docstring(feature, rule, example),
380        feature.feature_slug,
381        example.example_id,
382    )
383
384
385def _sync_one_example(
386    feature: ParsedFeature,
387    rule: ParsedRule | None,
388    example: ParsedExample,
389    test_file: Path,
390    existing: dict[ExampleId, ExistingStub],
391    stub_format: StubFormat = "functions",
392) -> SyncAction | None:
393    """Sync a single example stub — create or update.
394
395    Args:
396        feature: The parsed feature.
397        rule: The parsed rule (or None for top-level).
398        example: The parsed example.
399        test_file: Path to the test file.
400        existing: Map of existing stubs by example ID.
401        stub_format: The output format for new stubs.
402
403    Returns:
404        SyncAction or None.
405    """
406    if example.example_id in existing:
407        return _update_existing_stub(
408            feature, rule, example, test_file, existing[example.example_id]
409        )
410    rule_slug = rule.rule_slug if rule else None
411    spec = StubSpec(
412        feature_slug=feature.feature_slug,
413        rule_slug=rule_slug,
414        example=example,
415        feature=feature,
416        stub_format=stub_format,
417    )
418    return write_stub_to_file(test_file, spec)
419
420
421def _sync_deprecated_in_rule(
422    feature: ParsedFeature,
423    rule: ParsedRule,
424    test_file: Path,
425) -> list[SyncAction]:
426    """Sync deprecated markers for all examples in a rule.
427
428    Args:
429        feature: The parsed feature.
430        rule: The parsed rule.
431        test_file: Path to the test file.
432
433    Returns:
434        List of SyncAction objects.
435    """
436    return [
437        action
438        for example in rule.examples
439        for action in [
440            toggle_deprecated_marker(
441                test_file,
442                build_function_name(feature.feature_slug, example.example_id),
443                should_be_deprecated=example.is_deprecated,
444            )
445        ]
446        if action is not None
447    ]
448
449
450def _sync_top_level_stubs(
451    feature: ParsedFeature,
452    feature_test_dir: Path,
453) -> list[SyncAction]:
454    """Sync stubs for top-level examples (no Rule blocks).
455
456    Args:
457        feature: The parsed feature.
458        feature_test_dir: Directory for this feature's tests.
459
460    Returns:
461        List of SyncAction objects.
462    """
463    test_file = feature_test_dir / "examples_test.py"
464    existing = {s.example_id: s for s in read_stubs_from_file(test_file)}
465    actions: list[SyncAction] = []
466    for example in feature.top_level_examples:
467        action = _sync_one_example(feature, None, example, test_file, existing)
468        if action is not None:
469            actions.append(action)
470    actions.extend(_sync_deprecated_top_level(feature, test_file))
471    return actions
472
473
474def _sync_deprecated_top_level(
475    feature: ParsedFeature,
476    test_file: Path,
477) -> list[SyncAction]:
478    """Sync deprecated markers for top-level examples (no Rule blocks).
479
480    Args:
481        feature: The parsed feature.
482        test_file: Path to the top-level examples test file.
483
484    Returns:
485        List of SyncAction objects.
486    """
487    return [
488        action
489        for example in feature.top_level_examples
490        for action in [
491            toggle_deprecated_marker(
492                test_file,
493                build_function_name(feature.feature_slug, example.example_id),
494                should_be_deprecated=example.is_deprecated,
495            )
496        ]
497        if action is not None
498    ]
499
500
501def _sync_deprecated_rules(
502    feature: ParsedFeature,
503    feature_test_dir: Path,
504) -> list[SyncAction]:
505    """Sync deprecated markers for all rules in a feature.
506
507    Args:
508        feature: The parsed feature.
509        feature_test_dir: Directory for this feature's tests.
510
511    Returns:
512        List of SyncAction objects.
513    """
514    actions: list[SyncAction] = []
515    for rule in feature.rules:
516        test_file = feature_test_dir / f"{rule.rule_slug}_test.py"
517        actions.extend(_sync_deprecated_in_rule(feature, rule, test_file))
518    return actions
519
520
521def _sync_completed_feature(
522    feature: ParsedFeature,
523    tests_dir: Path,
524) -> list[SyncAction]:
525    """Sync deprecated markers only for a completed feature.
526
527    Args:
528        feature: The parsed feature.
529        tests_dir: Root of the tests/features/ directory.
530
531    Returns:
532        List of SyncAction objects.
533    """
534    feature_test_dir = tests_dir / str(feature.feature_slug)
535    if feature.rules:
536        return _sync_deprecated_rules(feature, feature_test_dir)
537    return _sync_deprecated_top_level(feature, feature_test_dir / "examples_test.py")
538
539
540def _folder_name_for(feature_path: Path, stage_dir: Path) -> str:
541    """Derive the folder name for a feature file.
542
543    Args:
544        feature_path: Path to the .feature file.
545        stage_dir: The stage directory (backlog/, in-progress/, completed/).
546
547    Returns:
548        Folder name string used as the feature slug source.
549    """
550    feature_parent = feature_path.parent
551    if feature_parent == stage_dir:
552        return feature_path.stem
553    return feature_parent.name
554
555
556def _features_in_stage(
557    stage: FeatureStage,
558    features_dir: Path,
559    filesystem: FileSystemProtocol,
560) -> list[tuple[ParsedFeature, FeatureStage]]:
561    """Discover all feature files for one stage directory.
562
563    Args:
564        stage: The feature stage.
565        features_dir: Root of the features directory.
566        filesystem: Filesystem adapter.
567
568    Returns:
569        List of (ParsedFeature, FeatureStage) tuples for this stage.
570    """
571    stage_dir = features_dir / stage.value
572    if not stage_dir.exists():
573        return []
574    return [
575        (parse_feature(p, _folder_name_for(p, stage_dir)), stage)
576        for p in filesystem.list_feature_files(stage_dir)
577    ]
578
579
580def discover_feature_locations(
581    features_dir: Path,
582    filesystem: FileSystemProtocol,
583) -> list[tuple[ParsedFeature, FeatureStage]]:
584    """Discover all feature files and their stages.
585
586    Args:
587        features_dir: Root of the features directory.
588        filesystem: Filesystem adapter.
589
590    Returns:
591        List of (ParsedFeature, FeatureStage) tuples.
592    """
593    results: list[tuple[ParsedFeature, FeatureStage]] = []
594    for stage in _FEATURE_STAGES:
595        results.extend(_features_in_stage(stage, features_dir, filesystem))
596    return results
597
598
599def run_sync(
600    features_root: Path,
601    tests_root: Path,
602    filesystem: FileSystemProtocol | None = None,
603    stub_format: StubFormat = "functions",
604) -> list[str]:
605    """Sync test stubs from .feature files to the tests directory.
606
607    Args:
608        features_root: Root of the features directory (contains backlog/,
609            in-progress/, completed/).
610        tests_root: Root of the tests/features/ directory.
611        filesystem: Optional filesystem adapter. Defaults to _RealFileSystem.
612        stub_format: The output format for new stubs.
613
614    Returns:
615        List of action description strings.
616    """
617    if filesystem is None:
618        filesystem = _RealFileSystem()
619    actions: list[SyncAction] = []
620    feature_stage_pairs = discover_feature_locations(features_root, filesystem)
621    for feature, stage in feature_stage_pairs:
622        if stage == FeatureStage.COMPLETED:
623            actions.extend(_sync_completed_feature(feature, tests_root))
624        else:
625            actions.extend(_sync_active_feature(feature, tests_root, stub_format))
626    expected_locations = _build_expected_locations(feature_stage_pairs, tests_root)
627    actions.extend(_sync_non_conforming(tests_root, expected_locations, filesystem))
628    all_ids = _collect_all_ids(features_root, filesystem)
629    actions.extend(_sync_orphans(tests_root, all_ids, filesystem))
630    return [str(a) for a in actions]
class FileSystemProtocol(typing.Protocol):
40class FileSystemProtocol(Protocol):
41    """Protocol for filesystem operations needed by the sync engine."""
42
43    def list_feature_files(self, stage_dir: Path) -> list[Path]:  # pragma: no cover
44        """List all .feature files recursively under stage_dir."""
45        ...
46
47    def list_test_files(self, tests_dir: Path) -> list[Path]:  # pragma: no cover
48        """List all *_test.py files recursively under tests_dir."""
49        ...

Protocol for filesystem operations needed by the sync engine.

FileSystemProtocol(*args, **kwargs)
1960def _no_init_or_replace_init(self, *args, **kwargs):
1961    cls = type(self)
1962
1963    if cls._is_protocol:
1964        raise TypeError('Protocols cannot be instantiated')
1965
1966    # Already using a custom `__init__`. No need to calculate correct
1967    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1968    if cls.__init__ is not _no_init_or_replace_init:
1969        return
1970
1971    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1972    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1973    # searches for a proper new `__init__` in the MRO. The new `__init__`
1974    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1975    # instantiation of the protocol subclass will thus use the new
1976    # `__init__` and no longer call `_no_init_or_replace_init`.
1977    for base in cls.__mro__:
1978        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1979        if init is not _no_init_or_replace_init:
1980            cls.__init__ = init
1981            break
1982    else:
1983        # should not happen
1984        cls.__init__ = object.__init__
1985
1986    cls.__init__(self, *args, **kwargs)
def list_feature_files(self, stage_dir: pathlib._local.Path) -> list[pathlib._local.Path]:
43    def list_feature_files(self, stage_dir: Path) -> list[Path]:  # pragma: no cover
44        """List all .feature files recursively under stage_dir."""
45        ...

List all .feature files recursively under stage_dir.

def list_test_files(self, tests_dir: pathlib._local.Path) -> list[pathlib._local.Path]:
47    def list_test_files(self, tests_dir: Path) -> list[Path]:  # pragma: no cover
48        """List all *_test.py files recursively under tests_dir."""
49        ...

List all *_test.py files recursively under tests_dir.

@dataclass(frozen=True, slots=True)
class SyncResult:
79@dataclass(frozen=True, slots=True)
80class SyncResult:
81    """Result of a sync operation.
82
83    Attributes:
84        actions: Tuple of SyncAction objects describing what was done.
85    """
86
87    actions: tuple[SyncAction, ...]
88
89    @property
90    def is_noop(self) -> bool:
91        """Return True if no actions were taken."""
92        return len(self.actions) == 0

Result of a sync operation.

Attributes: actions: Tuple of SyncAction objects describing what was done.

SyncResult(actions: tuple[pytest_beehave.stub_writer.SyncAction, ...])
is_noop: bool
89    @property
90    def is_noop(self) -> bool:
91        """Return True if no actions were taken."""
92        return len(self.actions) == 0

Return True if no actions were taken.

def discover_feature_locations( features_dir: pathlib._local.Path, filesystem: FileSystemProtocol) -> list[tuple[pytest_beehave.feature_parser.ParsedFeature, pytest_beehave.models.FeatureStage]]:
581def discover_feature_locations(
582    features_dir: Path,
583    filesystem: FileSystemProtocol,
584) -> list[tuple[ParsedFeature, FeatureStage]]:
585    """Discover all feature files and their stages.
586
587    Args:
588        features_dir: Root of the features directory.
589        filesystem: Filesystem adapter.
590
591    Returns:
592        List of (ParsedFeature, FeatureStage) tuples.
593    """
594    results: list[tuple[ParsedFeature, FeatureStage]] = []
595    for stage in _FEATURE_STAGES:
596        results.extend(_features_in_stage(stage, features_dir, filesystem))
597    return results

Discover all feature files and their stages.

Args: features_dir: Root of the features directory. filesystem: Filesystem adapter.

Returns: List of (ParsedFeature, FeatureStage) tuples.

def run_sync( features_root: pathlib._local.Path, tests_root: pathlib._local.Path, filesystem: FileSystemProtocol | None = None, stub_format: StubFormat = 'functions') -> list[str]:
600def run_sync(
601    features_root: Path,
602    tests_root: Path,
603    filesystem: FileSystemProtocol | None = None,
604    stub_format: StubFormat = "functions",
605) -> list[str]:
606    """Sync test stubs from .feature files to the tests directory.
607
608    Args:
609        features_root: Root of the features directory (contains backlog/,
610            in-progress/, completed/).
611        tests_root: Root of the tests/features/ directory.
612        filesystem: Optional filesystem adapter. Defaults to _RealFileSystem.
613        stub_format: The output format for new stubs.
614
615    Returns:
616        List of action description strings.
617    """
618    if filesystem is None:
619        filesystem = _RealFileSystem()
620    actions: list[SyncAction] = []
621    feature_stage_pairs = discover_feature_locations(features_root, filesystem)
622    for feature, stage in feature_stage_pairs:
623        if stage == FeatureStage.COMPLETED:
624            actions.extend(_sync_completed_feature(feature, tests_root))
625        else:
626            actions.extend(_sync_active_feature(feature, tests_root, stub_format))
627    expected_locations = _build_expected_locations(feature_stage_pairs, tests_root)
628    actions.extend(_sync_non_conforming(tests_root, expected_locations, filesystem))
629    all_ids = _collect_all_ids(features_root, filesystem)
630    actions.extend(_sync_orphans(tests_root, all_ids, filesystem))
631    return [str(a) for a in actions]

Sync test stubs from .feature files to the tests directory.

Args: features_root: Root of the features directory (contains backlog/, in-progress/, completed/). tests_root: Root of the tests/features/ directory. filesystem: Optional filesystem adapter. Defaults to _RealFileSystem. stub_format: The output format for new stubs.

Returns: List of action description strings.