Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions returns/contrib/pytest/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,32 @@ 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

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have a fast path here? what if they are not? comparing everything is slow and can cause different side-effects and other problems.

# so we also compare their closure variables to tell them apart:
frame_locals = frame.f_locals
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."""

Expand Down
135 changes: 135 additions & 0 deletions tests/test_contrib/test_pytest/test_plugin_has_trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,63 @@ 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)


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
Expand Down Expand Up @@ -113,3 +170,81 @@ 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)


@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)
Loading