@@ -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+
361392def 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+
729978def manual_step (message : str ) -> None :
730979 """Print a manual step message in yellow."""
731980 _manual_steps .append (message )
0 commit comments