Skip to content

Commit 8f17242

Browse files
authored
Enable pytest-asyncio debug mode (#603)
Fixes #541.
2 parents f023df5 + 1098835 commit 8f17242

11 files changed

Lines changed: 260 additions & 1 deletion

File tree

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ But you might still need to adapt your code:
2828

2929
- Generated `pyproject.toml` no longer sets `addopts = "-vv"` under `[tool.pytest.ini_options]` as this is too verbose for a default.
3030
- Generated projects enable mypy's `exhaustive-match` error code.
31+
- Generated non-API projects enable asyncio debug mode during tests to provide extra runtime checks.
3132
- Removed the dummy DCO workflow for the merge queue, as the DCO GitHub App now runs on `merge_group` events. The `DCO` required status check in the "Protect version branches" ruleset is now pinned to the DCO GitHub App; the migration script removes the workflow and updates the ruleset (via the `gh` CLI) for existing repositories.
3233

3334
## Enhancements

cookiecutter/migrate.py

Lines changed: 250 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ def main() -> None:
6363
print("=" * 72)
6464
print()
6565

66+
print("=" * 72)
67+
print("Enabling asyncio debug mode for pytest...")
68+
migrate_pytest_asyncio_debug()
69+
print("=" * 72)
70+
print()
71+
6672
if _manual_steps:
6773
print(
6874
"\033[5;33m⚠️⚠️⚠️\033[0;33m Remember to check the manual steps: \033[5;33m⚠️⚠️⚠️\033[0m"
@@ -358,6 +364,31 @@ def _infer_private_repo_from_metadata() -> bool | None:
358364
return None
359365

360366

367+
def specifier_ensures_min_version(specifier: str, min_version: tuple[int, ...]) -> bool:
368+
"""Return whether a version specifier guarantees a minimum version.
369+
370+
Only ``==``, ``>=``, ``~=`` and ``>`` clauses can establish a lower
371+
bound, and only plain numeric versions are understood (a clause like
372+
``== 1.*`` is ignored). The check is conservative: ``False`` is
373+
returned whenever no clause proves the minimum version is respected.
374+
375+
Args:
376+
specifier: A PEP 440 version specifier, e.g. ``">= 1.2, < 2"``.
377+
min_version: The minimum version to check for, e.g. ``(1, 2)``.
378+
379+
Returns:
380+
Whether `specifier` guarantees a version of at least `min_version`.
381+
"""
382+
for clause in specifier.split(","):
383+
clause_match = re.match(r"(==|>=|~=|>)\s*v?(\d+(?:\.\d+)*)$", clause.strip())
384+
if clause_match is None:
385+
continue
386+
version = tuple(int(part) for part in clause_match.group(2).split("."))
387+
if version >= min_version:
388+
return True
389+
return False
390+
391+
361392
def migrate_pytest_addopts_default() -> None:
362393
"""Remove the default ``-vv`` from pytest addopts in ``pyproject.toml``.
363394
@@ -408,7 +439,7 @@ def migrate_pytest_addopts_default() -> None:
408439
if addopts_match is None:
409440
print(
410441
f" Skipped {pyproject}: no addopts in [tool.pytest.ini_options], "
411-
"nothiing to remove"
442+
"nothing to remove"
412443
)
413444
return
414445

@@ -726,6 +757,224 @@ def migrate_build_dependencies() -> None:
726757
)
727758

728759

760+
def bump_pytest_asyncio_requirement(
761+
pyproject: Path, content: str, old_specifiers: list[str]
762+
) -> str | None:
763+
"""Bump too old simple pytest-asyncio requirements to ``>= 1.2.0``.
764+
765+
Requirements declared as a plain ``>= X.Y[.Z]`` lower bound are bumped
766+
to ``>= 1.2.0`` (where the ``asyncio_debug`` option was added) and the
767+
updated ``pyproject.toml`` is written to disk. The new lower bound does
768+
not affect which version gets installed (that still resolves to the
769+
latest allowed version), it only guarantees a version that understands
770+
the option.
771+
772+
A manual step is emitted instead for more complex specifiers or when
773+
the bump would cross a major version boundary, as that might bring
774+
other incompatible changes.
775+
776+
Args:
777+
pyproject: The path to the ``pyproject.toml`` file to update.
778+
content: The current contents of `pyproject`.
779+
old_specifiers: The pytest-asyncio version specifiers (stripped)
780+
that do not guarantee ``asyncio_debug`` support.
781+
782+
Returns:
783+
The updated contents of `pyproject` (already written to disk), or
784+
`None` if a manual step was emitted instead.
785+
"""
786+
upgrade_manually = (
787+
f"{pyproject} requires `pytest-asyncio {old_specifiers[0]}`, which "
788+
"does not guarantee the >= 1.2.0 needed by the `asyncio_debug` "
789+
"option (older versions emit a `PytestConfigWarning`, which "
790+
'`filterwarnings = ["error", ...]` turns into a test failure). '
791+
"Please upgrade pytest-asyncio and add `asyncio_debug = true` to "
792+
"`[tool.pytest.ini_options]` manually."
793+
)
794+
795+
simple_versions: list[str] = []
796+
for specifier in old_specifiers:
797+
simple_match = re.match(r">=\s*v?(\d+(?:\.\d+){0,2})$", specifier)
798+
if simple_match is None:
799+
manual_step(upgrade_manually)
800+
return None
801+
simple_versions.append(simple_match.group(1))
802+
803+
crossing_major = [
804+
specifier
805+
for specifier, version in zip(old_specifiers, simple_versions)
806+
if version.split(".")[0] != "1"
807+
]
808+
if crossing_major:
809+
manual_step(
810+
f"{pyproject} requires `pytest-asyncio {crossing_major[0]}`, but "
811+
"the `asyncio_debug` option needs pytest-asyncio >= 1.2.0 and "
812+
"the upgrade crosses a major version boundary, which might "
813+
"include other incompatible changes. Please upgrade "
814+
"pytest-asyncio and add `asyncio_debug = true` to "
815+
"`[tool.pytest.ini_options]` manually."
816+
)
817+
return None
818+
819+
def bump(match: re.Match[str]) -> str:
820+
version = tuple(int(part) for part in match.group(2).split(".") if part)
821+
if version >= (1, 2):
822+
return match.group(0)
823+
quote = match.group(1)
824+
return f"{quote}pytest-asyncio >= 1.2.0{quote}"
825+
826+
new_content = re.sub(r"""(["'])pytest-asyncio\s*>=\s*v?([\d.]+)\1""", bump, content)
827+
if new_content == content:
828+
manual_step(upgrade_manually)
829+
return None
830+
831+
try:
832+
replace_file_atomically(pyproject, new_content)
833+
except OSError as exc:
834+
manual_step(
835+
f"Failed to update {pyproject}: {exc}. Please upgrade "
836+
"pytest-asyncio to >= 1.2.0 and add `asyncio_debug = true` to "
837+
"`[tool.pytest.ini_options]` manually."
838+
)
839+
return None
840+
841+
print(
842+
f" Updated {pyproject}: bumped the pytest-asyncio requirement to "
843+
"`>= 1.2.0`, as needed by the `asyncio_debug` option"
844+
)
845+
return new_content
846+
847+
848+
def migrate_pytest_asyncio_debug() -> None:
849+
"""Enable asyncio debug mode in pytest when ``pytest-asyncio`` is used.
850+
851+
The step is skipped for projects that do not mention ``pytest-asyncio``
852+
in their ``pyproject.toml`` at all, as there is nothing to configure for
853+
them. Projects that already configure ``asyncio_debug`` are also left
854+
untouched, because the existing value represents an explicit project
855+
choice.
856+
857+
The ``asyncio_debug`` option was added in pytest-asyncio 1.2.0, and older
858+
versions emit a ``PytestConfigWarning`` for it, which the template's
859+
``filterwarnings = ["error", ...]`` configuration turns into a test
860+
failure. Requirements declared as a plain ``>= X.Y[.Z]`` lower bound are
861+
bumped to ``>= 1.2.0`` automatically when the bump stays within the same
862+
major version; a manual step is emitted instead for more complex
863+
specifiers or when the upgrade would cross a major version boundary.
864+
"""
865+
pyproject = Path("pyproject.toml")
866+
if not pyproject.exists():
867+
manual_step(
868+
f"{pyproject} not found. Please add "
869+
"`asyncio_debug = true` to `[tool.pytest.ini_options]` manually."
870+
)
871+
return
872+
873+
try:
874+
content = pyproject.read_text(encoding="utf-8")
875+
except OSError as exc:
876+
manual_step(
877+
f"Failed to read {pyproject}: {exc}. If the project uses "
878+
"pytest-asyncio, please add `asyncio_debug = true` to "
879+
"`[tool.pytest.ini_options]` manually."
880+
)
881+
return
882+
883+
if "pytest-asyncio" not in content:
884+
print(
885+
f" Skipped {pyproject}: the project does not use pytest-asyncio, "
886+
"there is nothing to update"
887+
)
888+
return
889+
890+
pytest_section_match = re.search(
891+
r"(?ms)^\[tool\.pytest\.ini_options\]\n.*?(?=^\[|\Z)",
892+
content,
893+
)
894+
if pytest_section_match is None:
895+
manual_step(
896+
f"{pyproject} uses pytest-asyncio but has no "
897+
"`[tool.pytest.ini_options]` section; please add the section and "
898+
"set `asyncio_debug = true` manually."
899+
)
900+
return
901+
902+
pytest_section = pytest_section_match.group(0)
903+
asyncio_debug_match = re.search(
904+
r"^asyncio_debug\s*=.*$", pytest_section, flags=re.MULTILINE
905+
)
906+
if asyncio_debug_match is not None:
907+
print(f" Skipped {pyproject}: {asyncio_debug_match.group(0)} is already set")
908+
return
909+
910+
specifiers = re.findall(r"""["']pytest-asyncio\s*([=><~!][^"']*)["']""", content)
911+
if not specifiers:
912+
manual_step(
913+
f"{pyproject} does not declare a pytest-asyncio version, so it is "
914+
"not possible to tell if it supports the `asyncio_debug` option "
915+
"(added in pytest-asyncio 1.2.0; older versions emit a "
916+
'`PytestConfigWarning`, which `filterwarnings = ["error", ...]` '
917+
"turns into a test failure). Please make sure pytest-asyncio >= "
918+
"1.2.0 is used and add `asyncio_debug = true` to "
919+
"`[tool.pytest.ini_options]` manually."
920+
)
921+
return
922+
923+
old_specifiers = [
924+
specifier.strip()
925+
for specifier in specifiers
926+
if not specifier_ensures_min_version(specifier, (1, 2))
927+
]
928+
if old_specifiers:
929+
bumped_content = bump_pytest_asyncio_requirement(
930+
pyproject, content, old_specifiers
931+
)
932+
if bumped_content is None:
933+
return
934+
content = bumped_content
935+
pytest_section_match = re.search(
936+
r"(?ms)^\[tool\.pytest\.ini_options\]\n.*?(?=^\[|\Z)", content
937+
)
938+
if pytest_section_match is None:
939+
manual_step(
940+
f"{pyproject} has no `[tool.pytest.ini_options]` section after "
941+
"updating the pytest-asyncio requirement; please add the "
942+
"section and set `asyncio_debug = true` manually."
943+
)
944+
return
945+
pytest_section = pytest_section_match.group(0)
946+
947+
asyncio_mode_match = re.search(
948+
r"^asyncio_mode\s*=.*$", pytest_section, flags=re.MULTILINE
949+
)
950+
if asyncio_mode_match is None:
951+
manual_step(
952+
f"{pyproject} uses pytest-asyncio but has no `asyncio_mode` setting "
953+
"under `[tool.pytest.ini_options]`; please add "
954+
"`asyncio_debug = true` there manually."
955+
)
956+
return
957+
958+
new_pytest_section = (
959+
pytest_section[: asyncio_mode_match.start()]
960+
+ "asyncio_debug = true\n"
961+
+ pytest_section[asyncio_mode_match.start() :]
962+
)
963+
new_content = content.replace(pytest_section, new_pytest_section, 1)
964+
965+
try:
966+
replace_file_atomically(pyproject, new_content)
967+
print(
968+
f" Updated {pyproject}: enabled asyncio debug mode under "
969+
"[tool.pytest.ini_options]"
970+
)
971+
except OSError as exc:
972+
manual_step(
973+
f"Failed to update {pyproject}: {exc}. Please add "
974+
"`asyncio_debug = true` to `[tool.pytest.ini_options]` manually."
975+
)
976+
977+
729978
def manual_step(message: str) -> None:
730979
"""Print a manual step message in yellow."""
731980
_manual_steps.append(message)

cookiecutter/{{cookiecutter.github_repo_name}}/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ filterwarnings = [
225225
]
226226
{%- if cookiecutter.type != "api" %}
227227
testpaths = ["tests", "src"]
228+
asyncio_debug = true
228229
asyncio_mode = "auto"
229230
asyncio_default_fixture_loop_scope = "function"
230231
required_plugins = ["pytest-asyncio", "pytest-mock"]

tests_golden/integration/test_cookiecutter_generation/actor-proprietary/frequenz-actor-test/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ filterwarnings = [
166166
'ignore:Protobuf gencode version .*exactly one major version older.*:UserWarning',
167167
]
168168
testpaths = ["tests", "src"]
169+
asyncio_debug = true
169170
asyncio_mode = "auto"
170171
asyncio_default_fixture_loop_scope = "function"
171172
required_plugins = ["pytest-asyncio", "pytest-mock"]

tests_golden/integration/test_cookiecutter_generation/actor/frequenz-actor-test/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ filterwarnings = [
166166
'ignore:Protobuf gencode version .*exactly one major version older.*:UserWarning',
167167
]
168168
testpaths = ["tests", "src"]
169+
asyncio_debug = true
169170
asyncio_mode = "auto"
170171
asyncio_default_fixture_loop_scope = "function"
171172
required_plugins = ["pytest-asyncio", "pytest-mock"]

tests_golden/integration/test_cookiecutter_generation/app-proprietary/frequenz-app-test/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ filterwarnings = [
165165
'ignore:Protobuf gencode version .*exactly one major version older.*:UserWarning',
166166
]
167167
testpaths = ["tests", "src"]
168+
asyncio_debug = true
168169
asyncio_mode = "auto"
169170
asyncio_default_fixture_loop_scope = "function"
170171
required_plugins = ["pytest-asyncio", "pytest-mock"]

tests_golden/integration/test_cookiecutter_generation/app/frequenz-app-test/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ filterwarnings = [
165165
'ignore:Protobuf gencode version .*exactly one major version older.*:UserWarning',
166166
]
167167
testpaths = ["tests", "src"]
168+
asyncio_debug = true
168169
asyncio_mode = "auto"
169170
asyncio_default_fixture_loop_scope = "function"
170171
required_plugins = ["pytest-asyncio", "pytest-mock"]

tests_golden/integration/test_cookiecutter_generation/lib-proprietary/frequenz-test-python/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ filterwarnings = [
162162
'ignore:Protobuf gencode version .*exactly one major version older.*:UserWarning',
163163
]
164164
testpaths = ["tests", "src"]
165+
asyncio_debug = true
165166
asyncio_mode = "auto"
166167
asyncio_default_fixture_loop_scope = "function"
167168
required_plugins = ["pytest-asyncio", "pytest-mock"]

tests_golden/integration/test_cookiecutter_generation/lib/frequenz-test-python/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ filterwarnings = [
162162
'ignore:Protobuf gencode version .*exactly one major version older.*:UserWarning',
163163
]
164164
testpaths = ["tests", "src"]
165+
asyncio_debug = true
165166
asyncio_mode = "auto"
166167
asyncio_default_fixture_loop_scope = "function"
167168
required_plugins = ["pytest-asyncio", "pytest-mock"]

tests_golden/integration/test_cookiecutter_generation/model-proprietary/frequenz-model-test/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ filterwarnings = [
166166
'ignore:Protobuf gencode version .*exactly one major version older.*:UserWarning',
167167
]
168168
testpaths = ["tests", "src"]
169+
asyncio_debug = true
169170
asyncio_mode = "auto"
170171
asyncio_default_fixture_loop_scope = "function"
171172
required_plugins = ["pytest-asyncio", "pytest-mock"]

0 commit comments

Comments
 (0)