|
29 | 29 |
|
30 | 30 | REPORT_CONTEXT: Any = None |
31 | 31 |
|
| 32 | +_PARAMETRIZE_PATH_KEY = pytest.StashKey[tuple[str, ...]]() |
| 33 | +# Each frame: (path_key, open step). Frames are shared across sibling test items |
| 34 | +# and drained at module-substep teardown / session end. Entries may be either a |
| 35 | +# real ``NewStep`` (online/offline) or a ``_NoopStep`` (disabled mode). |
| 36 | +_PARAMETRIZE_STACK: list[tuple[str, Any]] = [] |
| 37 | + |
| 38 | + |
| 39 | +def _drain_parametrize_stack() -> None: |
| 40 | + while _PARAMETRIZE_STACK: |
| 41 | + _, ns = _PARAMETRIZE_STACK.pop() |
| 42 | + ns.__exit__(None, None, None) |
| 43 | + |
| 44 | + |
| 45 | +def _build_parametrize_path(item: pytest.Item) -> tuple[str, ...]: |
| 46 | + """Outer-to-inner step display names for a parametrized item. |
| 47 | +
|
| 48 | + Pytest stores ``callspec.params`` with the BOTTOM decorator's axis first; |
| 49 | + the Sift step tree treats the TOP decorator as outermost, so we reverse. |
| 50 | + """ |
| 51 | + callspec = getattr(item, "callspec", None) |
| 52 | + if callspec is None or not callspec.params: |
| 53 | + return () |
| 54 | + originalname = getattr(item, "originalname", item.name) |
| 55 | + frames: list[str] = [originalname] |
| 56 | + for name, value in reversed(callspec.params.items()): |
| 57 | + frames.append(f"{name}={value!r}") |
| 58 | + return tuple(frames) |
| 59 | + |
32 | 60 |
|
33 | 61 | @dataclass(frozen=True) |
34 | 62 | class _Option: |
@@ -175,6 +203,18 @@ def pytest_configure(config: pytest.Config) -> None: |
175 | 203 | ) |
176 | 204 |
|
177 | 205 |
|
| 206 | +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: |
| 207 | + """Stash each item's parametrize path and cluster siblings by shared prefix.""" |
| 208 | + for item in items: |
| 209 | + item.stash[_PARAMETRIZE_PATH_KEY] = _build_parametrize_path(item) |
| 210 | + items.sort(key=lambda i: i.stash[_PARAMETRIZE_PATH_KEY]) |
| 211 | + |
| 212 | + |
| 213 | +def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: |
| 214 | + """Drain any parametrize parents still open (e.g. when module_substep was gated off).""" |
| 215 | + _drain_parametrize_stack() |
| 216 | + |
| 217 | + |
178 | 218 | def _is_offline(pytestconfig: pytest.Config | None) -> bool: |
179 | 219 | return bool(_option_or_ini(pytestconfig, _OFFLINE)) |
180 | 220 |
|
@@ -563,24 +603,75 @@ def report_context( |
563 | 603 | def _step_impl( |
564 | 604 | report_context: ReportContext | _NoopReportContext, request: pytest.FixtureRequest |
565 | 605 | ) -> Generator[NewStep | _NoopStep, None, None]: |
566 | | - name = str(request.node.name) |
567 | | - existing_docstring = request.node.obj.__doc__ or None |
| 606 | + node = request.node |
| 607 | + # Items get a parametrize path stashed in ``pytest_collection_modifyitems``; |
| 608 | + # modules/other nodes fall back to their node name. The leaf frame |
| 609 | + # (``path[-1]``) is the test-specific display name — parents are opened |
| 610 | + # by ``_parametrize_parents``. |
| 611 | + path = node.stash.get(_PARAMETRIZE_PATH_KEY, ()) |
| 612 | + name = path[-1] if path else str(node.name) |
| 613 | + existing_docstring = node.obj.__doc__ or None |
568 | 614 | with report_context.new_step( |
569 | 615 | name=name, description=existing_docstring, assertion_as_fail_not_error=False |
570 | 616 | ) as new_step: |
571 | 617 | yield new_step |
572 | | - if hasattr(request.node, "rep_call") and request.node.rep_call.excinfo: |
| 618 | + if hasattr(node, "rep_call") and node.rep_call.excinfo: |
573 | 619 | new_step.update_step_from_result( |
574 | | - request.node.rep_call.excinfo, |
575 | | - request.node.rep_call.excinfo.value, |
576 | | - request.node.rep_call.excinfo.tb, |
| 620 | + node.rep_call.excinfo, |
| 621 | + node.rep_call.excinfo.value, |
| 622 | + node.rep_call.excinfo.tb, |
577 | 623 | ) |
578 | 624 |
|
579 | 625 |
|
| 626 | +@pytest.fixture(autouse=True) |
| 627 | +def _parametrize_parents( |
| 628 | + request: pytest.FixtureRequest, |
| 629 | + pytestconfig: pytest.Config, |
| 630 | +) -> None: |
| 631 | + """Open/close shared parametrize parent steps for the current item. |
| 632 | +
|
| 633 | + Diffs the item's desired parametrize path against the open stack: pops the |
| 634 | + stale tail, then opens new parents (everything except the innermost frame — |
| 635 | + the ``step`` fixture creates that as the leaf). Parents persist across |
| 636 | + sibling items so a tree like ``test_x[a=1]`` / ``test_x[a=2]`` shares one |
| 637 | + ``test_x`` container. |
| 638 | +
|
| 639 | + Gated off when the current item is excluded so that excluded items don't |
| 640 | + eagerly request ``report_context`` (which would defeat its lazy creation). |
| 641 | + Any parents still open at the end of a module are drained by |
| 642 | + ``module_substep`` teardown; anything left at session end is drained by |
| 643 | + ``pytest_sessionfinish``. |
| 644 | + """ |
| 645 | + default = bool(_option_or_ini(pytestconfig, _AUTOUSE)) |
| 646 | + if not _sift_enabled_for(request.node, default): |
| 647 | + return None |
| 648 | + desired = request.node.stash.get(_PARAMETRIZE_PATH_KEY, ()) |
| 649 | + parents = desired[:-1] |
| 650 | + common = 0 |
| 651 | + while ( |
| 652 | + common < len(_PARAMETRIZE_STACK) |
| 653 | + and common < len(parents) |
| 654 | + and _PARAMETRIZE_STACK[common][0] == parents[common] |
| 655 | + ): |
| 656 | + common += 1 |
| 657 | + while len(_PARAMETRIZE_STACK) > common: |
| 658 | + _, ns = _PARAMETRIZE_STACK.pop() |
| 659 | + ns.__exit__(None, None, None) |
| 660 | + if not parents[common:]: |
| 661 | + return None |
| 662 | + rc = request.getfixturevalue("report_context") |
| 663 | + for display in parents[common:]: |
| 664 | + ns = rc.new_step(name=display, assertion_as_fail_not_error=False) |
| 665 | + ns.__enter__() |
| 666 | + _PARAMETRIZE_STACK.append((display, ns)) |
| 667 | + return None |
| 668 | + |
| 669 | + |
580 | 670 | @pytest.fixture(autouse=True) |
581 | 671 | def step( |
582 | 672 | request: pytest.FixtureRequest, |
583 | 673 | pytestconfig: pytest.Config, |
| 674 | + _parametrize_parents: None, |
584 | 675 | ) -> Generator[NewStep | _NoopStep | None, None, None]: |
585 | 676 | """Create an outer step for the function when the Sift gate is on. |
586 | 677 |
|
@@ -618,7 +709,18 @@ def module_substep( |
618 | 709 | yield None |
619 | 710 | return |
620 | 711 | rc = request.getfixturevalue("report_context") |
621 | | - yield from _step_impl(rc, request) |
| 712 | + gen = _step_impl(rc, request) |
| 713 | + new_step = next(gen) |
| 714 | + try: |
| 715 | + yield new_step |
| 716 | + finally: |
| 717 | + # Drain parametrize parents nested under this module step before it |
| 718 | + # exits — ReportContext.exit_step asserts the module step is the top. |
| 719 | + _drain_parametrize_stack() |
| 720 | + try: |
| 721 | + next(gen) |
| 722 | + except StopIteration: |
| 723 | + pass |
622 | 724 |
|
623 | 725 |
|
624 | 726 | @pytest.fixture(scope="session") |
|
0 commit comments