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.
- python_vibe_guard-0.7.1.dist-info/METADATA +286 -0
- python_vibe_guard-0.7.1.dist-info/RECORD +31 -0
- python_vibe_guard-0.7.1.dist-info/WHEEL +5 -0
- python_vibe_guard-0.7.1.dist-info/entry_points.txt +2 -0
- python_vibe_guard-0.7.1.dist-info/top_level.txt +1 -0
- pyvibe/__init__.py +1 -0
- pyvibe/__main__.py +3 -0
- pyvibe/analyzer.py +163 -0
- pyvibe/cli.py +148 -0
- pyvibe/rules/__init__.py +0 -0
- pyvibe/rules/async_requests.py +65 -0
- pyvibe/rules/async_sleep.py +44 -0
- pyvibe/rules/asyncio_run.py +48 -0
- pyvibe/rules/base.py +65 -0
- pyvibe/rules/celery_time_limit.py +103 -0
- pyvibe/rules/contextvar_cleanup.py +129 -0
- pyvibe/rules/create_task_orphan.py +44 -0
- pyvibe/rules/ensure_future_orphan.py +44 -0
- pyvibe/rules/gather_no_return_exceptions.py +57 -0
- pyvibe/rules/httpx_client_sync.py +43 -0
- pyvibe/rules/httpx_sync.py +52 -0
- pyvibe/rules/loop_run_until_complete.py +37 -0
- pyvibe/rules/open_async.py +39 -0
- pyvibe/rules/os_blocking.py +49 -0
- pyvibe/rules/queue_put_nowait.py +98 -0
- pyvibe/rules/retry_no_backoff.py +255 -0
- pyvibe/rules/silent_except.py +107 -0
- pyvibe/rules/sqlite_async.py +82 -0
- pyvibe/rules/subprocess_async.py +54 -0
- pyvibe/rules/threading_lock.py +89 -0
- pyvibe/rules/while_true_no_await.py +111 -0
|
@@ -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)
|