-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
use callable protocols for pytest.skip/exit/fail/xfail instead of _WithException wrapper with __call__ attribute #13445
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
Conversation
a0735e1
to
58892a3
Compare
Thanks! This looks good to me. But looking at this again, I wonder if it wouldn't be simpler (in terms of complexity, not in terms of lines of code) to turn these functions into callable classes? Then the |
Forgot to mention, the CI failures are unrelated and fixed in main -- rebase should take care of it. |
Hi @bluetech -- sorry, just got around to try this, did on a separate branch (just on Something like this (If I understood your suggestion correctly)
This generally works, and seems to result in correct runtime/typecheck time types. However it fails this test: pytest/testing/python/collect.py Lines 1075 to 1081 in 9e9633d
Which kinda makes sense -- python would normally print just Apart from that, the remaining asserts pass if I change the assert to So up to you if you'd prefer me to convert the rest ( |
Hi @bluetech -- any thoughts on this? :) |
This is a more canonical way of typing generic callbacks/decorators (see https://mypy.readthedocs.io/en/stable/protocols.html#callback-protocols) This helps with potential issues/ambiguity with `__call__` semanics in type checkers -- according to python spec, `__call__` needs to be present on the class, rather than as an instance attribute to be considered callable. This worked in mypy because it didn't handle the spec 100% correctly (in this case this has no negative consequences) However, `ty` type checker is stricter/more correct about it and every `pytest.skip` usage results in: `error[call-non-callable]: Object of type `_WithException[Unknown, <class 'Skipped'>]` is not callable` For more context, see: - astral-sh/ruff#17832 (comment) - https://discuss.python.org/t/when-should-we-assume-callable-types-are-method-descriptors/92938 Testing: Tested with running mypy against the following snippet: ``` import pytest reveal_type(pytest.skip) reveal_type(pytest.skip.Exception) reveal_type(pytest.skip(reason="whatever")) ``` Before the change: ``` test_pytest_skip.py:2: note: Revealed type is "_pytest.outcomes._WithException[def (reason: builtins.str =, *, allow_module_level: builtins.bool =) -> Never, def (msg: Union[builtins.str, None] =, pytrace: builtins.bool =, allow_module_level: builtins.bool =, *, _use_item_location: builtins.bool =) -> _pytest.outcomes.Skipped]" test_pytest_skip.py:3: note: Revealed type is "def (msg: Union[builtins.str, None] =, pytrace: builtins.bool =, allow_module_level: builtins.bool =, *, _use_item_location: builtins.bool =) -> _pytest.outcomes.Skipped" test_pytest_skip.py:4: note: Revealed type is "Never" ``` After the change: ``` test_pytest_skip.py:2: note: Revealed type is "_pytest.outcomes._WithException[[reason: builtins.str =, *, allow_module_level: builtins.bool =], Never, def (msg: Union[builtins.str, None] =, pytrace: builtins.bool =, allow_module_level: builtins.bool =, *, _use_item_location: builtins.bool =) -> _pytest.outcomes.Skipped]" test_pytest_skip.py:3: note: Revealed type is "def (msg: Union[builtins.str, None] =, pytrace: builtins.bool =, allow_module_level: builtins.bool =, *, _use_item_location: builtins.bool =) -> _pytest.outcomes.Skipped" test_pytest_skip.py:4: note: Revealed type is "Never" ``` All types are matching and propagated correctly.
befc88e
to
da59835
Compare
I rebased your current code and added a changelog to make things green in case we want to go with that.
Yes.
The purpose of the test is to test that the frame is hidden, so it's OK that the name is changed. The test can be fixed like this: - assert excinfo.traceback[-1].frame.code.name == "skip"
+ if sys.version_info >= (3, 11):
+ assert excinfo.traceback[-1].frame.code.raw.co_qualname == "_Skip.__call__" (there's probably a way to get the qualified name before py311 but wouldn't bother) One thing I was concerned about is sphinx autodoc not understanding the The So I think the |
…thException wrapper with __call__ attribute This is a more canonical way of typing generic callbacks/decorators (see https://mypy.readthedocs.io/en/stable/protocols.html#callback-protocols) This helps with potential issues/ambiguity with `__call__` semanics in type checkers -- according to python spec, `__call__` needs to be present on the class, rather than as an instance attribute to be considered callable. This worked in mypy because it didn't handle the spec 100% correctly (in this case this has no negative consequences) However, `ty` type checker is stricter/more correct about it and every `pytest.skip` usage results in: `error[call-non-callable]: Object of type `_WithException[Unknown, <class 'Skipped'>]` is not callable` For more context, see: - astral-sh/ruff#17832 (comment) - https://discuss.python.org/t/when-should-we-assume-callable-types-are-method-descriptors/92938 Testing: Tested with running mypy against the following snippet: ``` import pytest reveal_type(pytest.skip) reveal_type(pytest.skip.Exception) reveal_type(pytest.skip(reason="whatever")) ``` Before the change: ``` $ mypy test_pytest_skip.py test_pytest_skip.py:2: note: Revealed type is "_pytest.outcomes._WithException[def (reason: builtins.str =, *, allow_module_level: builtins.bool =) -> Never, def (msg: Union[builtins.str, None] =, pytrace: builtins.bool =, allow_module_level: builtins.bool =, *, _use_item_location: builtins.bool =) -> _pytest.outcomes.Skipped]" test_pytest_skip.py:3: note: Revealed type is "def (msg: Union[builtins.str, None] =, pytrace: builtins.bool =, allow_module_level: builtins.bool =, *, _use_item_location: builtins.bool =) -> _pytest.outcomes.Skipped" test_pytest_skip.py:4: note: Revealed type is "Never" ``` After the change: ``` $ mypy test_pytest_skip.py test_pytest_skip.py:2: note: Revealed type is "_pytest.outcomes._Skip" test_pytest_skip.py:3: note: Revealed type is "type[_pytest.outcomes.Skipped]" test_pytest_skip.py:4: note: Revealed type is "Never" ``` `ty` type checker is also inferring the same types correctly now. All types are matching and propagated correctly (most importantly, `Never` as a result of `pytest.skip(...)` call
Thanks for suggestions @bluetech! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the update @karlicoss. Please see my comments, other than them it looks good!
src/_pytest/outcomes.py
Outdated
Exception: _ET | ||
__call__: _F | ||
def __call__(self, reason: str = "", returncode: int | None = None) -> NoReturn: | ||
"""Exit testing process. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's put the docstring on the class, so that sphinx picks it up correctly. You can verify that it works by checking that it shows up in the PR docs preview (currently empty): https://pytest--13445.org.readthedocs.build/en/13445/reference/reference.html#pytest-skip
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh this docs pipeline is neat, didn't notice that! Moved and seems like it's picked up correctly now
src/_pytest/outcomes.py
Outdated
|
||
class _Exit: | ||
Exception: type[Exit] = Exit |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Usually we omit an explicit annotation when it is inferred.
Also, here I think a ClassVar
annotation would be appropriate. So Exception: ClassVar = Exit
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea, added ClassVar
-- see below comment re: type annotation
src/_pytest/outcomes.py
Outdated
It is better to use the :ref:`pytest.mark.xfail ref` marker when | ||
possible to declare a test to be xfailed under certain conditions | ||
like known bugs or missing features. | ||
# Exposed helper methods. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My slight preference it to have the exit = _Exit
etc. below the class _Exit
instead of group at the bottom, it makes it easier to understand what it's about IMO.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done!
src/_pytest/outcomes.py
Outdated
""" | ||
__tracebackhide__ = True | ||
raise XFailed(reason) | ||
exit: _Exit = _Exit() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Here too I think we can omit the explicit type annotations)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So I added them because otherwise ty
was inferring pytest.skip(reason=...)
as Unknown
instead of Never
-- which is a consequence (and possibly there is some bug involved too) of how ty
handles unannotated module/class attributes at the moment.
I reported it here astral-sh/ty#433 (comment)
So if we remove explicit annotation, ty would infer pytest.skip(...)
as Unknown
at the moment -- this would defeat the purpose of typing annotations to an extent (although would still be a net improvement as at least it wouldn't report pytest.skip
as false positives).
IMO would be nice to keep them even if it's a minor 'style' inconsistency as it doesn't make anything else worse. We could leave a comment in code explaining why it's annotated (so someone doesn't remove by accident), but now that the outcome objects are next to the corresponding class it's a bit harder, since would require duplicating the comment 4 times :) Not sure what to do, but looks like this file really isn't modified frequently, and the risk of losing annotations is low -- and in the meantime ty
might sort out the Unknown
shenanigans.
I can remove the Exception
annotations apart from ClassVar
if you prefer -- the only downside is that ty
would infer pytest.skip.Exception
as Unknown
, but this doesn't feel like a big deal and everything else would still work as expected.
Let me know what you think!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can't say I understand the rationale of ty to behave this way, but it's intentional and it's just a minor style thing. So let's keep the annotations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @karlicoss!
Thank you! Appreciate your time |
This is a more canonical way of typing generic callbacks/decorators
(see https://mypy.readthedocs.io/en/stable/protocols.html#callback-protocols)
This helps with potential issues/ambiguity with
__call__
semanics intype checkers -- according to python spec,
__call__
needs to be presenton the class, rather than as an instance attribute to be considered callable.
This worked in mypy because it didn't handle the spec 100% correctly (in this case this has no negative consequences)
However,
ty
type checker is stricter/more correct about it and everypytest.skip
usage results in:error[call-non-callable]: Object of type
_WithException[Unknown, <class 'Skipped'>]is not callable
For more context, see:
Protocol[]
as generic astral-sh/ruff#17832 (comment)Testing:
Tested with running mypy against the following snippet:
Before the change:
After the change:
ty
type checker is also inferring the same types correctly now.All types are matching and propagated correctly (most importantly,
Never
as a result ofpytest.skip(...)
call