Fix fixture discovery to use definition order instead of alphabetical#14169
Fix fixture discovery to use definition order instead of alphabetical#14169veeceey wants to merge 5 commits intopytest-dev:mainfrom
Conversation
|
Hi maintainers, friendly ping on this PR. It's been open for about 10 days. I see the merge state is showing a conflict -- I'll rebase to resolve that. In the meantime, would appreciate any feedback on the approach (using |
Fixtures are now discovered in their definition order (as they appear in source code) rather than alphabetical order. This change resolves issues where fixtures with the same name at different scopes would be processed in unexpected order due to dir() sorting. The fix changes parsefactories() to use __dict__ iteration (which preserves insertion order in Python 3.7+) instead of dir() which sorts. This makes fixture behavior predictable based on source code order. Fixes pytest-dev#11281 Related to pytest-dev#12952
for more information, see https://pre-commit.ci
The previous implementation only looked at holderobj.__dict__ which is empty for new instances (e.g. unittest TestCase instances passed from unittest.py). This caused autouse fixtures defined on the class to not be discovered. Now properly handle three cases: - Modules: use module.__dict__ directly - Classes: walk __mro__ to get class and base class attributes - Instances: walk type(instance).__mro__ for class hierarchy Also add assert isinstance(holderobj, type) to satisfy mypy since safe_isclass() doesn't use TypeGuard.
- Add explicit type annotation `list[Mapping[str, Any]]` for dicts variable to fix mypy error about MappingProxyType vs dict mismatch (cls.__dict__ returns MappingProxyType for classes) - Fix test_autouse_fixtures_definition_order_preserved to use distinct fixture names and correct expectations (module + class fixtures both run, not just module)
8601e0f to
8fad347
Compare
The elif safe_isclass(holderobj) branch was unreachable in practice since parsefactories() is never called with a class directly (only modules and instances). Merged it with the instance branch by using holderobj_tp, which already resolves to the correct type for both classes and instances (set on line 1866-1869).
nicoddemus
left a comment
There was a problem hiding this comment.
Thanks @veeceey, please see my comments.
| # Use __dict__ to preserve definition order instead of dir() which sorts. | ||
| # This ensures fixtures are processed in their definition order, which is | ||
| # important for autouse fixtures and fixture overriding (#11281, #12952). | ||
| # | ||
| # For modules: module.__dict__ contains all module-level names. | ||
| # For classes/instances: walk the MRO to get all class and base class | ||
| # attributes (using holderobj_tp which resolves to the class in both cases). | ||
| dicts: list[Mapping[str, Any]] | ||
| if isinstance(holderobj, types.ModuleType): | ||
| dicts = [holderobj.__dict__] | ||
| else: | ||
| # For classes and instances: walk the MRO to get all attributes. | ||
| # For classes, holderobj_tp is the class itself; for instances, | ||
| # holderobj_tp is type(holderobj) (set above on line 1867). | ||
| # In both cases holderobj_tp is a type with __mro__. | ||
| assert isinstance(holderobj_tp, type) | ||
| dicts = [cls.__dict__ for cls in holderobj_tp.__mro__] | ||
|
|
||
| seen: set[str] = set() | ||
| for dic in dicts: | ||
| for name in dic: | ||
| if name in seen: | ||
| continue | ||
| seen.add(name) |
There was a problem hiding this comment.
Could you please extract this logic into its own function? That would make it easier to understand and allow to add a unit test to ensure the desired behavior in isolation.
| result.assert_outcomes(passed=1) | ||
|
|
||
|
|
||
| def test_autouse_fixtures_definition_order_preserved(pytester: Pytester) -> None: |
There was a problem hiding this comment.
These 2 tests pass for me on main, so they are not really testing the underlying issue. Could you please review them?
| holderobj_tp = holderobj | ||
|
|
||
| self._holderobjseen.add(holderobj) | ||
| for name in dir(holderobj): |
There was a problem hiding this comment.
I'm not sure the fix is correct: when discovering the fixtures for a module, we holderobj will be a Module. We then find a test class, we will collect it in a later call, where holderobj will be the class itself.
So I don't think the order returned by dir is not related to the original issue.
Summary
Fixes #11281
This PR changes fixture discovery to preserve definition order instead of using alphabetical sorting from
dir().Problem
Previously,
parsefactories()useddir()to iterate over object attributes, which sorts names alphabetically. This caused unexpected behavior when fixtures with the same name were defined at different scopes (e.g., module vs class level), because the order of discovery depended on alphabetical sorting rather than source code order.Solution
Changed
parsefactories()to use__dict__iteration (similar to howPyCollector.collect()works), which preserves insertion/definition order in Python 3.7+. This ensures fixtures are processed in the order they appear in source code, making behavior predictable and intuitive.Changes
FixtureManager.parsefactories()to iterate__dict__instead ofdir()PyCollector)Test plan