pytest-asyncio-concurrent-fork 0.5.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_asyncio_concurrent/__init__.py +7 -0
- pytest_asyncio_concurrent/fixture_async.py +139 -0
- pytest_asyncio_concurrent/grouping.py +152 -0
- pytest_asyncio_concurrent/hooks.py +40 -0
- pytest_asyncio_concurrent/plugin.py +534 -0
- pytest_asyncio_concurrent_fork-0.5.2.dist-info/METADATA +212 -0
- pytest_asyncio_concurrent_fork-0.5.2.dist-info/RECORD +11 -0
- pytest_asyncio_concurrent_fork-0.5.2.dist-info/WHEEL +5 -0
- pytest_asyncio_concurrent_fork-0.5.2.dist-info/entry_points.txt +2 -0
- pytest_asyncio_concurrent_fork-0.5.2.dist-info/licenses/LICENSE +22 -0
- pytest_asyncio_concurrent_fork-0.5.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import inspect
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, Optional, Sequence
|
|
7
|
+
import warnings
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from _pytest import fixtures
|
|
11
|
+
from _pytest import nodes
|
|
12
|
+
|
|
13
|
+
event_loop_key = pytest.StashKey[asyncio.AbstractEventLoop]()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.hookimpl(specname="pytest_fixture_setup", tryfirst=True)
|
|
17
|
+
def pytest_fixture_setup_wrap_async(
|
|
18
|
+
fixturedef: pytest.FixtureDef, request: pytest.FixtureRequest
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Wraps the fixture function of an async fixture in a synchronous function."""
|
|
21
|
+
event_loop = request.config.stash[event_loop_key]
|
|
22
|
+
if inspect.isasyncgenfunction(fixturedef.func):
|
|
23
|
+
_wrap_asyncgen_fixture(fixturedef, event_loop)
|
|
24
|
+
elif inspect.iscoroutinefunction(fixturedef.func):
|
|
25
|
+
_wrap_asyncfunc_fixture(fixturedef, event_loop)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _wrap_asyncgen_fixture(fixturedef: pytest.FixtureDef, event_loop) -> None:
|
|
29
|
+
fixtureFunc = fixturedef.func
|
|
30
|
+
|
|
31
|
+
@functools.wraps(fixtureFunc)
|
|
32
|
+
def _asyncgen_fixture_wrapper(**kwargs: Any):
|
|
33
|
+
gen_obj = fixtureFunc(**kwargs)
|
|
34
|
+
|
|
35
|
+
async def setup():
|
|
36
|
+
res = await gen_obj.__anext__() # type: ignore[union-attr]
|
|
37
|
+
return res
|
|
38
|
+
|
|
39
|
+
async def teardown() -> None:
|
|
40
|
+
try:
|
|
41
|
+
await gen_obj.__anext__() # type: ignore[union-attr]
|
|
42
|
+
except StopAsyncIteration:
|
|
43
|
+
pass
|
|
44
|
+
else:
|
|
45
|
+
msg = "Async generator fixture didn't stop."
|
|
46
|
+
msg += "Yield only once."
|
|
47
|
+
raise ValueError(msg)
|
|
48
|
+
|
|
49
|
+
result = event_loop.run_until_complete(setup())
|
|
50
|
+
yield result
|
|
51
|
+
event_loop.run_until_complete(teardown())
|
|
52
|
+
|
|
53
|
+
fixturedef.func = _asyncgen_fixture_wrapper # type: ignore[misc]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _wrap_asyncfunc_fixture(fixturedef: pytest.FixtureDef, event_loop) -> None:
|
|
57
|
+
fixtureFunc = fixturedef.func
|
|
58
|
+
|
|
59
|
+
@functools.wraps(fixtureFunc)
|
|
60
|
+
def _async_fixture_wrapper(**kwargs: Dict[str, Any]):
|
|
61
|
+
async def setup():
|
|
62
|
+
res = await fixtureFunc(**kwargs)
|
|
63
|
+
return res
|
|
64
|
+
|
|
65
|
+
return event_loop.run_until_complete(setup())
|
|
66
|
+
|
|
67
|
+
fixturedef.func = _async_fixture_wrapper # type: ignore[misc]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
fixture_cache_key = pytest.StashKey[Dict[str, Optional[Sequence[pytest.FixtureDef[Any]]]]]()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.hookimpl(specname="pytest_sessionstart", trylast=True)
|
|
74
|
+
def pytest_sessionstart_cache_fixture(session: pytest.Session):
|
|
75
|
+
event_loop = session.config.stash[event_loop_key] = asyncio.new_event_loop()
|
|
76
|
+
session.config.add_cleanup(event_loop.close)
|
|
77
|
+
# This function in general utilized some private properties.
|
|
78
|
+
|
|
79
|
+
# This is to solve two problem:
|
|
80
|
+
# 1. Funtion scoped fixture result value got shared in different tests in same group.
|
|
81
|
+
# 2. And fixture teardown got registered under right test using it.
|
|
82
|
+
|
|
83
|
+
# FixtureDef for each fixture is unique and held in FixtureManger and got injected into
|
|
84
|
+
# pytest.Item when the Item is constructed, and FixtureDef class is also in charge of
|
|
85
|
+
# holding finalizers and cache value.
|
|
86
|
+
|
|
87
|
+
# Fixture value caching is highly coupled with pytest entire lifecycle, implementing a
|
|
88
|
+
# thirdparty fixture cache manager will be hard.
|
|
89
|
+
# The first problem can be solved by shallow copy the fixtureDef, to split the cache_value.
|
|
90
|
+
# The finalizers are stored in a private list property in fixtureDef, which need to touch
|
|
91
|
+
# private API anyway.
|
|
92
|
+
|
|
93
|
+
# If the private API change, finalizer errors from this fixture but in different
|
|
94
|
+
# tests in same group will be reported in one function.
|
|
95
|
+
|
|
96
|
+
fixManager: fixtures.FixtureManager = session.config.pluginmanager.get_plugin(
|
|
97
|
+
"funcmanage"
|
|
98
|
+
) # type: ignore
|
|
99
|
+
getfixturedefs_original = fixManager.getfixturedefs
|
|
100
|
+
|
|
101
|
+
@functools.wraps(getfixturedefs_original)
|
|
102
|
+
def getfixturedefs_wrapper(
|
|
103
|
+
argname: str,
|
|
104
|
+
node: nodes.Node,
|
|
105
|
+
) -> Optional[Sequence[pytest.FixtureDef[Any]]]:
|
|
106
|
+
if fixture_cache_key not in node.stash:
|
|
107
|
+
node.stash[fixture_cache_key] = {}
|
|
108
|
+
|
|
109
|
+
cache = node.stash[fixture_cache_key]
|
|
110
|
+
if argname not in cache:
|
|
111
|
+
fixtureDefs = getfixturedefs_original(argname, node)
|
|
112
|
+
|
|
113
|
+
if fixtureDefs:
|
|
114
|
+
fixtureDefs = tuple(_clone_function_fixture(fixDef) for fixDef in fixtureDefs)
|
|
115
|
+
|
|
116
|
+
cache[argname] = fixtureDefs
|
|
117
|
+
|
|
118
|
+
return cache[argname]
|
|
119
|
+
|
|
120
|
+
fixManager.getfixturedefs = getfixturedefs_wrapper # type: ignore
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _clone_function_fixture(fixtureDef: pytest.FixtureDef) -> pytest.FixtureDef:
|
|
124
|
+
if fixtureDef.scope != "function":
|
|
125
|
+
return fixtureDef
|
|
126
|
+
|
|
127
|
+
new_fixdef = copy.copy(fixtureDef)
|
|
128
|
+
if hasattr(fixtureDef, "_finalizers"):
|
|
129
|
+
new_fixdef._finalizers = [] # type: ignore
|
|
130
|
+
else:
|
|
131
|
+
warnings.warn(
|
|
132
|
+
f"""
|
|
133
|
+
pytest {pytest.__version__} changed internal property which this plugin relies on.
|
|
134
|
+
The teardown error in fixture {fixtureDef.argname} might be reported in wrong place.
|
|
135
|
+
Please raise an issue.
|
|
136
|
+
"""
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return new_fixdef
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import sys
|
|
3
|
+
import dataclasses
|
|
4
|
+
from typing import Any, Callable, Dict, List
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from _pytest import fixtures
|
|
8
|
+
from _pytest import outcomes
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if sys.version_info < (3, 11):
|
|
12
|
+
from exceptiongroup import BaseExceptionGroup
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PytestAsyncioConcurrentGroupingWarning(pytest.PytestWarning):
|
|
16
|
+
"""Raised when Test from different parent grouped into same group."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PytestAsyncioConcurrentInvalidMarkWarning(pytest.PytestWarning):
|
|
20
|
+
"""Raised when Sync Test got marked."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PytestAysncioGroupInvokeError(BaseException):
|
|
24
|
+
"""Raised when AsyncioGroup got invoked"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AsyncioConcurrentGroup(pytest.Function):
|
|
28
|
+
"""
|
|
29
|
+
The Function Group containing underneath children functions.
|
|
30
|
+
AsyncioConcurrentGroup will be pushed onto `SetupState` representing all children.
|
|
31
|
+
and in charging of holding and tearing down the finalizers from all children nodes.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
children: List["AsyncioConcurrentGroupMember"]
|
|
35
|
+
children_have_same_parent: bool
|
|
36
|
+
children_finalizer: Dict["AsyncioConcurrentGroupMember", List[Callable[[], Any]]]
|
|
37
|
+
has_setup: bool
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
parent,
|
|
42
|
+
originalname: str,
|
|
43
|
+
):
|
|
44
|
+
self.children_have_same_parent = True
|
|
45
|
+
self.has_setup = False
|
|
46
|
+
self.children = []
|
|
47
|
+
self.children_finalizer = {}
|
|
48
|
+
super().__init__(
|
|
49
|
+
name=originalname,
|
|
50
|
+
parent=parent,
|
|
51
|
+
callobj=lambda: None,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def runtest(self) -> None:
|
|
55
|
+
raise PytestAysncioGroupInvokeError()
|
|
56
|
+
|
|
57
|
+
def setup(self) -> None:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def add_child(self, item: "AsyncioConcurrentGroupMember") -> None:
|
|
61
|
+
child_parent = list(item.iter_parents())[1]
|
|
62
|
+
|
|
63
|
+
if child_parent is not self.parent:
|
|
64
|
+
self.children_have_same_parent = False
|
|
65
|
+
for child in self.children:
|
|
66
|
+
child.add_marker("skip")
|
|
67
|
+
|
|
68
|
+
if not self.children_have_same_parent:
|
|
69
|
+
item.add_marker("skip")
|
|
70
|
+
|
|
71
|
+
item.group = self
|
|
72
|
+
self.children.append(item)
|
|
73
|
+
self.children_finalizer[item] = []
|
|
74
|
+
|
|
75
|
+
def teardown_child(self, item: "AsyncioConcurrentGroupMember") -> None:
|
|
76
|
+
item.session._setupstate.stack.pop(item, None)
|
|
77
|
+
finalizers = self.children_finalizer.pop(item)
|
|
78
|
+
exceptions = []
|
|
79
|
+
|
|
80
|
+
while finalizers:
|
|
81
|
+
fin = finalizers.pop()
|
|
82
|
+
try:
|
|
83
|
+
fin()
|
|
84
|
+
except outcomes.TEST_OUTCOME as e:
|
|
85
|
+
exceptions.append(e)
|
|
86
|
+
|
|
87
|
+
if len(exceptions) == 1:
|
|
88
|
+
raise exceptions[0]
|
|
89
|
+
elif len(exceptions) > 1:
|
|
90
|
+
msg = f"errors while tearing down {item!r}"
|
|
91
|
+
raise BaseExceptionGroup(msg, exceptions[::-1])
|
|
92
|
+
|
|
93
|
+
def remove_child(self, item: "AsyncioConcurrentGroupMember") -> None:
|
|
94
|
+
assert item in self.children
|
|
95
|
+
self.children.remove(item)
|
|
96
|
+
self.children_finalizer.pop(item)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AsyncioConcurrentGroupMember(pytest.Function):
|
|
100
|
+
"""
|
|
101
|
+
A light wrapper around Function, representing a child of AsyncioConcurrentGroup.
|
|
102
|
+
The group registers active members in SetupState and tears them down individually.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
group: AsyncioConcurrentGroup
|
|
106
|
+
_inner: pytest.Function
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def promote_from_function(item: pytest.Function) -> "AsyncioConcurrentGroupMember":
|
|
110
|
+
AsyncioConcurrentGroupMember._refresh_function_scoped_fixture(item)
|
|
111
|
+
member = AsyncioConcurrentGroupMember.from_parent(
|
|
112
|
+
name=item.name,
|
|
113
|
+
parent=item.parent,
|
|
114
|
+
callspec=item.callspec if hasattr(item, "callspec") else None,
|
|
115
|
+
callobj=item.obj,
|
|
116
|
+
keywords=item.keywords,
|
|
117
|
+
fixtureinfo=item._fixtureinfo,
|
|
118
|
+
originalname=item.originalname,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
member._inner = item
|
|
122
|
+
return member
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _refresh_function_scoped_fixture(item: pytest.Function):
|
|
126
|
+
# Parametrized tests will use their meta func to gather fixture information
|
|
127
|
+
# on collection, which means they all share same fixture infomation.
|
|
128
|
+
# Have to refresh fixtureDef here to get their own their own fixtureDef
|
|
129
|
+
|
|
130
|
+
if not hasattr(item, "callspec"):
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
fixtureManager: fixtures.FixtureManager = item.config.pluginmanager.get_plugin(
|
|
134
|
+
"funcmanage"
|
|
135
|
+
) # type: ignore
|
|
136
|
+
|
|
137
|
+
new_name2fixturedefs = {}
|
|
138
|
+
for name in item._fixtureinfo.name2fixturedefs.keys():
|
|
139
|
+
if name in item.callspec.params.keys():
|
|
140
|
+
new_name2fixturedefs[name] = item._fixtureinfo.name2fixturedefs[name]
|
|
141
|
+
else:
|
|
142
|
+
new_name2fixturedefs[name] = fixtureManager.getfixturedefs(
|
|
143
|
+
name, item
|
|
144
|
+
) # type: ignore
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
item._fixtureinfo = dataclasses.replace(
|
|
148
|
+
item._fixtureinfo, name2fixturedefs=new_name2fixturedefs
|
|
149
|
+
)
|
|
150
|
+
except TypeError: # if item._fixtureinfo no longer a dataclass
|
|
151
|
+
item._fixtureinfo = copy.copy(item._fixtureinfo)
|
|
152
|
+
item._fixtureinfo.name2fixturedefs = new_name2fixturedefs # type: ignore
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from typing import Optional, Coroutine
|
|
3
|
+
|
|
4
|
+
from .plugin import AsyncioConcurrentGroup
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.hookspec(firstresult=True)
|
|
8
|
+
def pytest_runtest_protocol_async_group(
|
|
9
|
+
group: "AsyncioConcurrentGroup", nextgroup: Optional["AsyncioConcurrentGroup"]
|
|
10
|
+
) -> object:
|
|
11
|
+
"""
|
|
12
|
+
The pytest_runtest_protocol for async group.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.hookspec(firstresult=True)
|
|
17
|
+
def pytest_runtest_call_async(item: pytest.Item) -> Optional[Coroutine]:
|
|
18
|
+
"""
|
|
19
|
+
The pytest_runtest_call for async function.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.hookspec()
|
|
24
|
+
def pytest_runtest_setup_async_group(item: "AsyncioConcurrentGroup") -> None:
|
|
25
|
+
"""
|
|
26
|
+
The pytest_runtest_setup for async group.
|
|
27
|
+
Should be called before any of its children setup
|
|
28
|
+
Also work as a safe guard to prevent polluting pytest environment.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.hookspec()
|
|
33
|
+
def pytest_runtest_teardown_async_group(
|
|
34
|
+
item: "AsyncioConcurrentGroup", nextitem: "AsyncioConcurrentGroup"
|
|
35
|
+
) -> None:
|
|
36
|
+
"""
|
|
37
|
+
The pytest_runtest_teardown for async group.
|
|
38
|
+
Should be called after all children finished teardown.
|
|
39
|
+
Also work as a safe guard to prevent polluting pytest environment.
|
|
40
|
+
"""
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import bdb
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
import warnings
|
|
6
|
+
import sys
|
|
7
|
+
import contextlib
|
|
8
|
+
|
|
9
|
+
from typing import (
|
|
10
|
+
Any,
|
|
11
|
+
Callable,
|
|
12
|
+
Generator,
|
|
13
|
+
List,
|
|
14
|
+
Literal,
|
|
15
|
+
Optional,
|
|
16
|
+
Coroutine,
|
|
17
|
+
Dict,
|
|
18
|
+
Sequence,
|
|
19
|
+
Union,
|
|
20
|
+
cast,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
import pluggy
|
|
24
|
+
import pytest
|
|
25
|
+
from _pytest import timing
|
|
26
|
+
from _pytest import outcomes
|
|
27
|
+
from .fixture_async import event_loop_key
|
|
28
|
+
|
|
29
|
+
from .grouping import (
|
|
30
|
+
AsyncioConcurrentGroup,
|
|
31
|
+
AsyncioConcurrentGroupMember,
|
|
32
|
+
PytestAsyncioConcurrentInvalidMarkWarning,
|
|
33
|
+
PytestAsyncioConcurrentGroupingWarning,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if sys.version_info < (3, 11):
|
|
37
|
+
from exceptiongroup import BaseExceptionGroup
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# =========================== # Config # =========================== #
|
|
41
|
+
|
|
42
|
+
asyncio_concurrent_group_key = pytest.StashKey[Dict[str, AsyncioConcurrentGroup]]()
|
|
43
|
+
GroupStrategy = Literal["self", "parent"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pytest_addoption(parser: pytest.Parser):
|
|
47
|
+
parser.addoption(
|
|
48
|
+
"--default-group-strategy",
|
|
49
|
+
choices=["self", "parent"],
|
|
50
|
+
help="asyncio-concurrent: default grouping strategy, \
|
|
51
|
+
please refer to documentation for more info.",
|
|
52
|
+
)
|
|
53
|
+
parser.addini(
|
|
54
|
+
"default_group_strategy",
|
|
55
|
+
"asyncio: asyncio-concurrent: default grouping strategy, \
|
|
56
|
+
please refer to documentation for more info.",
|
|
57
|
+
default="self",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
62
|
+
config.addinivalue_line(
|
|
63
|
+
"markers",
|
|
64
|
+
"asyncio_concurrent(group, timeout): " "mark the async tests to run concurrently",
|
|
65
|
+
)
|
|
66
|
+
config.stash[asyncio_concurrent_group_key] = {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.hookimpl
|
|
70
|
+
def pytest_addhooks(pluginmanager: pytest.PytestPluginManager) -> None:
|
|
71
|
+
from . import hooks
|
|
72
|
+
from . import fixture_async
|
|
73
|
+
|
|
74
|
+
pluginmanager.add_hookspecs(hooks)
|
|
75
|
+
pluginmanager.register(fixture_async)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# =========================== # Collection # =========================== #
|
|
79
|
+
|
|
80
|
+
MakeItemResult = Union[
|
|
81
|
+
None, pytest.Item, pytest.Collector, List[Union[pytest.Item, pytest.Collector]]
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.hookimpl(specname="pytest_pycollect_makeitem", wrapper=True, trylast=True)
|
|
86
|
+
def pytest_pycollect_makeitem_make_group_and_member(
|
|
87
|
+
collector: pytest.Collector, name: str, obj: object
|
|
88
|
+
) -> Generator[None, MakeItemResult, MakeItemResult]:
|
|
89
|
+
ori_result = yield
|
|
90
|
+
if ori_result is None:
|
|
91
|
+
return None
|
|
92
|
+
if not isinstance(ori_result, list):
|
|
93
|
+
ori_result = [ori_result]
|
|
94
|
+
|
|
95
|
+
result = []
|
|
96
|
+
for item_or_collector in ori_result:
|
|
97
|
+
if (
|
|
98
|
+
not isinstance(item_or_collector, pytest.Function)
|
|
99
|
+
or _get_asyncio_concurrent_mark(item_or_collector) is None
|
|
100
|
+
):
|
|
101
|
+
result.append(item_or_collector)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
item = item_or_collector
|
|
105
|
+
|
|
106
|
+
item = AsyncioConcurrentGroupMember.promote_from_function(item)
|
|
107
|
+
result.append(item)
|
|
108
|
+
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.hookimpl(specname="pytest_itemcollected")
|
|
113
|
+
def pytest_itemcollected_register_in_group(item: pytest.Item) -> None:
|
|
114
|
+
if not isinstance(item, AsyncioConcurrentGroupMember):
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
known_groups = item.config.stash[asyncio_concurrent_group_key]
|
|
118
|
+
|
|
119
|
+
group_name = _get_asyncio_concurrent_group(item)
|
|
120
|
+
if group_name not in known_groups:
|
|
121
|
+
known_groups[group_name] = AsyncioConcurrentGroup.from_parent(
|
|
122
|
+
parent=item.parent, originalname=f"AsyncioConcurrentGroup[{group_name}]"
|
|
123
|
+
)
|
|
124
|
+
group = known_groups[group_name]
|
|
125
|
+
|
|
126
|
+
group.add_child(item)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# =========================== # deselect # =========================== #
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@pytest.hookimpl(specname="pytest_deselected")
|
|
133
|
+
def pytest_deselected_update_group(items: Sequence[pytest.Item]) -> None:
|
|
134
|
+
"""Remove item from group if deselected."""
|
|
135
|
+
for item in items:
|
|
136
|
+
if isinstance(item, AsyncioConcurrentGroupMember):
|
|
137
|
+
item.group.remove_child(item)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# =========================== # pytest_runtestloop # =========================== #
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@pytest.hookimpl(specname="pytest_runtestloop", wrapper=True, trylast=True)
|
|
144
|
+
def pytest_runtestloop_handle_async_by_group(session: pytest.Session) -> Generator[None, Any, Any]:
|
|
145
|
+
"""
|
|
146
|
+
- Wrapping around pytest_runtestloop, grouping items with same group name together.
|
|
147
|
+
- Run formal pytest_runtestloop without async tests.
|
|
148
|
+
- Handle async tests by group, one at a time.
|
|
149
|
+
- Ungroup them after everything done.
|
|
150
|
+
"""
|
|
151
|
+
items = session.items
|
|
152
|
+
ihook = session.ihook
|
|
153
|
+
|
|
154
|
+
asyncio_concurrent_tests = [
|
|
155
|
+
item for item in items if isinstance(item, AsyncioConcurrentGroupMember)
|
|
156
|
+
]
|
|
157
|
+
groups: List[AsyncioConcurrentGroup] = []
|
|
158
|
+
for async_test in asyncio_concurrent_tests:
|
|
159
|
+
if async_test.group not in groups:
|
|
160
|
+
groups.append(async_test.group)
|
|
161
|
+
items.remove(async_test)
|
|
162
|
+
|
|
163
|
+
assert sum([len(group.children) for group in groups]) == len(asyncio_concurrent_tests)
|
|
164
|
+
|
|
165
|
+
result = yield
|
|
166
|
+
|
|
167
|
+
if session.config.option.collectonly:
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
for i, group in enumerate(groups):
|
|
171
|
+
nextgroup = groups[i + 1] if i + 1 < len(groups) else None
|
|
172
|
+
ihook.pytest_runtest_protocol_async_group(group=group, nextgroup=nextgroup)
|
|
173
|
+
|
|
174
|
+
for group in groups:
|
|
175
|
+
for item in group.children:
|
|
176
|
+
items.append(item)
|
|
177
|
+
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@pytest.hookimpl(specname="pytest_runtest_protocol_async_group")
|
|
182
|
+
def pytest_runtest_protocol_async_group(
|
|
183
|
+
group: AsyncioConcurrentGroup, nextgroup: Optional[AsyncioConcurrentGroup]
|
|
184
|
+
) -> object:
|
|
185
|
+
"""
|
|
186
|
+
Handling life cycle of async group tests. Calling pytest hooks in the same order as pytest core,
|
|
187
|
+
but calling same hook on all tests in this group in batch. While for pytest_runtest_call,
|
|
188
|
+
all tests are called and gathered, and await in a single event loop, which is how tests running
|
|
189
|
+
concurrently.
|
|
190
|
+
|
|
191
|
+
Hooks order:
|
|
192
|
+
- pytest_runtest_logstart (batch)
|
|
193
|
+
- pytest_runtest_setup_async_group (reporting under first tests)
|
|
194
|
+
- pytest_runtest_setup (batch) (and reporting)
|
|
195
|
+
- pytest_runtest_call_async (batch) (and reporting)
|
|
196
|
+
- pytest_runtest_teardown (batch) (and reporting)
|
|
197
|
+
- pytest_runtest_teardown_async_group (reporting under last tests)
|
|
198
|
+
- pytest_runtest_logfinish (batch)
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
if not group.children_have_same_parent:
|
|
202
|
+
for child in group.children:
|
|
203
|
+
child.add_marker("skip")
|
|
204
|
+
|
|
205
|
+
warnings.warn(
|
|
206
|
+
PytestAsyncioConcurrentGroupingWarning(
|
|
207
|
+
f"""
|
|
208
|
+
Asyncio Concurrent Group [{group.name}] has children from different parents,
|
|
209
|
+
skipping all of it's children.
|
|
210
|
+
"""
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
item_passed_setup: List[AsyncioConcurrentGroupMember] = []
|
|
215
|
+
loop = group.config.stash[event_loop_key]
|
|
216
|
+
|
|
217
|
+
for childFunc in group.children:
|
|
218
|
+
childFunc.ihook.pytest_runtest_logstart(
|
|
219
|
+
nodeid=childFunc.nodeid, location=childFunc.location
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
report = _call_and_report(_setup_child(childFunc), childFunc, "setup")
|
|
223
|
+
if report.passed:
|
|
224
|
+
item_passed_setup.append(childFunc)
|
|
225
|
+
|
|
226
|
+
async def run_tests():
|
|
227
|
+
return await asyncio.gather(*[_call_runtest_async(child) for child in item_passed_setup])
|
|
228
|
+
|
|
229
|
+
callinfos = loop.run_until_complete(run_tests())
|
|
230
|
+
|
|
231
|
+
for childFunc, callinfo in zip(item_passed_setup, callinfos):
|
|
232
|
+
report = childFunc.ihook.pytest_runtest_makereport(item=childFunc, call=callinfo)
|
|
233
|
+
if _check_interactive_exception(call=callinfo, report=report):
|
|
234
|
+
childFunc.ihook.pytest_exception_interact(node=childFunc, call=callinfo, report=report)
|
|
235
|
+
|
|
236
|
+
childFunc.ihook.pytest_runtest_logreport(report=report)
|
|
237
|
+
|
|
238
|
+
for childFunc in group.children:
|
|
239
|
+
_call_and_report(_teardown_child(childFunc, nextgroup=nextgroup), childFunc, "teardown")
|
|
240
|
+
|
|
241
|
+
childFunc.ihook.pytest_runtest_logfinish(
|
|
242
|
+
nodeid=childFunc.nodeid, location=childFunc.location
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return True
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def _call_runtest_async(item: AsyncioConcurrentGroupMember) -> pytest.CallInfo:
|
|
249
|
+
mark = _get_asyncio_concurrent_mark(item)
|
|
250
|
+
assert mark
|
|
251
|
+
timeout = mark.kwargs.get("timeout")
|
|
252
|
+
|
|
253
|
+
return await _async_callinfo_from_call(
|
|
254
|
+
functools.partial(item.ihook.pytest_runtest_call_async, item=item), # type: ignore
|
|
255
|
+
timeout=timeout,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _setup_child(item: AsyncioConcurrentGroupMember) -> Callable[[], None]:
|
|
260
|
+
"""
|
|
261
|
+
Setup flow for normal pytest tests:
|
|
262
|
+
- Push all nodes onto `SetupState`, start from furthest.
|
|
263
|
+
- Register fixture finalizers to repective node in `SetupState` according to its scope.
|
|
264
|
+
Setup flow for async pytest tests:
|
|
265
|
+
- Setup group
|
|
266
|
+
- Push all nodes onto `SetupState`, start from furthest.
|
|
267
|
+
- The node on the face will be `AsyncioConcurrentGroup`.
|
|
268
|
+
- Setup individual tests.
|
|
269
|
+
- Register each active test in SetupState with its group's finalizer list.
|
|
270
|
+
- Non-function-scoped finalizers remain on their parent node.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
def inner() -> None:
|
|
274
|
+
if not item.group.has_setup:
|
|
275
|
+
item.ihook.pytest_runtest_setup_async_group(item=item.group)
|
|
276
|
+
|
|
277
|
+
item.session._setupstate.stack[item] = (item.group.children_finalizer[item], None)
|
|
278
|
+
item.config.pluginmanager.subset_hook_caller(
|
|
279
|
+
"pytest_runtest_setup", [item.config.pluginmanager.get_plugin("runner")]
|
|
280
|
+
)(item=item)
|
|
281
|
+
|
|
282
|
+
return inner
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _teardown_child(
|
|
286
|
+
item: AsyncioConcurrentGroupMember,
|
|
287
|
+
nextgroup: Optional[AsyncioConcurrentGroup],
|
|
288
|
+
) -> Callable[[], None]:
|
|
289
|
+
"""
|
|
290
|
+
Similar to setup.
|
|
291
|
+
Teardown flow for normal pytest tests:
|
|
292
|
+
- Remove all nodes not used in next item from `SetupState`, start from closest.
|
|
293
|
+
- Call all finalizer on node removed.
|
|
294
|
+
Teardown flow for async pytest tests:
|
|
295
|
+
- Teardown individual tests.
|
|
296
|
+
- Remove individual test from group, and let group call their finalizers.
|
|
297
|
+
- Teardown group.
|
|
298
|
+
- Remove all nodes not used in next item from `SetupState`, start from closest.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
def inner() -> None:
|
|
302
|
+
exceptions = []
|
|
303
|
+
try:
|
|
304
|
+
item.config.pluginmanager.subset_hook_caller(
|
|
305
|
+
"pytest_runtest_teardown", [item.config.pluginmanager.get_plugin("runner")]
|
|
306
|
+
)(item=item, nextitem=nextgroup)
|
|
307
|
+
except Exception as e:
|
|
308
|
+
exceptions.append(e)
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
if len(item.group.children_finalizer) == 0:
|
|
312
|
+
item.ihook.pytest_runtest_teardown_async_group(item=item.group, nextitem=nextgroup)
|
|
313
|
+
except Exception as e:
|
|
314
|
+
if isinstance(e, BaseExceptionGroup):
|
|
315
|
+
exceptions.extend(e.exceptions) # type: ignore
|
|
316
|
+
else:
|
|
317
|
+
exceptions.append(e)
|
|
318
|
+
|
|
319
|
+
if len(exceptions) == 1:
|
|
320
|
+
raise exceptions[0]
|
|
321
|
+
elif len(exceptions) > 1:
|
|
322
|
+
msg = f"errors while tearing down {item!r}"
|
|
323
|
+
raise BaseExceptionGroup(msg, exceptions)
|
|
324
|
+
|
|
325
|
+
return inner
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# =========================== # group lifcycle # =========================== #
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@pytest.hookimpl(specname="pytest_runtest_call_async")
|
|
332
|
+
async def pytest_runtest_call_async(item: pytest.Function) -> object:
|
|
333
|
+
if not inspect.iscoroutinefunction(item.obj):
|
|
334
|
+
warnings.warn(
|
|
335
|
+
PytestAsyncioConcurrentInvalidMarkWarning(
|
|
336
|
+
"Marking a sync function with @asyncio_concurrent is invalid."
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
pytest.skip("Marking a sync function with @asyncio_concurrent is invalid.")
|
|
341
|
+
|
|
342
|
+
with hook_wrapper_entered(item.ihook.pytest_runtest_call, item=item):
|
|
343
|
+
testfunction = item.obj
|
|
344
|
+
testargs = {arg: item.funcargs[arg] for arg in item._fixtureinfo.argnames}
|
|
345
|
+
return await testfunction(**testargs)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@pytest.hookimpl(specname="pytest_runtest_setup_async_group")
|
|
349
|
+
def pytest_runtest_setup_async_group(item: AsyncioConcurrentGroup) -> None:
|
|
350
|
+
"""
|
|
351
|
+
Set up the group's ancestors before registering its concurrent members.
|
|
352
|
+
"""
|
|
353
|
+
assert not item.has_setup
|
|
354
|
+
item.ihook.pytest_runtest_setup(item=item)
|
|
355
|
+
item.has_setup = True
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@pytest.hookimpl(specname="pytest_runtest_teardown_async_group")
|
|
359
|
+
def pytest_runtest_teardown_async_group(
|
|
360
|
+
item: "AsyncioConcurrentGroup",
|
|
361
|
+
nextitem: "AsyncioConcurrentGroup",
|
|
362
|
+
) -> None:
|
|
363
|
+
assert item.has_setup
|
|
364
|
+
assert len(item.children_finalizer) == 0
|
|
365
|
+
item.ihook.pytest_runtest_teardown(item=item, nextitem=nextitem)
|
|
366
|
+
item.has_setup = False
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# =========================== # async lifcycle redirection # =========================== #
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@pytest.hookimpl(specname="pytest_runtest_setup")
|
|
373
|
+
def pytest_runtest_setup_handle_async_function(item: pytest.Item) -> None:
|
|
374
|
+
"""We have skipped the one in pytest.runner, but we still need setup."""
|
|
375
|
+
if not isinstance(item, AsyncioConcurrentGroupMember):
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
item.setup()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@pytest.hookimpl(specname="pytest_runtest_teardown")
|
|
382
|
+
def pytest_runtest_teardown_handle_async_function(
|
|
383
|
+
item: pytest.Item, nextitem: Optional[pytest.Item]
|
|
384
|
+
) -> None:
|
|
385
|
+
"""
|
|
386
|
+
We have skipped the one in pytest.runner,
|
|
387
|
+
redirecting to AsyncioConcurrentGroup for teardown.
|
|
388
|
+
"""
|
|
389
|
+
if not isinstance(item, AsyncioConcurrentGroupMember):
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
item.group.teardown_child(item)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# =========================== # Captures #===========================#
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@pytest.hookimpl(specname="pytest_runtest_protocol_async_group", wrapper=True, tryfirst=True)
|
|
399
|
+
def pytest_runtest_protocol_async_group_warning(
|
|
400
|
+
group: "AsyncioConcurrentGroup", nextgroup: Optional["AsyncioConcurrentGroup"]
|
|
401
|
+
) -> Generator[None, object, object]:
|
|
402
|
+
with hook_wrapper_entered(group.ihook.pytest_runtest_protocol, item=group, nextitem=nextgroup):
|
|
403
|
+
return (yield)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# =========================== # helper #===========================#
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _get_asyncio_concurrent_mark(item: pytest.Item) -> Optional[pytest.Mark]:
|
|
410
|
+
return item.get_closest_marker("asyncio_concurrent")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _get_asyncio_concurrent_group(item: AsyncioConcurrentGroupMember) -> str:
|
|
414
|
+
marker = item.get_closest_marker("asyncio_concurrent")
|
|
415
|
+
assert marker is not None
|
|
416
|
+
|
|
417
|
+
default_group_name = (
|
|
418
|
+
f"self_[{item.nodeid}]"
|
|
419
|
+
if _get_group_strategy(item.config) == "self"
|
|
420
|
+
else f"parent_[{item.parent.nodeid}]" # type: ignore
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
return marker.kwargs.get("group", default_group_name)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@functools.lru_cache(maxsize=1)
|
|
427
|
+
def _get_group_strategy(config: pytest.Config) -> GroupStrategy:
|
|
428
|
+
strategy = (
|
|
429
|
+
config.getoption("--default-group-strategy")
|
|
430
|
+
or config.getini("default_group_strategy")
|
|
431
|
+
or "self"
|
|
432
|
+
).lower()
|
|
433
|
+
assert (
|
|
434
|
+
strategy == "self" or strategy == "parent"
|
|
435
|
+
), "group_strategy should be either self or parent"
|
|
436
|
+
return cast(GroupStrategy, strategy)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# referencing CallInfo.from_call
|
|
440
|
+
async def _async_callinfo_from_call(
|
|
441
|
+
func: Callable[[], Coroutine], timeout: Optional[int]
|
|
442
|
+
) -> pytest.CallInfo:
|
|
443
|
+
"""An async version of CallInfo.from_call"""
|
|
444
|
+
|
|
445
|
+
excinfo = None
|
|
446
|
+
start = timing.time()
|
|
447
|
+
precise_start = timing.perf_counter()
|
|
448
|
+
try:
|
|
449
|
+
result = await asyncio.wait_for(func(), timeout=timeout)
|
|
450
|
+
except BaseException:
|
|
451
|
+
excinfo = pytest.ExceptionInfo.from_current()
|
|
452
|
+
if isinstance(excinfo.value, outcomes.Exit) or isinstance(excinfo.value, KeyboardInterrupt):
|
|
453
|
+
raise
|
|
454
|
+
result = None
|
|
455
|
+
|
|
456
|
+
precise_stop = timing.perf_counter()
|
|
457
|
+
duration = precise_stop - precise_start
|
|
458
|
+
stop = timing.time()
|
|
459
|
+
|
|
460
|
+
callInfo: pytest.CallInfo = pytest.CallInfo(
|
|
461
|
+
start=start,
|
|
462
|
+
stop=stop,
|
|
463
|
+
duration=duration,
|
|
464
|
+
when="call",
|
|
465
|
+
result=result,
|
|
466
|
+
excinfo=excinfo,
|
|
467
|
+
_ispytest=True,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
return callInfo
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# referencing runner.call_and_report
|
|
474
|
+
def _call_and_report(
|
|
475
|
+
func: Callable[[], None],
|
|
476
|
+
item: pytest.Item,
|
|
477
|
+
when: Literal["setup", "teardown"],
|
|
478
|
+
) -> pytest.TestReport:
|
|
479
|
+
reraise: tuple[type[BaseException], ...] = (outcomes.Exit,)
|
|
480
|
+
if not item.config.getoption("usepdb", False):
|
|
481
|
+
reraise += (KeyboardInterrupt,)
|
|
482
|
+
|
|
483
|
+
call = pytest.CallInfo.from_call(func, when=when, reraise=reraise)
|
|
484
|
+
report: pytest.TestReport = item.ihook.pytest_runtest_makereport(item=item, call=call)
|
|
485
|
+
item.ihook.pytest_runtest_logreport(report=report)
|
|
486
|
+
|
|
487
|
+
if (
|
|
488
|
+
call.excinfo
|
|
489
|
+
and not isinstance(call.excinfo.value, outcomes.Skipped)
|
|
490
|
+
and not hasattr(report, "wasxfail")
|
|
491
|
+
):
|
|
492
|
+
item.ihook.pytest_exception_interact(node=item, call=call, report=report)
|
|
493
|
+
|
|
494
|
+
if _check_interactive_exception(call, report):
|
|
495
|
+
item.ihook.pytest_exception_interact(node=item, call=call, report=report)
|
|
496
|
+
return report
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@contextlib.contextmanager
|
|
500
|
+
def hook_wrapper_entered(
|
|
501
|
+
hook: pluggy.HookCaller,
|
|
502
|
+
**kwds: Any,
|
|
503
|
+
) -> Generator[None, None, Any]:
|
|
504
|
+
"""
|
|
505
|
+
# Capture and logging are hard to handle without duplicating huge amount of code,
|
|
506
|
+
# so reusing defined hooks wrapper here.
|
|
507
|
+
"""
|
|
508
|
+
with contextlib.ExitStack() as es:
|
|
509
|
+
for hookimpl in hook.get_hookimpls():
|
|
510
|
+
if not hookimpl.wrapper:
|
|
511
|
+
continue
|
|
512
|
+
es.enter_context(
|
|
513
|
+
contextlib.contextmanager(hookimpl.function)( # type: ignore
|
|
514
|
+
**{k: v for k, v in kwds.items() if k in hookimpl.argnames}
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
yield
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# copied from _pytest/runner
|
|
522
|
+
def _check_interactive_exception(call: pytest.CallInfo[object], report: pytest.TestReport) -> bool:
|
|
523
|
+
"""Check whether the call raised an exception that should be reported as
|
|
524
|
+
interactive."""
|
|
525
|
+
if call.excinfo is None:
|
|
526
|
+
# Didn't raise.
|
|
527
|
+
return False
|
|
528
|
+
if hasattr(report, "wasxfail"):
|
|
529
|
+
# Exception was expected.
|
|
530
|
+
return False
|
|
531
|
+
if isinstance(call.excinfo.value, (outcomes.Skipped, bdb.BdbQuit)):
|
|
532
|
+
# Special control flow exception.
|
|
533
|
+
return False
|
|
534
|
+
return True
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-asyncio-concurrent-fork
|
|
3
|
+
Version: 0.5.2
|
|
4
|
+
Summary: Temporary pytest compatibility fork of pytest-asyncio-concurrent.
|
|
5
|
+
Author-email: Zane Chen <czl970721@gmail.com>
|
|
6
|
+
Maintainer-email: Zane Chen <czl970721@gmail.com>
|
|
7
|
+
License:
|
|
8
|
+
The MIT License (MIT)
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2024 Zane Chen
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in
|
|
20
|
+
all copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
28
|
+
THE SOFTWARE.
|
|
29
|
+
|
|
30
|
+
Project-URL: Repository, https://github.com/AnswerDotAI/pytest-asyncio-concurrent
|
|
31
|
+
Project-URL: Homepage, https://github.com/AnswerDotAI/pytest-asyncio-concurrent
|
|
32
|
+
Project-URL: Issues, https://github.com/AnswerDotAI/pytest-asyncio-concurrent/issues
|
|
33
|
+
Classifier: Framework :: Pytest
|
|
34
|
+
Classifier: Development Status :: 4 - Beta
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: Topic :: Software Development :: Testing
|
|
37
|
+
Classifier: Operating System :: OS Independent
|
|
38
|
+
Classifier: Programming Language :: Python
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
45
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
46
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
47
|
+
Requires-Python: >=3.8
|
|
48
|
+
Description-Content-Type: text/x-rst
|
|
49
|
+
License-File: LICENSE
|
|
50
|
+
Requires-Dist: pytest>=6.2.0
|
|
51
|
+
Provides-Extra: testing
|
|
52
|
+
Requires-Dist: coverage>=7.6.0; extra == "testing"
|
|
53
|
+
Dynamic: license-file
|
|
54
|
+
|
|
55
|
+
==============================
|
|
56
|
+
pytest-asyncio-concurrent-fork
|
|
57
|
+
==============================
|
|
58
|
+
|
|
59
|
+
This is a temporary pytest compatibility fork of `pytest-asyncio-concurrent <https://github.com/czl9707/pytest-asyncio-concurrent>`_. The intent is to return to upstream once the fixes land there.
|
|
60
|
+
|
|
61
|
+
The fork registers concurrent tests as active during fixture setup and execution, as required by pytest 9.1. Async fixtures and concurrent tests share one session-owned event loop rather than relying on an implicit current loop.
|
|
62
|
+
|
|
63
|
+
.. image:: https://img.shields.io/pypi/v/pytest-asyncio-concurrent-fork.svg
|
|
64
|
+
:target: https://pypi.org/project/pytest-asyncio-concurrent-fork
|
|
65
|
+
:alt: PyPI version
|
|
66
|
+
|
|
67
|
+
.. image:: https://img.shields.io/pypi/pyversions/pytest-asyncio-concurrent-fork.svg
|
|
68
|
+
:target: https://pypi.org/project/pytest-asyncio-concurrent-fork
|
|
69
|
+
:alt: Python versions
|
|
70
|
+
|
|
71
|
+
.. image:: https://github.com/AnswerDotAI/pytest-asyncio-concurrent/actions/workflows/main.yml/badge.svg
|
|
72
|
+
:target: https://github.com/AnswerDotAI/pytest-asyncio-concurrent/actions/workflows/main.yml
|
|
73
|
+
:alt: See Build Status on GitHub Actions
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
System/Integration tests can take a really long time.
|
|
77
|
+
|
|
78
|
+
And ``pytest-asyncio-concurrent`` A pytest plugin aiming to solve this by running asynchronous tests in true parallel, enabling faster execution for high I/O or network-bound test suites.
|
|
79
|
+
|
|
80
|
+
Unlike ``pytest-asyncio``, which runs async tests **sequentially**, ``pytest-asyncio-concurrent`` takes advantage of Python's asyncio capabilities to execute tests **concurrently** by specifying **async group**.
|
|
81
|
+
|
|
82
|
+
Note: This plugin would more or less `Break Test Isolation Principle` \(for none function scoped fixture\). Please make sure your tests is ok to run concurrently before you use this plugin.
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
Key Features
|
|
86
|
+
------------
|
|
87
|
+
|
|
88
|
+
* Giving the capability to run pytest async functions.
|
|
89
|
+
* Providing granular control over Concurrency -- See **Async Group** for details
|
|
90
|
+
|
|
91
|
+
* Specifying Async Group to control tests that can run together.
|
|
92
|
+
* Limitation: Only test functions defined under same direct parent can be put into same group.
|
|
93
|
+
|
|
94
|
+
* ``asyncio_concurrent`` mark accept a ``timeout`` parameter, which would throw error when test reach the given time.
|
|
95
|
+
* Compatible with ``pytest-asyncio``.
|
|
96
|
+
|
|
97
|
+
Key Concept: Async Group
|
|
98
|
+
------------------------
|
|
99
|
+
|
|
100
|
+
The plugin control tests concurrency by putting them into different groups.
|
|
101
|
+
|
|
102
|
+
* Limitation: Each group can **only** contain tests under **same direct parent**. For example, class methods within same class, or functions within same file.
|
|
103
|
+
* Explicitly specify group by providing ``group`` parameter to ``asyncio_concurrent`` mark.
|
|
104
|
+
|
|
105
|
+
* All tests marked with same group name will be executed together.
|
|
106
|
+
* All tests will be skipped if the ``Same Parent`` rule got violated.
|
|
107
|
+
|
|
108
|
+
* Use default grouping strategy. The plugin accept ``--default-group-strategy`` cli parameter, or ``default_group_strategy`` in ini or toml file. Possible value: ``self``, ``parent``.
|
|
109
|
+
|
|
110
|
+
* **self**\(default\): Each test will be executed by itself if no group provided.
|
|
111
|
+
* **parent**: Tests will grouped by their parent node. For example, all method within same class will be grouped together.
|
|
112
|
+
|
|
113
|
+
Installation
|
|
114
|
+
------------
|
|
115
|
+
|
|
116
|
+
Replace the upstream distribution with the fork; do not install both, since they provide the same Python package and pytest plugin::
|
|
117
|
+
|
|
118
|
+
$ pip uninstall pytest-asyncio-concurrent
|
|
119
|
+
$ pip install pytest-asyncio-concurrent-fork
|
|
120
|
+
|
|
121
|
+
The marker and plugin names are unchanged. With pytest 8.4 or later, projects can load only the plugins they use:
|
|
122
|
+
|
|
123
|
+
.. code-block:: toml
|
|
124
|
+
|
|
125
|
+
[tool.pytest.ini_options]
|
|
126
|
+
addopts = "--disable-plugin-autoload -p asyncio-concurrent"
|
|
127
|
+
|
|
128
|
+
Use plain ``@pytest.fixture`` for async fixtures owned by this plugin. If also using ``pytest-asyncio``, explicitly load ``-p asyncio`` and use its strict mode so it does not take ownership of concurrent tests.
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
How this work?
|
|
132
|
+
--------------
|
|
133
|
+
|
|
134
|
+
This plugin extend ``pytest_runtestloop`` hook to handle async tests seperately.
|
|
135
|
+
|
|
136
|
+
In the 'async loop', tests are grouped by the `group` provided in the mark. and executed one group at a time.
|
|
137
|
+
|
|
138
|
+
In each group, instead of sequentially calling ``setup``, ``call``, ``teardown`` for individual test, these hooks are called for all tests in the group in batch.
|
|
139
|
+
|
|
140
|
+
The fixture lifecycle within group is handled by this plugin instead of pytest core, to work around pytest sequential test execution assumption.
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
Usage
|
|
144
|
+
-----
|
|
145
|
+
|
|
146
|
+
Run test Sequentially
|
|
147
|
+
|
|
148
|
+
.. code-block:: python
|
|
149
|
+
|
|
150
|
+
@pytest.mark.asyncio_concurrent
|
|
151
|
+
async def async_test_A():
|
|
152
|
+
res = await wait_for_something_async()
|
|
153
|
+
assert result.is_valid()
|
|
154
|
+
|
|
155
|
+
@pytest.mark.asyncio_concurrent
|
|
156
|
+
async def async_test_B():
|
|
157
|
+
res = await wait_for_something_async()
|
|
158
|
+
assert result.is_valid()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
Run tests Concurrently
|
|
162
|
+
|
|
163
|
+
.. code-block:: python
|
|
164
|
+
|
|
165
|
+
# the test below will run by itself
|
|
166
|
+
@pytest.mark.asyncio_concurrent
|
|
167
|
+
async def test_by_itself():
|
|
168
|
+
res = await wait_for_something_async()
|
|
169
|
+
assert result.is_valid()
|
|
170
|
+
|
|
171
|
+
# the two tests below will run concurrently
|
|
172
|
+
@pytest.mark.asyncio_concurrent(group="my_group")
|
|
173
|
+
async def test_groupA():
|
|
174
|
+
res = await wait_for_something_async()
|
|
175
|
+
assert result.is_valid()
|
|
176
|
+
|
|
177
|
+
# this one will have a 10s timeout
|
|
178
|
+
@pytest.mark.asyncio_concurrent(group="my_group", timeout=10)
|
|
179
|
+
async def test_groupB():
|
|
180
|
+
res = await wait_for_something_async()
|
|
181
|
+
assert result.is_valid()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
Parametrized Tests
|
|
185
|
+
|
|
186
|
+
.. code-block:: python
|
|
187
|
+
|
|
188
|
+
# the parametrized tests below will run sequential
|
|
189
|
+
@pytest.mark.asyncio_concurrent
|
|
190
|
+
@pytest.parametrize("p", [0, 1, 2])
|
|
191
|
+
async def test_parametrize_sequential(p):
|
|
192
|
+
res = await wait_for_something_async()
|
|
193
|
+
assert result.is_valid()
|
|
194
|
+
|
|
195
|
+
# the parametrized tests below will run concurrently
|
|
196
|
+
@pytest.mark.asyncio_concurrent(group="my_group")
|
|
197
|
+
@pytest.parametrize("p", [0, 1, 2])
|
|
198
|
+
async def test_parametrize_concurrent():
|
|
199
|
+
res = await wait_for_something_async()
|
|
200
|
+
assert result.is_valid()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
Contributing
|
|
204
|
+
------------
|
|
205
|
+
|
|
206
|
+
Contributions are very welcome. Tests can be run with ``tox``, please ensure
|
|
207
|
+
the coverage at least stays the same before you submit a pull request.
|
|
208
|
+
|
|
209
|
+
License
|
|
210
|
+
-------
|
|
211
|
+
|
|
212
|
+
Distributed under the terms of the ``MIT`` license, "pytest-asyncio-concurrent" is free and open source software
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pytest_asyncio_concurrent/__init__.py,sha256=TTvtXKpM6kLqa6uK4cowV8o8mhQa1qdVI-zyeYmKyVs,164
|
|
2
|
+
pytest_asyncio_concurrent/fixture_async.py,sha256=bM6kZNa8QXtVAUsYBKm6m5m6S4VBVK9rHvh5tw9p8CI,4961
|
|
3
|
+
pytest_asyncio_concurrent/grouping.py,sha256=P5i3k-fYUYsABVFE_9zrtE482CqwPDwcWHGeh_G-xRw,5061
|
|
4
|
+
pytest_asyncio_concurrent/hooks.py,sha256=TrBCU08ZLE1DyTSR8NP9bMdWzCv_bpE2vOO96BheuVo,1135
|
|
5
|
+
pytest_asyncio_concurrent/plugin.py,sha256=TGaQWyoy8dcFfm6-cIa9gGwi_ZGuTs8xNOwhbKetqVU,17534
|
|
6
|
+
pytest_asyncio_concurrent_fork-0.5.2.dist-info/licenses/LICENSE,sha256=rYU59J3fLm1rl8ZZusOawdV63oDawVvKvqOQcx-kmrg,1077
|
|
7
|
+
pytest_asyncio_concurrent_fork-0.5.2.dist-info/METADATA,sha256=LlIhDpfPMxB8PvLO9PYyxpziHoIwBvilVDYbUxrEV5U,8970
|
|
8
|
+
pytest_asyncio_concurrent_fork-0.5.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
pytest_asyncio_concurrent_fork-0.5.2.dist-info/entry_points.txt,sha256=SsXF0fcu1YweCprSYHbbVsMYRJKaZNg-hfA2HqcbaEc,65
|
|
10
|
+
pytest_asyncio_concurrent_fork-0.5.2.dist-info/top_level.txt,sha256=fBFeLvQQvSL9JdjKtFSAF_VuaqEWFC25jLveZHovcQY,26
|
|
11
|
+
pytest_asyncio_concurrent_fork-0.5.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
The MIT License (MIT)
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2024 Zane Chen
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in
|
|
14
|
+
all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
22
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pytest_asyncio_concurrent
|