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

Save teardown exceptions for further teardowns #13002

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

Conversation

Myp3a
Copy link

@Myp3a Myp3a commented Nov 27, 2024

This PR adds an ability to access the exceptions that happened during teardown in other teardowns.

Motivation: some plugins (playwright-pytest) must rely on current teardown status to properly execute their hooks. Currently the teardown information isn't available, so it must be extracted with different workarounds.

Doesn't change functionality, only exposes the local variable on Node object.

Allows proper implementation of microsoft/playwright-pytest#207, not relying on traceback hacks but getting the information directly.

Copy link

@storenth storenth left a comment

Choose a reason for hiding this comment

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

LGTM. Possible resolved: #9909 and #12163

Copy link
Member

@webknjaz webknjaz left a comment

Choose a reason for hiding this comment

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

FWIW such changes must be always accompanied by respective tests.

@@ -214,6 +214,9 @@ def __init__(
# Deprecated alias. Was never public. Can be removed in a few releases.
self._store = self.stash

#: A list of exceptions that happened during teardown
self.teardown_exceptions: list[BaseException] = []
Copy link
Member

Choose a reason for hiding this comment

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

Does this need to be a list and not an exception group?

Copy link
Member

Choose a reason for hiding this comment

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

It's better to use a list then pass the list to the exception group, you can't append things to a group

Copy link
Member

Choose a reason for hiding this comment

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

Ah, I didn't realize they were immutable..

Copy link

@storenth storenth Nov 27, 2024

Choose a reason for hiding this comment

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

Guys, where to put tests? Can u suggest appropriate place? Looks like: testing/test_collection.py and teardown?

Choose a reason for hiding this comment

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

Copy link
Author

Choose a reason for hiding this comment

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

Merged, thank you. Now we have a test to check if the fixture fails and exception is added to the list

@storenth
Copy link

storenth commented Dec 5, 2024

Looks like we need reviewers: @nicoddemus @daara-s @graingert @Zac-HD @bluetech @jakkdl could you please review this improvement? thanks)

Copy link
Contributor

@daara-s daara-s left a comment

Choose a reason for hiding this comment

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

LGTM - just a small change in the test and needs a changelog (see contributing docs)

def pytest_exception_interact(node, call, report):
sys.stderr.write("{}".format(node.teardown_exceptions))

import pytest
Copy link
Contributor

Choose a reason for hiding this comment

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

minor - importing pytest twice in this conftest block

Copy link
Member

@jakkdl jakkdl left a comment

Choose a reason for hiding this comment

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

seems fine, just a couple small things.

Comment on lines +550 to +556
if len(node.teardown_exceptions) == 1:
exceptions.extend(node.teardown_exceptions)
elif node.teardown_exceptions:
msg = f"errors while tearing down {node!r}"
exceptions.append(BaseExceptionGroup(msg, these_exceptions[::-1]))
exceptions.append(
BaseExceptionGroup(msg, node.teardown_exceptions[::-1])
)
Copy link
Member

Choose a reason for hiding this comment

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

This is perhaps something for another PR (or too late/not worth changing), but from trio's experience with strict_exception_groups it's generally a bad design philosophy to sometimes return an exception group. This leads users to write code where they assume there's only ever one or no exceptions, and everything breaks when >1 exceptions happen to occur.
Maybe it's less of an issue here, given that we're not raising the exceptions, but I could see this complicating parsing logic nevertheless.

Copy link
Author

Choose a reason for hiding this comment

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

We're raising them a few lines later, after all teardowns. However, this is a design change, and probably should be done in another PR

these_exceptions = []
node.teardown_exceptions = []
Copy link
Member

Choose a reason for hiding this comment

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

does this need to reinitialize the list? Shouldn't that already be done in Node.__init__?

Copy link
Author

Choose a reason for hiding this comment

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

As there's no other code interacting with this list, you are correct - this is not really needed

Comment on lines 1595 to 1612
def pytest_exception_interact(node, call, report):
sys.stderr.write("{}".format(node.teardown_exceptions))

import pytest
@pytest.fixture
def mylist():
yield
raise AssertionError(111)
""",
test_file="""
def test_func(mylist):
assert True
""",
)
result = pytester.runpytest()
assert result.ret == ExitCode.TESTS_FAILED
assert "AssertionError(111)" in result.stderr.str()
result.stdout.fnmatch_lines(["*1 error*"])
Copy link
Member

Choose a reason for hiding this comment

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

this looks a bit unreliable to me, assert "AssertionError(111)" in result.stderr.str() could plausibly pass for other reasons than pytest_exception_interact printing it. Printing some marker characters surrounding it should suffice though, e.g. assert "node.teardown_exceptions: `AssertionError(111)`" in result.stderr.str().

It's also better to use result.assert_outcomes(errors=1) than fnmatch'ing lines

Copy link

Choose a reason for hiding this comment

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

I've put this stuff because there is unresolved one #9909
btw @jakkdl please, share you mind on that: a8e0aba#diff-c2ee4e81db455299781df03980cd131ee4680fa5b6e5e484f3355dbeef8452c6R1611

Copy link
Member

Choose a reason for hiding this comment

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

ah, should add a comment referring to #9909 for why there's both a pass and a fail to the test.
I don't think I have anything to add to the discussion in #9909 though

Copy link

Choose a reason for hiding this comment

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

Nothing to comment, the PR doesn't touch the pytest architecture.

@psf-chronographer psf-chronographer bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Dec 5, 2024
@jakkdl
Copy link
Member

jakkdl commented Dec 5, 2024

oh actually, probably nice to add to the comment that it's only saved for the sake of external post-teardown inspection; and not required internally. If there's any docs for the Node type (that's not just autogenerated) they might also need updating

@storenth
Copy link

storenth commented Dec 8, 2024

Guys, are we waiting for something?) Looks like we did all stuff from reviewers

@jakkdl
Copy link
Member

jakkdl commented Dec 9, 2024

Guys, are we waiting for something?) Looks like we did all stuff from reviewers

it's usually good habit to wait a couple days to see if anybody else wants to review, though this is minor enough that I feel comfortable merging it soon

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 looks good, I wonder though if keeping those exceptions around might cause more memory to be used than expected?

Perhaps not given those are only really related to exceptions during teardown...

An alternative for this, in case this is a problem, would be to implement a new hook, and call that hook passing the node and exception (or exceptions?), instead of storing them in the Node.

I'm not sure if this is needed, just food for thought in case storing the exceptions is something we should be more careful about.

@@ -0,0 +1 @@
Teardown fixtures now can access the information about current teardown exceptions in `node.teardown_exceptions`.
Copy link
Member

@nicoddemus nicoddemus Dec 9, 2024

Choose a reason for hiding this comment

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

This is a bit confusing as there are no "teardown fixtures", perhaps:

Suggested change
Teardown fixtures now can access the information about current teardown exceptions in `node.teardown_exceptions`.
Fixtures now can access the information about current teardown exceptions in `node.teardown_exceptions` during their own teardowns.

@graingert
Copy link
Member

graingert commented Dec 9, 2024

I wonder though if keeping those exceptions around might cause more memory to be used than expected?

Keeping the exceptions alive keeps the tracebacks alive which keeps all the locals in all the frames alive

@nicoddemus
Copy link
Member

nicoddemus commented Dec 9, 2024

Keeping the exceptions alive keeps the tracebacks alive which keeps all the locals in all the frames alive

Indeed, that's why I'm bringing this up.

Initially I was thinking this might not be a big problem, but actually this might really be catastrophic in case you have a fixture which is used a lot (say autouse), which then would fail for every test onward and store a lot of data in memory, possibly causing the machine to run out of memory and crashing, instead of being able to report the problem...

To be safe it is probably best to introduce a new hook, which would circumvent the problem and also allow plugins to handle the exceptions as they appear (possibly handling them immediately or storing them for later, depending on their need).

@storenth
Copy link

storenth commented Dec 15, 2024

To be safe it is probably best to introduce a new hook...

nicoddemus agree, but, what if we rewrite self.teardown_exceptions to use it as iterator? But, if no chance, do you prefer we refactor to handle hook based on this PR or create another?

@nicoddemus
Copy link
Member

what if we rewrite self.teardown_exceptions to use it as iterator?

What do you mean? Doesn't that require storing the exceptions anyway, even if we deliver those later as an iterator?

But, if no chance, do you prefer we refactor to handle hook based on this PR or create another?

Might be better to create a new one, and close this one but keeping it on the repository for historical reasons.

@storenth
Copy link

What do you mean? Doesn't that require storing the exceptions anyway, even if we deliver those later as an iterator?

What about a hook? Doesn't it meanning we need to store same thing but in another place? Confused a little bit.

@nicoddemus
Copy link
Member

nicoddemus commented Dec 21, 2024

What about a hook? Doesn't it meanning we need to store same thing but in another place? Confused a little bit.

No, using a hook, instead of storing the exception like this:

try: 
    fin()
except TEST_OUTCOME as e:
    node.teardown_exceptions.append(e)

We just call the hook at that point, passing the exception:

try: 
    fin()
except TEST_OUTCOME as e:
    self.config.ihook.pytest_handle_teardown_exception(item=item, e=e)

So pytest itself will no longer long term store the tracebacks, and whoever implements that hook can decide what to do (they might want to store the tracebacks anyway, but then it is the plugin's decision).

Of course we will need to restore the these_exceptions variable in order to raise the ExceptionGroup, however this will be local and not so much of a problem (just as before).

Using a hook like this to forward information to plugins is central to pytest's design, and used a lot: for example test reports are not stored anywhere, they are passed to hooks, which are then processed by plugins, like the terminal and junitxml.

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.

7 participants