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,52 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from typing import List
|
|
3
|
+
from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
|
|
4
|
+
|
|
5
|
+
HTTPX_BLOCKING = {"get", "post", "put", "patch", "delete", "request", "stream"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HttpxSyncRule(AsyncBlockingCallVisitor):
|
|
9
|
+
"""
|
|
10
|
+
PYVIBE-010 — httpx top-level sync methods inside async def
|
|
11
|
+
|
|
12
|
+
httpx exposes both a synchronous API (httpx.get, httpx.post, …) and an
|
|
13
|
+
async API (httpx.AsyncClient). AI-generated code frequently uses the sync
|
|
14
|
+
top-level functions inside async handlers, blocking the OS thread for the
|
|
15
|
+
full HTTP round-trip.
|
|
16
|
+
|
|
17
|
+
httpx.AsyncClient() has attr='AsyncClient' — outside HTTPX_BLOCKING —
|
|
18
|
+
so it is never matched by this rule.
|
|
19
|
+
|
|
20
|
+
Fix: async with httpx.AsyncClient() as client: response = await client.get(url)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
RULE_ID = "PYVIBE-010"
|
|
24
|
+
SEVERITY = "CRITICAL"
|
|
25
|
+
|
|
26
|
+
def visit_Call(self, node: ast.Call):
|
|
27
|
+
if self._current_async_func is None:
|
|
28
|
+
self.generic_visit(node)
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
method = self._get_httpx_method(node)
|
|
32
|
+
if method:
|
|
33
|
+
self.violations.append(Violation(
|
|
34
|
+
rule_id=self.RULE_ID,
|
|
35
|
+
severity=self.SEVERITY,
|
|
36
|
+
line=node.lineno,
|
|
37
|
+
function_name=self._current_async_func,
|
|
38
|
+
message=f"httpx.{method}() is synchronous — blocks the event loop for the full HTTP round-trip",
|
|
39
|
+
evidence=f"Use `async with httpx.AsyncClient() as client: await client.{method}(url)`",
|
|
40
|
+
))
|
|
41
|
+
|
|
42
|
+
self.generic_visit(node)
|
|
43
|
+
|
|
44
|
+
def _get_httpx_method(self, node: ast.Call):
|
|
45
|
+
if (
|
|
46
|
+
isinstance(node.func, ast.Attribute)
|
|
47
|
+
and node.func.attr in HTTPX_BLOCKING
|
|
48
|
+
and isinstance(node.func.value, ast.Name)
|
|
49
|
+
and node.func.value.id == "httpx"
|
|
50
|
+
):
|
|
51
|
+
return node.func.attr
|
|
52
|
+
return None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class LoopRunUntilCompleteRule(AsyncBlockingCallVisitor):
|
|
6
|
+
"""
|
|
7
|
+
PYVIBE-015 — loop.run_until_complete() inside async def
|
|
8
|
+
|
|
9
|
+
run_until_complete() blocks the calling OS thread until the coroutine
|
|
10
|
+
finishes. Calling it from inside an already-running async function raises
|
|
11
|
+
RuntimeError at runtime ("This event loop is already running") and defeats
|
|
12
|
+
the entire purpose of async/await. AI-generated code that wraps existing
|
|
13
|
+
sync code often makes this mistake when trying to "bridge" sync and async.
|
|
14
|
+
|
|
15
|
+
Fix: replace `loop.run_until_complete(coro())` with `await coro()` directly.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
RULE_ID = "PYVIBE-015"
|
|
19
|
+
SEVERITY = "CRITICAL"
|
|
20
|
+
|
|
21
|
+
def visit_Call(self, node: ast.Call):
|
|
22
|
+
if self._current_async_func and self._is_run_until_complete(node):
|
|
23
|
+
self.violations.append(Violation(
|
|
24
|
+
rule_id=self.RULE_ID,
|
|
25
|
+
severity=self.SEVERITY,
|
|
26
|
+
line=node.lineno,
|
|
27
|
+
function_name=self._current_async_func,
|
|
28
|
+
message="loop.run_until_complete() inside async def — raises RuntimeError at runtime (event loop already running)",
|
|
29
|
+
evidence="Replace with `await coro()` directly; the enclosing async function is already on the event loop",
|
|
30
|
+
))
|
|
31
|
+
self.generic_visit(node)
|
|
32
|
+
|
|
33
|
+
def _is_run_until_complete(self, node: ast.Call) -> bool:
|
|
34
|
+
return (
|
|
35
|
+
isinstance(node.func, ast.Attribute)
|
|
36
|
+
and node.func.attr == "run_until_complete"
|
|
37
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from typing import List
|
|
3
|
+
from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OpenAsyncRule(AsyncBlockingCallVisitor):
|
|
7
|
+
"""
|
|
8
|
+
PYVIBE-009 — open() builtin inside async def
|
|
9
|
+
|
|
10
|
+
The builtin open() performs synchronous file I/O. Each call blocks the
|
|
11
|
+
OS thread until the kernel completes the read/write, preventing the event
|
|
12
|
+
loop from scheduling other coroutines. AI-generated FastAPI code routinely
|
|
13
|
+
mixes open() with async endpoints.
|
|
14
|
+
|
|
15
|
+
aiofiles.open() is an ast.Attribute node — structurally different from the
|
|
16
|
+
bare ast.Name 'open', so it is never matched by this rule.
|
|
17
|
+
|
|
18
|
+
Fix: use `async with aiofiles.open(path) as f: content = await f.read()`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
RULE_ID = "PYVIBE-009"
|
|
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
|
+
if isinstance(node.func, ast.Name) and node.func.id == "open":
|
|
30
|
+
self.violations.append(Violation(
|
|
31
|
+
rule_id=self.RULE_ID,
|
|
32
|
+
severity=self.SEVERITY,
|
|
33
|
+
line=node.lineno,
|
|
34
|
+
function_name=self._current_async_func,
|
|
35
|
+
message="open() performs synchronous file I/O — blocks the event loop",
|
|
36
|
+
evidence="Use `async with aiofiles.open(path) as f: content = await f.read()`",
|
|
37
|
+
))
|
|
38
|
+
|
|
39
|
+
self.generic_visit(node)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from typing import List
|
|
3
|
+
from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
|
|
4
|
+
|
|
5
|
+
OS_BLOCKING = {"system", "popen", "waitpid"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OsBlockingRule(AsyncBlockingCallVisitor):
|
|
9
|
+
"""
|
|
10
|
+
PYVIBE-011 — os.system / os.popen / os.waitpid inside async def
|
|
11
|
+
|
|
12
|
+
These OS calls block the thread until the child process or I/O completes.
|
|
13
|
+
Unlike subprocess, they have no direct async equivalent and are commonly
|
|
14
|
+
generated when AI migrates sync scripts to async without replacing the
|
|
15
|
+
OS interaction layer.
|
|
16
|
+
|
|
17
|
+
Fix: use asyncio.create_subprocess_shell() with await proc.communicate().
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
RULE_ID = "PYVIBE-011"
|
|
21
|
+
SEVERITY = "CRITICAL"
|
|
22
|
+
|
|
23
|
+
def visit_Call(self, node: ast.Call):
|
|
24
|
+
if self._current_async_func is None:
|
|
25
|
+
self.generic_visit(node)
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
method = self._get_os_blocking_method(node)
|
|
29
|
+
if method:
|
|
30
|
+
self.violations.append(Violation(
|
|
31
|
+
rule_id=self.RULE_ID,
|
|
32
|
+
severity=self.SEVERITY,
|
|
33
|
+
line=node.lineno,
|
|
34
|
+
function_name=self._current_async_func,
|
|
35
|
+
message=f"os.{method}() blocks the OS thread — no direct async equivalent",
|
|
36
|
+
evidence="Use `proc = await asyncio.create_subprocess_shell(cmd)` then `await proc.communicate()`",
|
|
37
|
+
))
|
|
38
|
+
|
|
39
|
+
self.generic_visit(node)
|
|
40
|
+
|
|
41
|
+
def _get_os_blocking_method(self, node: ast.Call):
|
|
42
|
+
if (
|
|
43
|
+
isinstance(node.func, ast.Attribute)
|
|
44
|
+
and node.func.attr in OS_BLOCKING
|
|
45
|
+
and isinstance(node.func.value, ast.Name)
|
|
46
|
+
and node.func.value.id == "os"
|
|
47
|
+
):
|
|
48
|
+
return node.func.attr
|
|
49
|
+
return None
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
from pyvibe.rules.base import Violation
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _catches_queuefull(handlers: list) -> bool:
|
|
7
|
+
"""True if any handler catches asyncio.QueueFull, QueueFull, bare except, or Exception."""
|
|
8
|
+
for h in handlers:
|
|
9
|
+
t = h.type
|
|
10
|
+
if t is None: # bare except — catches everything
|
|
11
|
+
return True
|
|
12
|
+
if isinstance(t, ast.Name) and t.id in ("QueueFull", "Exception"):
|
|
13
|
+
return True
|
|
14
|
+
if isinstance(t, ast.Attribute) and t.attr in ("QueueFull", "Exception"):
|
|
15
|
+
return True
|
|
16
|
+
if isinstance(t, ast.Tuple): # except (QueueFull, ...) — tuple of types
|
|
17
|
+
for elt in t.elts:
|
|
18
|
+
if isinstance(elt, ast.Name) and elt.id in ("QueueFull", "Exception"):
|
|
19
|
+
return True
|
|
20
|
+
if isinstance(elt, ast.Attribute) and elt.attr in ("QueueFull", "Exception"):
|
|
21
|
+
return True
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class QueuePutNowaitRule(ast.NodeVisitor):
|
|
26
|
+
"""
|
|
27
|
+
PYVIBE-020 — put_nowait() without asyncio.QueueFull handler
|
|
28
|
+
|
|
29
|
+
asyncio.Queue.put_nowait() raises asyncio.QueueFull immediately when the
|
|
30
|
+
queue is at maxsize. Unless caught, the exception propagates and the item
|
|
31
|
+
is silently lost — no log, no retry, no metric. This affects any queue
|
|
32
|
+
with maxsize > 0 (unbounded queues never raise, but maxsize is often set
|
|
33
|
+
later or injected).
|
|
34
|
+
|
|
35
|
+
Fix: wrap in try/except asyncio.QueueFull and decide explicitly what to
|
|
36
|
+
do — log and drop, or switch to `await queue.put()` to block instead.
|
|
37
|
+
|
|
38
|
+
Fires in any function context (sync and async). The guard is the absence
|
|
39
|
+
of an enclosing try/except that catches asyncio.QueueFull or Exception.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
RULE_ID = "PYVIBE-020"
|
|
43
|
+
SEVERITY = "WARNING"
|
|
44
|
+
|
|
45
|
+
def __init__(self):
|
|
46
|
+
self.violations: List[Violation] = []
|
|
47
|
+
self._current_func: Optional[str] = None
|
|
48
|
+
self._protected_depth: int = 0 # try/except with QueueFull handler nesting depth
|
|
49
|
+
|
|
50
|
+
def visit_FunctionDef(self, node: ast.FunctionDef):
|
|
51
|
+
prev = self._current_func
|
|
52
|
+
self._current_func = node.name
|
|
53
|
+
self.generic_visit(node)
|
|
54
|
+
self._current_func = prev
|
|
55
|
+
|
|
56
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
|
57
|
+
prev = self._current_func
|
|
58
|
+
self._current_func = node.name
|
|
59
|
+
self.generic_visit(node)
|
|
60
|
+
self._current_func = prev
|
|
61
|
+
|
|
62
|
+
def visit_Try(self, node: ast.Try):
|
|
63
|
+
protected = _catches_queuefull(node.handlers)
|
|
64
|
+
if protected:
|
|
65
|
+
self._protected_depth += 1
|
|
66
|
+
for stmt in node.body:
|
|
67
|
+
self.visit(stmt)
|
|
68
|
+
if protected:
|
|
69
|
+
self._protected_depth -= 1
|
|
70
|
+
# handlers, orelse, finalbody are NOT covered by this try's own handlers
|
|
71
|
+
for handler in node.handlers:
|
|
72
|
+
self.visit(handler)
|
|
73
|
+
for stmt in node.orelse:
|
|
74
|
+
self.visit(stmt)
|
|
75
|
+
for stmt in node.finalbody:
|
|
76
|
+
self.visit(stmt)
|
|
77
|
+
|
|
78
|
+
def visit_Call(self, node: ast.Call):
|
|
79
|
+
if (
|
|
80
|
+
isinstance(node.func, ast.Attribute)
|
|
81
|
+
and node.func.attr == "put_nowait"
|
|
82
|
+
and self._protected_depth == 0
|
|
83
|
+
):
|
|
84
|
+
self.violations.append(Violation(
|
|
85
|
+
rule_id=self.RULE_ID,
|
|
86
|
+
severity=self.SEVERITY,
|
|
87
|
+
line=node.lineno,
|
|
88
|
+
function_name=self._current_func or "<module>",
|
|
89
|
+
message=(
|
|
90
|
+
"put_nowait() without asyncio.QueueFull handler — "
|
|
91
|
+
"raises silently if queue is full, item is lost"
|
|
92
|
+
),
|
|
93
|
+
evidence=(
|
|
94
|
+
"Wrap in `try/except asyncio.QueueFull:` and log or handle explicitly, "
|
|
95
|
+
"or use `await queue.put()` to block until space is available"
|
|
96
|
+
),
|
|
97
|
+
))
|
|
98
|
+
self.generic_visit(node)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
from pyvibe.rules.base import Violation
|
|
4
|
+
|
|
5
|
+
# Loop variable names that signal retry intent.
|
|
6
|
+
# Excluded: i, j, n, count, idx, chunk_start, page_num, dpynum — index/iteration variables.
|
|
7
|
+
_RETRY_VAR_NAMES = frozenset({"_"})
|
|
8
|
+
_RETRY_VAR_PREFIXES = ("attempt", "retry", "retries")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _ends_with_retry(body: list) -> bool:
|
|
12
|
+
"""True if the except body ends with continue.
|
|
13
|
+
|
|
14
|
+
`pass` alone is intentionally NOT counted. CLEANUP_PASS analysis (jun 2026)
|
|
15
|
+
showed 97/186 hits (52.2%) were pass-only handlers in cleanup blocks — all false
|
|
16
|
+
positives. `pass` lets the enclosing loop continue by fall-through rather than
|
|
17
|
+
expressing explicit retry intent.
|
|
18
|
+
"""
|
|
19
|
+
if not body:
|
|
20
|
+
return False
|
|
21
|
+
last = body[-1]
|
|
22
|
+
return isinstance(last, ast.Continue)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_retry_loop(node: ast.For) -> bool:
|
|
26
|
+
"""True if the for loop is a bounded retry: for _ in range(N) or for attempt in range(N).
|
|
27
|
+
|
|
28
|
+
Two conditions required:
|
|
29
|
+
1. Iterable must be range() — bounded count of attempts.
|
|
30
|
+
2. Loop variable must be anonymous (_) or carry retry-intent semantics
|
|
31
|
+
(name contains 'attempt', 'retry', or 'retries').
|
|
32
|
+
|
|
33
|
+
Excluded: for i/j/n/count/idx in range(N) — these are index variables used to
|
|
34
|
+
iterate over elements, not retry counters. Analysis of 30 for-range hits showed
|
|
35
|
+
the 'i'-named group was 100% RANGE_FOREACH false positives.
|
|
36
|
+
"""
|
|
37
|
+
if not (
|
|
38
|
+
isinstance(node.iter, ast.Call)
|
|
39
|
+
and isinstance(node.iter.func, ast.Name)
|
|
40
|
+
and node.iter.func.id == "range"
|
|
41
|
+
):
|
|
42
|
+
return False
|
|
43
|
+
target = node.target
|
|
44
|
+
if not isinstance(target, ast.Name):
|
|
45
|
+
return False
|
|
46
|
+
varname = target.id.lower()
|
|
47
|
+
return varname in _RETRY_VAR_NAMES or any(p in varname for p in _RETRY_VAR_PREFIXES)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_sleep_call(func_node) -> bool:
|
|
51
|
+
"""True if func_node is asyncio.sleep, time.sleep, or bare sleep() (direct import)."""
|
|
52
|
+
if isinstance(func_node, ast.Attribute):
|
|
53
|
+
return (
|
|
54
|
+
func_node.attr == "sleep"
|
|
55
|
+
and isinstance(func_node.value, ast.Name)
|
|
56
|
+
and func_node.value.id in ("asyncio", "time")
|
|
57
|
+
)
|
|
58
|
+
# Bare name: `from time import sleep; sleep(N)` — no module prefix
|
|
59
|
+
if isinstance(func_node, ast.Name):
|
|
60
|
+
return func_node.id == "sleep"
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _has_backoff_name(func_node) -> bool:
|
|
65
|
+
"""True if the callable signals backoff/sleep delay.
|
|
66
|
+
|
|
67
|
+
Detects:
|
|
68
|
+
- Function/method name contains 'backoff' or 'jitter'
|
|
69
|
+
- Method name is 'asleep' (Backoff class async-sleep pattern: await backoff.asleep())
|
|
70
|
+
- Receiver object name contains 'backoff' or 'jitter' (e.g. backoff.sleep())
|
|
71
|
+
"""
|
|
72
|
+
if isinstance(func_node, ast.Name):
|
|
73
|
+
name = func_node.id.lower()
|
|
74
|
+
return "backoff" in name or "jitter" in name
|
|
75
|
+
if isinstance(func_node, ast.Attribute):
|
|
76
|
+
attr = func_node.attr.lower()
|
|
77
|
+
if "backoff" in attr or "jitter" in attr or attr == "asleep":
|
|
78
|
+
return True
|
|
79
|
+
# Receiver object named after a backoff helper: backoff.sleep(), jitter.wait()
|
|
80
|
+
if isinstance(func_node.value, ast.Name):
|
|
81
|
+
receiver = func_node.value.id.lower()
|
|
82
|
+
return "backoff" in receiver or "jitter" in receiver
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _body_has_backoff(stmts: list) -> bool:
|
|
87
|
+
"""True if any statement contains a sleep or backoff/jitter call."""
|
|
88
|
+
for stmt in stmts:
|
|
89
|
+
for node in ast.walk(stmt):
|
|
90
|
+
if isinstance(node, ast.Call):
|
|
91
|
+
if _is_sleep_call(node.func):
|
|
92
|
+
return True
|
|
93
|
+
if _has_backoff_name(node.func):
|
|
94
|
+
return True
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _body_has_escalation(stmts: list) -> bool:
|
|
99
|
+
"""True if any if-block in the except body contains raise or break (escalation pattern)."""
|
|
100
|
+
for stmt in stmts:
|
|
101
|
+
for node in ast.walk(stmt):
|
|
102
|
+
if isinstance(node, ast.If):
|
|
103
|
+
for child in ast.walk(node):
|
|
104
|
+
if isinstance(child, (ast.Raise, ast.Break)):
|
|
105
|
+
return True
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_timeout_only_handler(handler: ast.ExceptHandler) -> bool:
|
|
110
|
+
"""True if the handler catches ONLY asyncio.TimeoutError or bare TimeoutError.
|
|
111
|
+
|
|
112
|
+
Handlers that catch TimeoutError exclusively are very likely polling loops
|
|
113
|
+
(e.g. `await asyncio.wait_for(event.wait(), timeout=N)`) rather than retries.
|
|
114
|
+
Tuple handlers like `except (asyncio.TimeoutError, ConnectionError)` return False
|
|
115
|
+
because the presence of other exception types indicates genuine error handling.
|
|
116
|
+
"""
|
|
117
|
+
typ = handler.type
|
|
118
|
+
if typ is None:
|
|
119
|
+
return False
|
|
120
|
+
if isinstance(typ, ast.Name) and typ.id == "TimeoutError":
|
|
121
|
+
return True
|
|
122
|
+
return (
|
|
123
|
+
isinstance(typ, ast.Attribute)
|
|
124
|
+
and typ.attr == "TimeoutError"
|
|
125
|
+
and isinstance(typ.value, ast.Name)
|
|
126
|
+
and typ.value.id == "asyncio"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _try_body_has_timeout_kwarg(stmts: list) -> bool:
|
|
131
|
+
"""True if any call in the try body uses a 'timeout' keyword argument.
|
|
132
|
+
|
|
133
|
+
Combined with _is_timeout_only_handler, this identifies the POLL_LOOP pattern:
|
|
134
|
+
try:
|
|
135
|
+
await asyncio.wait_for(event.wait(), timeout=N) ← timeout= here
|
|
136
|
+
except asyncio.TimeoutError: ← TimeoutError only
|
|
137
|
+
continue ← not a retry
|
|
138
|
+
|
|
139
|
+
A genuine network retry would either catch Exception (not TimeoutError only)
|
|
140
|
+
or would catch asyncio.TimeoutError without any explicit timeout= in the try body.
|
|
141
|
+
"""
|
|
142
|
+
for stmt in stmts:
|
|
143
|
+
for node in ast.walk(stmt):
|
|
144
|
+
if isinstance(node, ast.Call):
|
|
145
|
+
for kw in node.keywords:
|
|
146
|
+
if kw.arg == "timeout":
|
|
147
|
+
return True
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class RetryNoBackoffRule(ast.NodeVisitor):
|
|
152
|
+
"""
|
|
153
|
+
PYVIBE-019 — retry loop without backoff inside async def
|
|
154
|
+
|
|
155
|
+
Scope (v3, jun 2026): exclusively `for _ in range(N)` or `for attempt in range(N)`
|
|
156
|
+
loops inside async functions. While loops are out of scope (FP rate ~90% due to
|
|
157
|
+
CLEANUP_PASS, MEM_PARSE, and MISSED_BACKOFF patterns; not reducible with AST alone).
|
|
158
|
+
|
|
159
|
+
Fires when:
|
|
160
|
+
- Loop iterates over range() with anonymous (_) or retry-intent variable name
|
|
161
|
+
- Handler body ends with `continue` (explicit retry)
|
|
162
|
+
- No sleep or backoff call detected in the handler body
|
|
163
|
+
|
|
164
|
+
Not flagged when the except handler contains:
|
|
165
|
+
- asyncio.sleep(N) or time.sleep(N) or bare sleep(N)
|
|
166
|
+
- await backoff.asleep() or any call with 'backoff'/'jitter'/'asleep' in the name
|
|
167
|
+
- An escalation pattern (if-block leading to raise or break)
|
|
168
|
+
- Catches only asyncio.TimeoutError AND try body has timeout= kwarg (POLL_LOOP pattern)
|
|
169
|
+
|
|
170
|
+
Fix: add `await asyncio.sleep(2 ** attempt)` before continue, or use
|
|
171
|
+
tenacity / backoff library.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
RULE_ID = "PYVIBE-019"
|
|
175
|
+
SEVERITY = "WARNING"
|
|
176
|
+
|
|
177
|
+
def __init__(self):
|
|
178
|
+
self.violations: List[Violation] = []
|
|
179
|
+
self._current_async_func: Optional[str] = None
|
|
180
|
+
# Stack: True = enclosing loop is a qualifying retry loop (for _ in range(N)).
|
|
181
|
+
# Innermost value determines firing — `continue` resumes the innermost loop.
|
|
182
|
+
self._retry_loop_stack: List[bool] = []
|
|
183
|
+
|
|
184
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
|
185
|
+
prev_func = self._current_async_func
|
|
186
|
+
prev_stack = self._retry_loop_stack
|
|
187
|
+
self._current_async_func = node.name
|
|
188
|
+
self._retry_loop_stack = []
|
|
189
|
+
self.generic_visit(node)
|
|
190
|
+
self._current_async_func = prev_func
|
|
191
|
+
self._retry_loop_stack = prev_stack
|
|
192
|
+
|
|
193
|
+
def visit_FunctionDef(self, node: ast.FunctionDef):
|
|
194
|
+
prev_func = self._current_async_func
|
|
195
|
+
prev_stack = self._retry_loop_stack
|
|
196
|
+
self._current_async_func = None
|
|
197
|
+
self._retry_loop_stack = []
|
|
198
|
+
self.generic_visit(node)
|
|
199
|
+
self._current_async_func = prev_func
|
|
200
|
+
self._retry_loop_stack = prev_stack
|
|
201
|
+
|
|
202
|
+
def visit_Lambda(self, node: ast.Lambda):
|
|
203
|
+
prev_func = self._current_async_func
|
|
204
|
+
prev_stack = self._retry_loop_stack
|
|
205
|
+
self._current_async_func = None
|
|
206
|
+
self._retry_loop_stack = []
|
|
207
|
+
self.generic_visit(node)
|
|
208
|
+
self._current_async_func = prev_func
|
|
209
|
+
self._retry_loop_stack = prev_stack
|
|
210
|
+
|
|
211
|
+
def visit_For(self, node: ast.For):
|
|
212
|
+
self._retry_loop_stack.append(_is_retry_loop(node))
|
|
213
|
+
self.generic_visit(node)
|
|
214
|
+
self._retry_loop_stack.pop()
|
|
215
|
+
|
|
216
|
+
def visit_While(self, node: ast.While):
|
|
217
|
+
# While loops are explicitly out of scope for PYVIBE-019 v3.
|
|
218
|
+
# Push False so nested for-range retry loops can still be detected.
|
|
219
|
+
self._retry_loop_stack.append(False)
|
|
220
|
+
self.generic_visit(node)
|
|
221
|
+
self._retry_loop_stack.pop()
|
|
222
|
+
|
|
223
|
+
def visit_Try(self, node: ast.Try):
|
|
224
|
+
if (self._current_async_func
|
|
225
|
+
and self._retry_loop_stack
|
|
226
|
+
and self._retry_loop_stack[-1]):
|
|
227
|
+
for handler in node.handlers:
|
|
228
|
+
self._check_handler(handler, node.body)
|
|
229
|
+
self.generic_visit(node)
|
|
230
|
+
|
|
231
|
+
def _check_handler(self, handler: ast.ExceptHandler, try_body: list):
|
|
232
|
+
body = handler.body
|
|
233
|
+
if not _ends_with_retry(body):
|
|
234
|
+
return
|
|
235
|
+
if _body_has_backoff(body):
|
|
236
|
+
return
|
|
237
|
+
if _body_has_escalation(body):
|
|
238
|
+
return
|
|
239
|
+
# POLL_LOOP exclusion: `except asyncio.TimeoutError` + `timeout=` kwarg in try body
|
|
240
|
+
# is the canonical asyncio.wait_for polling pattern — not a retry.
|
|
241
|
+
if _is_timeout_only_handler(handler) and _try_body_has_timeout_kwarg(try_body):
|
|
242
|
+
return
|
|
243
|
+
self.violations.append(Violation(
|
|
244
|
+
rule_id=self.RULE_ID,
|
|
245
|
+
severity=self.SEVERITY,
|
|
246
|
+
line=handler.lineno,
|
|
247
|
+
function_name=self._current_async_func,
|
|
248
|
+
message=(
|
|
249
|
+
"retry loop without backoff — except retries immediately with no delay"
|
|
250
|
+
),
|
|
251
|
+
evidence=(
|
|
252
|
+
"Add `await asyncio.sleep(2 ** attempt)` before continue, "
|
|
253
|
+
"or use tenacity / backoff library"
|
|
254
|
+
),
|
|
255
|
+
))
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from pyvibe.rules.base import Violation
|
|
5
|
+
|
|
6
|
+
_NOSEC_B110 = re.compile(r'#\s*nosec\b[^#\n]*\bB110\b')
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _is_empty_body(body: list) -> bool:
|
|
10
|
+
"""Return True if every statement is pass or ... (Ellipsis)."""
|
|
11
|
+
if not body:
|
|
12
|
+
return True
|
|
13
|
+
for stmt in body:
|
|
14
|
+
if isinstance(stmt, ast.Pass):
|
|
15
|
+
continue
|
|
16
|
+
if (
|
|
17
|
+
isinstance(stmt, ast.Expr)
|
|
18
|
+
and isinstance(stmt.value, ast.Constant)
|
|
19
|
+
and stmt.value.value is ...
|
|
20
|
+
):
|
|
21
|
+
continue
|
|
22
|
+
return False
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SilentExceptRule(ast.NodeVisitor):
|
|
27
|
+
"""
|
|
28
|
+
PYVIBE-017 — except Exception with empty body (silences errors)
|
|
29
|
+
|
|
30
|
+
A bare except or except Exception block whose body is only pass/... swallows
|
|
31
|
+
every exception without any record, making bugs invisible in production.
|
|
32
|
+
bare except also catches KeyboardInterrupt and SystemExit, preventing clean
|
|
33
|
+
shutdown.
|
|
34
|
+
|
|
35
|
+
Suppression: add `# nosec B110` on the `except` line to mark intentional
|
|
36
|
+
suppression (Bandit convention). Generic `# nosec` without B110 is NOT
|
|
37
|
+
honoured — specificity is required so that the opt-out is deliberate.
|
|
38
|
+
|
|
39
|
+
Fix: log the error, re-raise, or handle explicitly.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
RULE_ID = "PYVIBE-017"
|
|
43
|
+
SEVERITY = "CRITICAL"
|
|
44
|
+
|
|
45
|
+
def __init__(self, source_lines: Optional[List[str]] = None):
|
|
46
|
+
self.violations: List[Violation] = []
|
|
47
|
+
self._current_func: Optional[str] = None
|
|
48
|
+
self._source_lines: List[str] = source_lines or []
|
|
49
|
+
|
|
50
|
+
def _is_suppressed(self, lineno: int) -> bool:
|
|
51
|
+
"""Return True if the given 1-based line carries # nosec B110."""
|
|
52
|
+
if not self._source_lines:
|
|
53
|
+
return False
|
|
54
|
+
idx = lineno - 1
|
|
55
|
+
if 0 <= idx < len(self._source_lines):
|
|
56
|
+
return bool(_NOSEC_B110.search(self._source_lines[idx]))
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
def visit_FunctionDef(self, node: ast.FunctionDef):
|
|
60
|
+
previous = self._current_func
|
|
61
|
+
self._current_func = node.name
|
|
62
|
+
self.generic_visit(node)
|
|
63
|
+
self._current_func = previous
|
|
64
|
+
|
|
65
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
|
66
|
+
previous = self._current_func
|
|
67
|
+
self._current_func = node.name
|
|
68
|
+
self.generic_visit(node)
|
|
69
|
+
self._current_func = previous
|
|
70
|
+
|
|
71
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler):
|
|
72
|
+
if not _is_empty_body(node.body):
|
|
73
|
+
self.generic_visit(node)
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
if self._is_suppressed(node.lineno):
|
|
77
|
+
self.generic_visit(node)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
exc_type = node.type
|
|
81
|
+
if exc_type is None:
|
|
82
|
+
severity = "CRITICAL"
|
|
83
|
+
message = (
|
|
84
|
+
"bare except swallows all exceptions including "
|
|
85
|
+
"KeyboardInterrupt and SystemExit"
|
|
86
|
+
)
|
|
87
|
+
elif isinstance(exc_type, ast.Name) and exc_type.id == "Exception":
|
|
88
|
+
severity = "WARNING"
|
|
89
|
+
message = "except Exception with empty body silences all errors silently"
|
|
90
|
+
else:
|
|
91
|
+
# specific exception type — acceptable pattern
|
|
92
|
+
self.generic_visit(node)
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
self.violations.append(Violation(
|
|
96
|
+
rule_id=self.RULE_ID,
|
|
97
|
+
severity=severity,
|
|
98
|
+
line=node.lineno,
|
|
99
|
+
function_name=self._current_func or "<module>",
|
|
100
|
+
message=message,
|
|
101
|
+
evidence=(
|
|
102
|
+
"Log the error: `except Exception as e: logger.error(e)`, "
|
|
103
|
+
"or re-raise: `except Exception: raise`, "
|
|
104
|
+
"or handle explicitly"
|
|
105
|
+
),
|
|
106
|
+
))
|
|
107
|
+
self.generic_visit(node)
|