Coverage for pytest_beehave/sync_engine.py: 100%

158 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2026-04-21 04:49 +0000

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]