From 7ca652f26a939c435f83bef9a02f2830153b1a65 Mon Sep 17 00:00:00 2001 From: Enes Uygur Date: Fri, 23 Oct 2026 18:50:54 +0300 Subject: [PATCH 1/3] fix(pytest): make assert_trace tell apart functions with the same decorator Functions wrapped by the same decorator share one code object, so assert_trace matched a container created by any of them. Also compare closure variables of the frame with the searched function's closure. Fixes #876 --- CHANGELOG.md | 3 + returns/contrib/pytest/plugin.py | 19 ++++++- .../test_pytest/test_plugin_has_trace.py | 56 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 448b1b7e4..8e9e41d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ See [0Ver](https://0ver.org/). `Coroutine` to `Awaitable`, so plain `async def` functions wrapping another awaitable (instead of being coroutine functions themselves) type-check correctly +- Fixes `assert_trace` from our `pytest` plugin passing when the container + was created by a different function that uses the same decorator, + see #876 ## 0.29.0 diff --git a/returns/contrib/pytest/plugin.py b/returns/contrib/pytest/plugin.py index cf8413b8c..b7eecc588 100644 --- a/returns/contrib/pytest/plugin.py +++ b/returns/contrib/pytest/plugin.py @@ -101,12 +101,27 @@ def _trace_function( ) if is_desired_type_call: current_call_stack = inspect.stack() - function_to_search_code = getattr(function_to_search, '__code__', None) for frame_info in current_call_stack: - if function_to_search_code is frame_info.frame.f_code: + if _is_function_frame(function_to_search, frame_info.frame): raise _DesiredFunctionFound +def _is_function_frame(function: _FunctionType, frame: FrameType) -> bool: + function_code = getattr(function, '__code__', None) + if frame.f_code is not function_code: + return False + + # Functions created by the same decorator share the same code object, + # so we also compare their closure variables to tell them apart: + frame_locals = frame.f_locals + closure = getattr(function, '__closure__', None) or () + return all( + frame_locals[name] is cell.cell_contents + for name, cell in zip(function_code.co_freevars, closure, strict=True) + if name in frame_locals + ) + + class _DesiredFunctionFound(BaseException): # noqa: WPS418 """Exception to raise when expected function is found.""" diff --git a/tests/test_contrib/test_pytest/test_plugin_has_trace.py b/tests/test_contrib/test_pytest/test_plugin_has_trace.py index 8cc4d26b4..9fde52cfc 100644 --- a/tests/test_contrib/test_pytest/test_plugin_has_trace.py +++ b/tests/test_contrib/test_pytest/test_plugin_has_trace.py @@ -22,6 +22,23 @@ def _safe_decorated_function(*, return_failure: bool = False): raise ValueError('Function failed') +def _same_decorator(function): + def decorator(*args, **kwargs): + return function(*args, **kwargs) + + return decorator + + +@_same_decorator +def _first_decorated_function(container_type, container_value): + return container_type(container_value) + + +@_same_decorator +def _second_decorated_function(container_type, container_value): + return container_type(container_value) + + @pytest.mark.parametrize( 'container_type', [ # noqa: WPS118 @@ -113,3 +130,42 @@ def test_safe_decorated_assert(container_type, returns: ReturnsAsserts): """Test if our plugin will catch containers from @safe-wrapped functions.""" with returns.assert_trace(container_type, _safe_decorated_function): _safe_decorated_function(return_failure=container_type is Failure) + + +@pytest.mark.parametrize( + 'container_type', + [ # noqa: WPS118 + Success, + Failure, + IOSuccess, + IOFailure, + ], +) +def test_same_decorator_assert_trace( + container_type, + returns: ReturnsAsserts, +): + """Test if our plugin will catch containers from the decorated function.""" + with returns.assert_trace(container_type, _first_decorated_function): + _first_decorated_function(container_type, 1) + + +@pytest.mark.parametrize( + 'container_type', + [ # noqa: WPS118 + Success, + Failure, + IOSuccess, + IOFailure, + ], +) +def test_same_decorator_failed_assert_trace( + container_type, + returns: ReturnsAsserts, +): + """Test if our plugin will ignore other functions with same decorator.""" + with ( + pytest.raises(pytest.fail.Exception), + returns.assert_trace(container_type, _first_decorated_function), + ): + _second_decorated_function(container_type, 1) From a191a574ca065d8777828db46817dac3000ebc1c Mon Sep 17 00:00:00 2001 From: Enes Uygur Date: Sun, 25 Oct 2026 21:53:48 +0300 Subject: [PATCH 2/3] test(pytest): check assert_trace never calls __eq__ on closure variables --- .../test_pytest/test_plugin_has_trace.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/test_contrib/test_pytest/test_plugin_has_trace.py b/tests/test_contrib/test_pytest/test_plugin_has_trace.py index 9fde52cfc..467cf8dbe 100644 --- a/tests/test_contrib/test_pytest/test_plugin_has_trace.py +++ b/tests/test_contrib/test_pytest/test_plugin_has_trace.py @@ -39,6 +39,46 @@ def _second_decorated_function(container_type, container_value): return container_type(container_value) +class _NotComparable: + """Object that must only be compared by identity.""" + + def __eq__(self, other): + raise AssertionError('__eq__ must not be called') + + def __hash__(self): + raise AssertionError('__hash__ must not be called') + + def __bool__(self): + raise AssertionError('__bool__ must not be called') + + +def _not_comparable_decorator(function): + closure_value = _NotComparable() + + def decorator(*args, **kwargs): + return function(closure_value, *args, **kwargs) + + return decorator + + +@_not_comparable_decorator +def _first_not_comparable_function( + closure_value, + container_type, + container_value, +): + return container_type(container_value) + + +@_not_comparable_decorator +def _second_not_comparable_function( + closure_value, + container_type, + container_value, +): + return container_type(container_value) + + @pytest.mark.parametrize( 'container_type', [ # noqa: WPS118 @@ -169,3 +209,42 @@ def test_same_decorator_failed_assert_trace( returns.assert_trace(container_type, _first_decorated_function), ): _second_decorated_function(container_type, 1) + + +@pytest.mark.parametrize( + 'container_type', + [ # noqa: WPS118 + Success, + Failure, + IOSuccess, + IOFailure, + ], +) +def test_identity_closure_assert_trace( + container_type, + returns: ReturnsAsserts, +): + """Test that closure variables are compared by identity only.""" + with returns.assert_trace(container_type, _first_not_comparable_function): + _first_not_comparable_function(container_type, 1) + + +@pytest.mark.parametrize( + 'container_type', + [ # noqa: WPS118 + Success, + Failure, + IOSuccess, + IOFailure, + ], +) +def test_identity_closure_failed_assert_trace( + container_type, + returns: ReturnsAsserts, +): + """Test that other closure variables are not compared by ``__eq__``.""" + with ( + pytest.raises(pytest.fail.Exception), + returns.assert_trace(container_type, _first_not_comparable_function), + ): + _second_not_comparable_function(container_type, 1) From f88178422454e378545f32ca2666a3d07fc024a3 Mon Sep 17 00:00:00 2001 From: Enes Uygur Date: Sun, 25 Oct 2026 22:14:04 +0300 Subject: [PATCH 3/3] perf(pytest): skip closure comparison for functions without a closure --- returns/contrib/pytest/plugin.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/returns/contrib/pytest/plugin.py b/returns/contrib/pytest/plugin.py index b7eecc588..d0e666cd2 100644 --- a/returns/contrib/pytest/plugin.py +++ b/returns/contrib/pytest/plugin.py @@ -111,10 +111,15 @@ def _is_function_frame(function: _FunctionType, frame: FrameType) -> bool: if frame.f_code is not function_code: return False + closure = getattr(function, '__closure__', None) + if not closure: + # Fast path: without a closure the code object is enough, + # so we don't touch the frame's locals at all: + return True + # Functions created by the same decorator share the same code object, # so we also compare their closure variables to tell them apart: frame_locals = frame.f_locals - closure = getattr(function, '__closure__', None) or () return all( frame_locals[name] is cell.cell_contents for name, cell in zip(function_code.co_freevars, closure, strict=True)