python-vibe-guard 0.7.1__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.
@@ -0,0 +1,82 @@
1
+ import ast
2
+ from typing import List, Set
3
+ from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
4
+
5
+
6
+ class SqliteAsyncRule(AsyncBlockingCallVisitor):
7
+ """
8
+ PYVIBE-008 — sqlite3.connect() inside async def
9
+
10
+ sqlite3 is a synchronous library. connect() opens the database file with
11
+ blocking I/O and all subsequent cursor operations block the OS thread.
12
+ AI-generated FastAPI examples routinely use sqlite3 inside async handlers.
13
+
14
+ Fix: use `aiosqlite.connect()` with async context manager and await.
15
+ """
16
+
17
+ RULE_ID = "PYVIBE-008"
18
+ SEVERITY = "CRITICAL"
19
+
20
+ def __init__(self):
21
+ super().__init__()
22
+ # Names bound to the `sqlite3` module via `import sqlite3 [as X]`
23
+ self._sqlite3_aliases: Set[str] = set()
24
+ # Names imported directly from sqlite3: `from sqlite3 import connect`
25
+ self._from_sqlite3: Set[str] = set()
26
+
27
+ def visit_Import(self, node: ast.Import):
28
+ for alias in node.names:
29
+ if alias.name == "sqlite3":
30
+ self._sqlite3_aliases.add(alias.asname if alias.asname else alias.name)
31
+ self.generic_visit(node)
32
+
33
+ def visit_ImportFrom(self, node: ast.ImportFrom):
34
+ if node.module == "sqlite3":
35
+ for alias in node.names:
36
+ if alias.name == "connect":
37
+ self._from_sqlite3.add(alias.asname if alias.asname else alias.name)
38
+ self.generic_visit(node)
39
+
40
+ def visit_Call(self, node: ast.Call):
41
+ if self._current_async_func is None:
42
+ self.generic_visit(node)
43
+ return
44
+
45
+ if self._is_sqlite_connect(node):
46
+ self.violations.append(Violation(
47
+ rule_id=self.RULE_ID,
48
+ severity=self.SEVERITY,
49
+ line=node.lineno,
50
+ function_name=self._current_async_func,
51
+ message="sqlite3.connect() is synchronous — blocks the event loop during I/O",
52
+ evidence="Use `async with aiosqlite.connect('db.sqlite3') as db:` instead",
53
+ ))
54
+
55
+ self.generic_visit(node)
56
+
57
+ def _is_sqlite_connect(self, node: ast.Call) -> bool:
58
+ # sqlite3.connect(...)
59
+ # Guard against NAME_COLLISION: only flag when the receiver name is
60
+ # known to be bound to the sqlite3 module via an import statement.
61
+ # Without this, any `obj.connect(...)` where the variable happens to
62
+ # be named `sqlite3` would be a false positive.
63
+ if (
64
+ isinstance(node.func, ast.Attribute)
65
+ and node.func.attr == "connect"
66
+ and isinstance(node.func.value, ast.Name)
67
+ and node.func.value.id in self._sqlite3_aliases
68
+ ):
69
+ return True
70
+
71
+ # from sqlite3 import connect → connect(...)
72
+ # Guard against NAME_COLLISION: bare `connect()` is an extremely common
73
+ # name used by asyncpg, aiomysql, aiopg, websockets, aio-pika, asyncssh,
74
+ # and many other async libraries. Only flag when `connect` was explicitly
75
+ # imported from sqlite3 (a rare but real pattern: `from sqlite3 import connect`).
76
+ if (
77
+ isinstance(node.func, ast.Name)
78
+ and node.func.id in self._from_sqlite3
79
+ ):
80
+ return True
81
+
82
+ return False
@@ -0,0 +1,54 @@
1
+ import ast
2
+ from typing import List
3
+ from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
4
+
5
+ SUBPROCESS_BLOCKING = {"run", "call", "check_output", "Popen"}
6
+
7
+
8
+ class SubprocessAsyncRule(AsyncBlockingCallVisitor):
9
+ """
10
+ PYVIBE-007 — subprocess blocking calls inside async def
11
+
12
+ subprocess.run / call / check_output / Popen all block the OS thread
13
+ running the event loop for the entire duration of the subprocess.
14
+ Under concurrent load this serialises execution and eliminates the
15
+ benefit of async.
16
+
17
+ Fix: use asyncio.create_subprocess_exec() or
18
+ asyncio.create_subprocess_shell() with await proc.communicate().
19
+ """
20
+
21
+ RULE_ID = "PYVIBE-007"
22
+ SEVERITY = "CRITICAL"
23
+
24
+ def visit_Call(self, node: ast.Call):
25
+ if self._current_async_func is None:
26
+ self.generic_visit(node)
27
+ return
28
+
29
+ method = self._get_subprocess_method(node)
30
+ if method:
31
+ self.violations.append(Violation(
32
+ rule_id=self.RULE_ID,
33
+ severity=self.SEVERITY,
34
+ line=node.lineno,
35
+ function_name=self._current_async_func,
36
+ message=f"subprocess.{method}() blocks the event loop for the entire subprocess duration",
37
+ evidence="Use `proc = await asyncio.create_subprocess_exec(*cmd)` then `await proc.communicate()`",
38
+ ))
39
+
40
+ self.generic_visit(node)
41
+
42
+ def _get_subprocess_method(self, node: ast.Call):
43
+ # subprocess.run(...), subprocess.Popen(...), etc. — qualified form only
44
+ # Bare names (run, call…) excluded: 'call' is too generic and caused a
45
+ # false positive in FastAPI where 'call' is a local dependency variable.
46
+ if (
47
+ isinstance(node.func, ast.Attribute)
48
+ and node.func.attr in SUBPROCESS_BLOCKING
49
+ and isinstance(node.func.value, ast.Name)
50
+ and node.func.value.id == "subprocess"
51
+ ):
52
+ return node.func.attr
53
+
54
+ return None
@@ -0,0 +1,89 @@
1
+ import ast
2
+ from typing import List, Set
3
+ from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
4
+
5
+ # threading.Event is a signalling primitive (set/wait), not a lock — it does not
6
+ # block the event loop in the same way and is legitimately used as a sync↔async
7
+ # bridge (e.g. signalling a Playwright or subprocess thread). Excluded deliberately.
8
+ THREADING_PRIMITIVES = {"Lock", "RLock", "Semaphore", "BoundedSemaphore", "Condition"}
9
+
10
+
11
+ class ThreadingLockRule(AsyncBlockingCallVisitor):
12
+ """
13
+ PYVIBE-004 — threading.Lock (and other threading primitives) inside async def
14
+
15
+ threading.Lock.acquire() is a blocking call. When used inside an async
16
+ function it blocks the OS thread, preventing the event loop from
17
+ scheduling other coroutines. This is the async equivalent of
18
+ synchronized + Virtual Threads pinning in Java.
19
+
20
+ AI models mix threading and asyncio when they copy sync patterns into
21
+ async code — the bug only surfaces under concurrent load.
22
+
23
+ Fix: use `asyncio.Lock()` with `async with lock:`.
24
+ """
25
+
26
+ RULE_ID = "PYVIBE-004"
27
+ SEVERITY = "CRITICAL"
28
+
29
+ def __init__(self):
30
+ super().__init__()
31
+ # Names bound to the `threading` module via `import threading [as X]`
32
+ self._threading_aliases: Set[str] = set()
33
+ # Names imported directly from threading: `from threading import Lock`
34
+ self._from_threading: Set[str] = set()
35
+
36
+ def visit_Import(self, node: ast.Import):
37
+ for alias in node.names:
38
+ if alias.name == "threading":
39
+ self._threading_aliases.add(alias.asname if alias.asname else alias.name)
40
+ self.generic_visit(node)
41
+
42
+ def visit_ImportFrom(self, node: ast.ImportFrom):
43
+ if node.module == "threading":
44
+ for alias in node.names:
45
+ if alias.name in THREADING_PRIMITIVES:
46
+ self._from_threading.add(alias.asname if alias.asname else alias.name)
47
+ self.generic_visit(node)
48
+
49
+ def visit_Call(self, node: ast.Call):
50
+ if self._current_async_func is None:
51
+ self.generic_visit(node)
52
+ return
53
+
54
+ primitive = self._get_threading_primitive(node)
55
+ if primitive:
56
+ self.violations.append(Violation(
57
+ rule_id=self.RULE_ID,
58
+ severity=self.SEVERITY,
59
+ line=node.lineno,
60
+ function_name=self._current_async_func,
61
+ message=f"threading.{primitive}() blocks the event loop under contention",
62
+ evidence="Use `asyncio.Lock()` with `async with lock:` instead",
63
+ ))
64
+
65
+ self.generic_visit(node)
66
+
67
+ def _get_threading_primitive(self, node: ast.Call):
68
+ # threading.Lock(), threading.RLock(), etc.
69
+ # Guard against NAME_COLLISION: only flag when the receiver is known to
70
+ # be bound to the `threading` module via an import statement.
71
+ if (
72
+ isinstance(node.func, ast.Attribute)
73
+ and node.func.attr in THREADING_PRIMITIVES
74
+ and isinstance(node.func.value, ast.Name)
75
+ and node.func.value.id in self._threading_aliases
76
+ ):
77
+ return node.func.attr
78
+
79
+ # from threading import Lock → Lock()
80
+ # Guard against NAME_COLLISION: bare names only fire when the name was
81
+ # explicitly imported from threading (e.g. anyio.Lock or a custom Lock
82
+ # class would not be in _from_threading and are correctly skipped).
83
+ if (
84
+ isinstance(node.func, ast.Name)
85
+ and node.func.id in self._from_threading
86
+ ):
87
+ return node.func.id
88
+
89
+ return None
@@ -0,0 +1,111 @@
1
+ import ast
2
+ from typing import List, Optional
3
+ from pyvibe.rules.base import Violation
4
+
5
+
6
+ def _has_await_in_scope(nodes) -> bool:
7
+ """Return True if any await exists in the given nodes, not crossing function boundaries."""
8
+ for node in nodes:
9
+ if isinstance(node, ast.Await):
10
+ return True
11
+ if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)):
12
+ # new scope — awaits inside don't help the outer loop
13
+ continue
14
+ for child in ast.iter_child_nodes(node):
15
+ if _has_await_in_scope([child]):
16
+ return True
17
+ return False
18
+
19
+
20
+ def _has_yield_in_body(nodes) -> bool:
21
+ """Return True if body contains yield/yield-from, not crossing function boundaries.
22
+
23
+ An async def that yields is an async generator — each `yield` suspends the
24
+ generator and gives control back to the caller's `await __anext__()`, so it
25
+ IS a valid event-loop checkpoint and must not be flagged by PYVIBE-018.
26
+ """
27
+ for node in nodes:
28
+ if isinstance(node, (ast.Yield, ast.YieldFrom)):
29
+ return True
30
+ if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)):
31
+ continue # inner function's yields are its own scope
32
+ for child in ast.iter_child_nodes(node):
33
+ if _has_yield_in_body([child]):
34
+ return True
35
+ return False
36
+
37
+
38
+ class WhileTrueNoAwaitRule(ast.NodeVisitor):
39
+ """
40
+ PYVIBE-018 — while True without await inside async def
41
+
42
+ An infinite loop in an async function with no await point monopolises the
43
+ event loop indefinitely — no other coroutine can run, and CPU usage hits
44
+ 100%. AI-generated worker loops frequently omit the await.
45
+
46
+ Fix: add `await asyncio.sleep(0)` (yield control) or any real await inside
47
+ the loop body.
48
+
49
+ Exclusion: async generators (async def bodies that contain `yield` or
50
+ `yield from`) are not flagged — each `yield` suspends the generator and the
51
+ caller's `await __anext__()` is a real event-loop checkpoint.
52
+ """
53
+
54
+ RULE_ID = "PYVIBE-018"
55
+ SEVERITY = "CRITICAL"
56
+
57
+ def __init__(self):
58
+ self.violations: List[Violation] = []
59
+ self._current_async_func: Optional[str] = None
60
+ self._current_func_is_async_gen: bool = False
61
+
62
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
63
+ previous = self._current_async_func
64
+ previous_is_gen = self._current_func_is_async_gen
65
+ self._current_async_func = node.name
66
+ self._current_func_is_async_gen = _has_yield_in_body(node.body)
67
+ self.generic_visit(node)
68
+ self._current_async_func = previous
69
+ self._current_func_is_async_gen = previous_is_gen
70
+
71
+ def visit_FunctionDef(self, node: ast.FunctionDef):
72
+ previous = self._current_async_func
73
+ previous_is_gen = self._current_func_is_async_gen
74
+ self._current_async_func = None
75
+ self._current_func_is_async_gen = False
76
+ self.generic_visit(node)
77
+ self._current_async_func = previous
78
+ self._current_func_is_async_gen = previous_is_gen
79
+
80
+ def visit_Lambda(self, node: ast.Lambda):
81
+ previous = self._current_async_func
82
+ previous_is_gen = self._current_func_is_async_gen
83
+ self._current_async_func = None
84
+ self._current_func_is_async_gen = False
85
+ self.generic_visit(node)
86
+ self._current_async_func = previous
87
+ self._current_func_is_async_gen = previous_is_gen
88
+
89
+ def visit_While(self, node: ast.While):
90
+ if (
91
+ self._current_async_func
92
+ and not self._current_func_is_async_gen
93
+ and isinstance(node.test, ast.Constant)
94
+ and node.test.value is True
95
+ and not _has_await_in_scope(node.body)
96
+ ):
97
+ self.violations.append(Violation(
98
+ rule_id=self.RULE_ID,
99
+ severity=self.SEVERITY,
100
+ line=node.lineno,
101
+ function_name=self._current_async_func,
102
+ message=(
103
+ "while True loop inside async def has no await — "
104
+ "event loop blocked indefinitely"
105
+ ),
106
+ evidence=(
107
+ "Add `await asyncio.sleep(0)` to yield control, "
108
+ "or `await asyncio.sleep(N)` for a real polling interval"
109
+ ),
110
+ ))
111
+ self.generic_visit(node)