Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(fixtures): Replace fixture represenation with a class #12473

Open
wants to merge 35 commits into
base: main
Choose a base branch
from

Conversation

Glyphack
Copy link

@Glyphack Glyphack commented Jun 18, 2024

Closes #11525

During the sprint we discussed fixing the above issue and thought maybe it's a better idea to add a better represenation to fixtures. To do this without patching the object more, this PR refactors fixtures to have a class with attributes.
The main rational behind that is:

  • Now we have a type for fixtures makes it easier to check for fixture types and no need to perform cast on objects. Removed monkey patching.
  • They are no longer functions that carry a class within them.
  • When you decorate a function with a fixture it's not possible to call it anymore. So it makes more sense for it to not be a function or method.

Example

Previously we had:

===================================== short test summary info ======================================
FAILED tmp31nzhe4b.py::test_something - assert fixt == 42
======================================== 1 failed in 0.03s =========================================

where fixt is a pytest fixture function that is not replaced by it's value(directly used)

Now we print:

===================================== short test summary info ======================================
FAILED tmp31nzhe4b.py::test_something - assert pytest_fixture(<function fixt at *>) == 42 
======================================== 1 failed in 0.03s =========================================

@Glyphack Glyphack changed the title (assertion): Use repr for pytest fixtures and set repr for pytest fixtures (fixtures): Replace fixture represenation with a class Jun 20, 2024
@psf-chronographer psf-chronographer bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Jun 20, 2024
@Glyphack Glyphack force-pushed the reprs-assert-callable branch 3 times, most recently from bf90fca to d25a8d9 Compare June 20, 2024 13:51
testing/test_compat.py Outdated Show resolved Hide resolved
@Glyphack Glyphack marked this pull request as ready for review June 20, 2024 15:19
@Glyphack Glyphack force-pushed the reprs-assert-callable branch 2 times, most recently from 3d2ec33 to 84cc5b7 Compare July 4, 2024 18:48
@Glyphack
Copy link
Author

Glyphack commented Jul 4, 2024

Hey @The-Compiler @RonnyPfannschmidt I rebased the PR could you please take a look?

changelog/11525.improvement.rst Outdated Show resolved Hide resolved
src/_pytest/compat.py Outdated Show resolved Hide resolved
src/_pytest/compat.py Outdated Show resolved Hide resolved
src/_pytest/fixtures.py Outdated Show resolved Hide resolved
src/_pytest/fixtures.py Outdated Show resolved Hide resolved
@@ -478,12 +478,13 @@ def deco_mark():
def deco_fixture():
assert False

src = inspect.getsource(deco_fixture)
# Since deco_fixture is now an instance of FixtureFunctionDef the getsource function will not work on it.
with pytest.raises(Exception):
Copy link
Member

Choose a reason for hiding this comment

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

Could you assert against a more specific exception? As I understand, this is not testing that something went wrong but it's not clear what exactly. Also, it's usually a good idea to use the match= regex with this helper.

Copy link
Author

Choose a reason for hiding this comment

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

Yes will apply. This tries to document the behavior more than testing anything. Before introducing the FixtureFunctionDefinition it was possible to run inspect.getsource on fixtures but now it's not. This is just describing the behavior. I'm open to removing it as well.

Copy link
Author

Choose a reason for hiding this comment

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

I changed it to a more specific error. Do you think we can remove the test? This just tests that you cannot use inspect on fixture functions.

Copy link
Author

@Glyphack Glyphack Oct 23, 2024

Choose a reason for hiding this comment

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

I think we can remove this test. I removed it.

@Glyphack Glyphack force-pushed the reprs-assert-callable branch 3 times, most recently from f0324a5 to 9d2a916 Compare July 30, 2024 18:37
@webknjaz
Copy link
Member

Hey @Glyphack, could you resolve the merge conflicts / rebase this?

@Glyphack
Copy link
Author

Hey @Glyphack, could you resolve the merge conflicts / rebase this?

Yes will do today.

Copy link
Member

@RonnyPfannschmidt RonnyPfannschmidt left a comment

Choose a reason for hiding this comment

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

Iove where this is going

It opens up nice follow ups

new_obj = getattr(obj, "__pytest_wrapped__", None)
if isinstance(new_obj, _PytestWrapper):
obj = new_obj.obj
for _ in range(100):
Copy link
Member

Choose a reason for hiding this comment

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

I believe we can drop the loop as nesting is no longer available

Copy link
Author

Choose a reason for hiding this comment

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

You're correct. I removed the loop. We still need to keep the unwrapping behavior. Because we expect the function to also unwrap the decorated functions for example this test: https://github.com/Glyphack/pytest/blob/3d7416c2dd5dcf2d60a8b30636dcc90978c64215/testing/test_compat.py#L63

def get_real_func(obj):
"""Get the real function object of the (possibly) wrapped object by
functools.wraps or functools.partial."""
:func:`functools.wraps`, or :func:`functools.partial`, or :func:`pytest.fixture`."""
from _pytest.fixtures import FixtureFunctionDefinition
Copy link
Member

Choose a reason for hiding this comment

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

I believe we can drop fixture definition from that , we should pass the definitions attribute

Copy link
Author

@Glyphack Glyphack Oct 27, 2024

Choose a reason for hiding this comment

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

Are you suggesting to pass the FixtureFunctionDefinition object to this function? If I understand correctly this function is expected to take any object and return the real object according to this test testing the behavior:
https://github.com/Glyphack/pytest/blob/3d7416c2dd5dcf2d60a8b30636dcc90978c64215/testing/test_compat.py#L63

Copy link
Member

Choose a reason for hiding this comment

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

As fixture definitions are no longer wrapping functions into other functions

The suggestion is to no longer consider them wrappers and passing the function directly

Copy link
Author

@Glyphack Glyphack Nov 15, 2024

Choose a reason for hiding this comment

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

@RonnyPfannschmidt Sorry I didn't fully understand your comment.

The current unwrapping behavior here is only for two cases:

  1. The pytest fixture is applied on a function so I can display this case with Fixture(f)
  2. The pytest fixture is decorated so we have deco(Fixture(f))

The first case is handled by obj = obj._get_wrapped_function() line and second case is handled by obj = inspect.unwrap(obj, stop=lambda x: type(x) is FixtureFunctionDefinition).

If we drop any of them we cannot handle one of those two cases.

But I feel I am missing your point but unwrapping to me are these two lines.

Copy link
Member

Choose a reason for hiding this comment

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

Decorators cannot apply to a fixture definition

So that should trigger a error

It's wrong even now

Copy link
Author

@Glyphack Glyphack Nov 17, 2024

Choose a reason for hiding this comment

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

I was wrong about that but the behavior is still needed. For example this test fails if we don't do unwrapping in this function:
https://github.com/Glyphack/pytest/blob/a6318c944f907bc350922c3961365e9ecd8d3e81/testing/python/integration.py#L11

The reason is that getfslineno is using this function to unwrap a decorator:
https://github.com/Glyphack/pytest/blob/a6318c944f907bc350922c3961365e9ecd8d3e81/src/_pytest/_code/code.py#L1341

I think the solution is to make a separate function for the cases when we want to perform the unwrapping? What is your suggestion?

Copy link
Member

Choose a reason for hiding this comment

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

What I meant is that we no longer ought to pass fixture definition to anything that unwrapped

Definition is no longer a function

Perhaps we should drop the call altogether

Copy link
Author

Choose a reason for hiding this comment

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

Thanks I realized the reason my code required this check to be here was that I was getting objects using the fixture name instead of their actual name and mistakenly I calling this on non fixture objects. After fixing that I could remove the get_real_method and simplify this function.

src/_pytest/fixtures.py Show resolved Hide resolved
return fixture_definition


class FixtureFunctionDefinition:
Copy link
Member

Choose a reason for hiding this comment

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

We need to log a followup task for paramspec/return type annotation tracking and storing

Copy link
Author

Choose a reason for hiding this comment

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

Added. I just don't fully know what does this mean I added your comment but feel free to edit it with more context.

Copy link
Member

Choose a reason for hiding this comment

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

We should create an issue and reference it here, taking the opportunity of writing a rationale and what needs to be done in the issue.

@RonnyPfannschmidt can you do that, given you asked this originally and @Glyphack doesn't know exactly what you mean? I understand you mean for this to be a Generic which carries around the type information.

Copy link
Member

Choose a reason for hiding this comment

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

I gave this a try in #12999, so we don't need to create an issue after all in the end, we can continue on that PR once this one gets merged.

@Glyphack
Copy link
Author

Glyphack commented Oct 27, 2024

Thank you both for the review. I left some TODO comments I will address them in a follow up PR.

@RonnyPfannschmidt RonnyPfannschmidt added this to the 8.4 milestone Nov 12, 2024
Glyphack and others added 26 commits November 23, 2024 16:20
This is an internal class users don't need to know about it.
Co-authored-by: Sviatoslav Sydorenko (Святослав Сидоренко) <[email protected]>
Co-authored-by: Sviatoslav Sydorenko (Святослав Сидоренко) <[email protected]>
Co-authored-by: Sviatoslav Sydorenko (Святослав Сидоренко) <[email protected]>
Co-authored-by: Sviatoslav Sydorenko (Святослав Сидоренко) <[email protected]>
Copy link
Member

@nicoddemus nicoddemus left a comment

Choose a reason for hiding this comment

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

Overall great work!

I think this almost ready to merge, and we can improve on the typing later on #12999.

@@ -0,0 +1,3 @@
The fixtures are now represented as fixture in test output.
Copy link
Member

Choose a reason for hiding this comment

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

This sentence by itself, without more context, is confusing.

Perhaps:

Suggested change
The fixtures are now represented as fixture in test output.
Fixtures are now clearly represented in the output as a "fixture object", not as a normal function as before, making it easy for beginners to catch mistakes such as referencing a fixture declared in the same module but not requested in the test function.

Copy link
Member

Choose a reason for hiding this comment

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

(Might be too verbose still).

return fixture_definition


class FixtureFunctionDefinition:
Copy link
Member

Choose a reason for hiding this comment

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

We should create an issue and reference it here, taking the opportunity of writing a rationale and what needs to be done in the issue.

@RonnyPfannschmidt can you do that, given you asked this originally and @Glyphack doesn't know exactly what you mean? I understand you mean for this to be a Generic which carries around the type information.

function: Callable[..., Any],
fixture_function_marker: FixtureFunctionMarker,
instance: type | None = None,
):
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
):
) -> None:

self,
function: Callable[..., Any],
fixture_function_marker: FixtureFunctionMarker,
instance: type | None = None,
Copy link
Member

Choose a reason for hiding this comment

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

instance seems like it should be an object, not a type?

Suggested change
instance: type | None = None,
instance: object | None = None,

@@ -1271,7 +1279,7 @@ def fixture(
autouse: bool = ...,
ids: Sequence[object | None] | Callable[[Any], object | None] | None = ...,
name: str | None = None,
) -> FixtureFunctionMarker: ...
) -> FixtureFunctionDefinition: ...
Copy link
Member

Choose a reason for hiding this comment

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

Why did this type annotation changed? Seems like it should still return a marker if we are using the @fixture(...) syntax?

@@ -1223,19 +1195,19 @@ class FixtureFunctionMarker:
def __post_init__(self, _ispytest: bool) -> None:
check_ispytest(_ispytest)

def __call__(self, function: FixtureFunction) -> FixtureFunction:
def __call__(self, function: FixtureFunction) -> FixtureFunctionDefinition:
Copy link
Member

@nicoddemus nicoddemus Nov 26, 2024

Choose a reason for hiding this comment

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

I think we should get rid of FixtureFunction at this point...

Done that in #12999.

return fixture_definition


class FixtureFunctionDefinition:
Copy link
Member

Choose a reason for hiding this comment

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

I gave this a try in #12999, so we don't need to create an issue after all in the end, we can continue on that PR once this one gets merged.

Copy link
Member

@bluetech bluetech left a comment

Choose a reason for hiding this comment

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

I like this change, thanks!

I left some comments, please take a look.

instance: type | None = None,
):
self.name = fixture_function_marker.name or function.__name__
self.__name__ = self.name
Copy link
Member

Choose a reason for hiding this comment

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

Can you add a comment on this line, explaining what it does? IIUC, it's to match the __name__ attribute of function objects, but it's not obvious.

def __repr__(self) -> str:
return f"<pytest_fixture({self._fixture_function})>"

def __get__(self, instance, owner=None):
Copy link
Member

Choose a reason for hiding this comment

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

Can you add a comment explaining what does? IIUC, it makes the FixtureFunctionDefinition bind like a method, but it's not obvious so would be nice have a comment.

@@ -1352,7 +1360,7 @@ def fixture(
def yield_fixture(
fixture_function=None,
*args,
scope="function",
scope: _ScopeName = "function",
Copy link
Member

Choose a reason for hiding this comment

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

yield_fixture is a legacy thing and is intentionally not typed, so I would omit this change.

# TODO: paramspec/return type annotation tracking and storing
class FixtureFunctionDefinition:
def __init__(
self,
Copy link
Member

Choose a reason for hiding this comment

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

These days I prefer to force keyword arguments in such cases:

Suggested change
self,
self,
*,


# TODO: paramspec/return type annotation tracking and storing
class FixtureFunctionDefinition:
def __init__(
Copy link
Member

Choose a reason for hiding this comment

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

While this type is not public, it's seems like something that plugin might start to use directly. For this, we have an "opt in" mechanism which makes it clear the ctor is internal and can change between pytest versions without warning. The mechanism is to add a _ispytest: bool = False, parameter, always call with _ispytest=True inside of pytest, and issue a warning when it's False. There are many examples in the code you can copy from.

Comment on lines +3347 to +3353
@pytest.fixture
def fixture_function_def_test_method(self):
return self.i


@pytest.fixture
def fixture_function_def_test_func():
Copy link
Member

Choose a reason for hiding this comment

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

I would write tests that register fixtures as pytester tests, to avoid "polluting" the actual pytest-testsuite pytest session.

@@ -472,7 +473,7 @@ def _format_assertmsg(obj: object) -> str:

def _should_repr_global_name(obj: object) -> bool:
if callable(obj):
return False
return isinstance(obj, FixtureFunctionDefinition)
Copy link
Member

Choose a reason for hiding this comment

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

Can you add a comment on this line?

"https://docs.pytest.org/en/stable/deprecations.html#calling-fixtures-directly about how to update your code."
)

@functools.wraps(function)
Copy link
Member

Choose a reason for hiding this comment

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

The previous code used functools.wraps, while the new code does the wrapping "manually". But functools.wraps/functools.update_wrapper does some stuff like copy __doc__ and such. Are we losing that?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bot:chronographer:provided (automation) changelog entry is part of PR
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Show repr for callables used in an assert directly
5 participants