"""Flag patches that globally clobber ``asyncio`` module attributes. What goes wrong --------------- When test code writes:: with patch("omnigent.tools.mcp.asyncio.sleep", new_callable=AsyncMock): ... ``unittest.mock``'s dotted-path resolver walks: 1. ``import omnigent.tools.mcp`` 2. ``getattr(omnigent.tools.mcp, "asyncio")`` -> returns the ``asyncio`` module singleton (because production code did ``import asyncio`` at module top). 3. ``setattr(asyncio_module, "sleep", mock)`` -> globally clobbers ``asyncio.sleep`` for the entire process for the patch's lifetime. Under ``pytest-xdist``, concurrent tests in other workers that touch ``asyncio.sleep`` (directly or via any of the many helpers built on top of it) silently get the mock, causing hard-to-debug cross-test flakiness. The same bug shape applies to: - ``monkeypatch.setattr("...asyncio.sleep", ...)`` (pytest) - ``patch.object(mod.asyncio, "sleep", ...)`` What this catches ----------------- 1. ``patch("...asyncio.", ...)`` / ``mock.patch(...)`` where the first positional arg is a string literal containing ``.asyncio.`` or ending in ``.asyncio``. 2. ``monkeypatch.setattr("...asyncio.", ...)`` with the same string-literal shape. 3. ``patch.object(, "", ...)`` where ```` is an attribute access whose leaf attribute is ``asyncio`` (e.g. ``patch.object(mcp_mod.asyncio, "sleep", ...)``). Correct shapes -------------- Option A (preferred, when production has few sleep sites): add a thin ``_sleep`` indirection in the production module and patch ``module._sleep`` from tests. The real ``asyncio.sleep`` is never touched. Option B (when A is too invasive): replace the ``asyncio`` binding in the target module's namespace with a ``SimpleNamespace`` that mirrors the real module but with the desired attribute swapped. The real ``asyncio`` module is left alone. Exit code 1 if any hits are found. """ from __future__ import annotations import ast import sys from pathlib import Path def _is_global_asyncio_literal(value: str) -> bool: """Return True if ``value`` is a one-segment getattr off the stdlib ``asyncio`` module name (the shape that clobbers the singleton). The dangerous shape is:: patch("..asyncio.", ...) where ```` is exactly one segment. The dotted-path resolver imports ``.``, calls ``getattr(_, "asyncio")`` -- which returns the stdlib ``asyncio`` module singleton because the consumer did ``import asyncio`` -- then sets ```` on it. NOT flagged: * ``".asyncio.."`` -- here ``asyncio`` is a subpackage name (e.g. ``websockets.asyncio.client``) and the resolver descends *through* a package directory, not through a getattr on the stdlib module. * ``".asyncio"`` -- the bare module path is used to *replace* the binding (the safe Option B pattern), not to clobber an attribute inside it. :param value: The dotted-path string passed to ``patch`` / ``monkeypatch.setattr``. :returns: ``True`` if the path has exactly one segment after the last ``.asyncio.``. """ marker = ".asyncio." idx = value.rfind(marker) if idx == -1: return False tail = value[idx + len(marker) :] # Exactly one segment after `.asyncio.` -> getattr on the stdlib # module singleton. Two or more segments -> the path descends # through a subpackage named `asyncio`. return bool(tail) and "." not in tail def _is_patch_func(func: ast.expr) -> bool: """Return True if ``func`` spells ``patch`` or ``mock.patch``. Plain ``patch.object`` is handled separately -- this only matches the dotted-path form ``patch("...")`` / ``mock.patch("...")``. """ if isinstance(func, ast.Name) and func.id == "patch": return True if isinstance(func, ast.Attribute) and func.attr == "patch": return True return False def _is_patch_object(func: ast.expr) -> bool: """Return True if ``func`` spells ``patch.object`` or ``mock.patch.object``.""" if not isinstance(func, ast.Attribute) or func.attr != "object": return False inner = func.value if isinstance(inner, ast.Name) and inner.id == "patch": return True if isinstance(inner, ast.Attribute) and inner.attr == "patch": return True return False def _is_monkeypatch_setattr(func: ast.expr) -> bool: """Return True if ``func`` spells ``.setattr``. Conservative: matches any attribute access where the leaf is ``setattr`` and the receiver is a ``Name`` containing ``monkeypatch`` (case-insensitive). This covers the common ``monkeypatch.setattr(...)`` and ``m.setattr(...)`` (from ``with monkeypatch.context() as m:``). :param func: The function-position expression of a ``Call``. :returns: ``True`` if this looks like ``monkeypatch.setattr``. """ if not isinstance(func, ast.Attribute) or func.attr != "setattr": return False base = func.value if isinstance(base, ast.Name): return "monkeypatch" in base.id.lower() or base.id == "m" return False def _attribute_ends_in_asyncio(node: ast.expr) -> bool: """Return True if ``node`` is an attribute access ending in ``.asyncio``. e.g. matches ``mcp_mod.asyncio`` and ``omnigent.tools.mcp.asyncio``. """ return isinstance(node, ast.Attribute) and node.attr == "asyncio" def _check_string_literal_first_arg(node: ast.Call) -> bool: """Return True if ``node.args[0]`` is a string literal flagged by :func:`_is_global_asyncio_literal`.""" if not node.args: return False first = node.args[0] if not isinstance(first, ast.Constant) or not isinstance(first.value, str): return False return _is_global_asyncio_literal(first.value) def scan(path: Path) -> list[tuple[int, str]]: """ Return ``[(lineno, snippet), ...]`` for each violation in ``path``. :param path: File to scan. :returns: One entry per flagged call site. """ try: source = path.read_text() tree = ast.parse(source) except (SyntaxError, UnicodeDecodeError): return [] source_lines = source.splitlines() hits: list[tuple[int, str]] = [] for node in ast.walk(tree): if not isinstance(node, ast.Call): continue if not _is_flagged_call(node): continue line = node.lineno snippet = source_lines[line - 1].strip() if 0 < line <= len(source_lines) else "" hits.append((line, snippet)) return hits def _is_flagged_call(node: ast.Call) -> bool: """Return True if ``node`` is one of the banned patch shapes. Covers three call shapes: 1. ``patch("...asyncio.", ...)`` / ``mock.patch(...)``. 2. ``monkeypatch.setattr("...asyncio.", ...)``. 3. ``patch.object(, "", ...)``. :param node: A ``Call`` node from the AST. :returns: ``True`` if the call matches a banned shape. """ # patch("...asyncio.", ...) / mock.patch("...asyncio.", ...) if _is_patch_func(node.func) and _check_string_literal_first_arg(node): return True # monkeypatch.setattr("...asyncio.", ...) if _is_monkeypatch_setattr(node.func) and _check_string_literal_first_arg(node): return True # patch.object(<...>.asyncio, "", ...) if ( _is_patch_object(node.func) and len(node.args) >= 1 and _attribute_ends_in_asyncio(node.args[0]) ): return True return False def main(argv: list[str]) -> int: """ Scan every file in ``argv[1:]`` and report violations. :param argv: Command-line args (``argv[0]`` is the script name). :returns: Exit code -- 0 on clean scan, 1 if any hits. """ failed = False for arg in argv[1:]: path = Path(arg) if not path.is_file(): continue hits = scan(path) if hits: failed = True for line, snippet in hits: sys.stdout.write( f"{path}:{line}: globally-clobbering asyncio patch detected: {snippet}\n" ) sys.stdout.write( " hint: see dev/lint/lint_no_global_asyncio_patch.py " "docstring for correct shapes.\n" ) if failed: sys.stdout.write( "\nPatching ``module.asyncio.sleep`` (or any other asyncio " "attribute via a dotted path that walks through the asyncio " "module) mutates the real asyncio module singleton, which " "leaks into every other test running in the same process " "(critical under pytest-xdist). Prefer adding a thin " "``_sleep`` indirection in the production module and " "patching that, or replace the module's ``asyncio`` binding " "wholesale with a ``SimpleNamespace`` -- see the script " "docstring for Option A / Option B examples.\n" ) return 1 return 0 if __name__ == "__main__": sys.exit(main(sys.argv))