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,65 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from typing import List, Set
|
|
3
|
+
from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
|
|
4
|
+
|
|
5
|
+
REQUESTS_METHODS = {"get", "post", "put", "patch", "delete", "head", "options", "request"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AsyncRequestsRule(AsyncBlockingCallVisitor):
|
|
9
|
+
"""
|
|
10
|
+
PYVIBE-002 — requests.* inside async def
|
|
11
|
+
|
|
12
|
+
`requests` is a synchronous HTTP library. Calling it inside an async
|
|
13
|
+
function blocks the OS thread running the event loop. Under concurrent
|
|
14
|
+
load this serialises all I/O and eliminates any benefit of async.
|
|
15
|
+
|
|
16
|
+
Fix: use `httpx.AsyncClient` or `aiohttp.ClientSession` with await.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
RULE_ID = "PYVIBE-002"
|
|
20
|
+
SEVERITY = "CRITICAL"
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
super().__init__()
|
|
24
|
+
# Names bound to the `requests` module via `import requests [as X]`
|
|
25
|
+
self._requests_aliases: Set[str] = set()
|
|
26
|
+
|
|
27
|
+
def visit_Import(self, node: ast.Import):
|
|
28
|
+
for alias in node.names:
|
|
29
|
+
if alias.name == "requests":
|
|
30
|
+
self._requests_aliases.add(alias.asname if alias.asname else alias.name)
|
|
31
|
+
self.generic_visit(node)
|
|
32
|
+
|
|
33
|
+
def visit_Call(self, node: ast.Call):
|
|
34
|
+
if self._current_async_func is None:
|
|
35
|
+
self.generic_visit(node)
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
method = self._get_requests_method(node)
|
|
39
|
+
if method:
|
|
40
|
+
self.violations.append(Violation(
|
|
41
|
+
rule_id=self.RULE_ID,
|
|
42
|
+
severity=self.SEVERITY,
|
|
43
|
+
line=node.lineno,
|
|
44
|
+
function_name=self._current_async_func,
|
|
45
|
+
message=f"requests.{method}() is synchronous — blocks the event loop",
|
|
46
|
+
evidence=f"Use `async with httpx.AsyncClient() as c: await c.{method}(url)`",
|
|
47
|
+
))
|
|
48
|
+
|
|
49
|
+
self.generic_visit(node)
|
|
50
|
+
|
|
51
|
+
def _get_requests_method(self, node: ast.Call):
|
|
52
|
+
# requests.get(...), requests.post(...), etc. — qualified form only.
|
|
53
|
+
# Guard against NAME_COLLISION: only flag when the receiver name is
|
|
54
|
+
# known to be bound to the `requests` module via an import statement.
|
|
55
|
+
# Without this, `requests = get_pending_requests(); requests.get(key)`
|
|
56
|
+
# (a dict/object .get() call) would be a false positive.
|
|
57
|
+
if (
|
|
58
|
+
isinstance(node.func, ast.Attribute)
|
|
59
|
+
and node.func.attr in REQUESTS_METHODS
|
|
60
|
+
and isinstance(node.func.value, ast.Name)
|
|
61
|
+
and node.func.value.id in self._requests_aliases
|
|
62
|
+
):
|
|
63
|
+
return node.func.attr
|
|
64
|
+
|
|
65
|
+
return None
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from typing import List
|
|
3
|
+
from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AsyncSleepRule(AsyncBlockingCallVisitor):
|
|
7
|
+
"""
|
|
8
|
+
PYVIBE-001 — time.sleep() inside async def
|
|
9
|
+
|
|
10
|
+
Blocks the event loop entirely. All concurrent tasks on the same
|
|
11
|
+
loop are frozen for the duration of the sleep. Under load, this
|
|
12
|
+
causes cascading timeouts that look like random failures.
|
|
13
|
+
|
|
14
|
+
Fix: use `await asyncio.sleep(n)` instead.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
RULE_ID = "PYVIBE-001"
|
|
18
|
+
SEVERITY = "CRITICAL"
|
|
19
|
+
|
|
20
|
+
def visit_Call(self, node: ast.Call):
|
|
21
|
+
if self._current_async_func is None:
|
|
22
|
+
self.generic_visit(node)
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
if self._is_time_sleep(node):
|
|
26
|
+
self.violations.append(Violation(
|
|
27
|
+
rule_id=self.RULE_ID,
|
|
28
|
+
severity=self.SEVERITY,
|
|
29
|
+
line=node.lineno,
|
|
30
|
+
function_name=self._current_async_func,
|
|
31
|
+
message="time.sleep() blocks the entire event loop",
|
|
32
|
+
evidence="Use `await asyncio.sleep(n)` instead",
|
|
33
|
+
))
|
|
34
|
+
|
|
35
|
+
self.generic_visit(node)
|
|
36
|
+
|
|
37
|
+
def _is_time_sleep(self, node: ast.Call) -> bool:
|
|
38
|
+
# time.sleep(n) — qualified form only; bare sleep() excluded (false positives)
|
|
39
|
+
return (
|
|
40
|
+
isinstance(node.func, ast.Attribute)
|
|
41
|
+
and node.func.attr == "sleep"
|
|
42
|
+
and isinstance(node.func.value, ast.Name)
|
|
43
|
+
and node.func.value.id == "time"
|
|
44
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from typing import List
|
|
3
|
+
from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AsyncioRunRule(AsyncBlockingCallVisitor):
|
|
7
|
+
"""
|
|
8
|
+
PYVIBE-003 — asyncio.run() inside async def
|
|
9
|
+
|
|
10
|
+
asyncio.run() creates a *new* event loop and blocks until the coroutine
|
|
11
|
+
completes. Calling it from within a running loop raises:
|
|
12
|
+
RuntimeError: This event loop is already running.
|
|
13
|
+
|
|
14
|
+
AI-generated code frequently does this when mixing sync entrypoints
|
|
15
|
+
with async handlers — it compiles, passes basic tests, and explodes
|
|
16
|
+
at runtime in FastAPI / async frameworks.
|
|
17
|
+
|
|
18
|
+
Fix: use `await coroutine()` directly.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
RULE_ID = "PYVIBE-003"
|
|
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 self._is_asyncio_run(node):
|
|
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="asyncio.run() inside async def raises RuntimeError at runtime",
|
|
36
|
+
evidence="Use `await coroutine()` directly — asyncio.run() is for sync entrypoints only",
|
|
37
|
+
))
|
|
38
|
+
|
|
39
|
+
self.generic_visit(node)
|
|
40
|
+
|
|
41
|
+
def _is_asyncio_run(self, node: ast.Call) -> bool:
|
|
42
|
+
# asyncio.run(...)
|
|
43
|
+
return (
|
|
44
|
+
isinstance(node.func, ast.Attribute)
|
|
45
|
+
and node.func.attr == "run"
|
|
46
|
+
and isinstance(node.func.value, ast.Name)
|
|
47
|
+
and node.func.value.id == "asyncio"
|
|
48
|
+
)
|
pyvibe/rules/base.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Violation:
|
|
8
|
+
rule_id: str
|
|
9
|
+
severity: str
|
|
10
|
+
line: int
|
|
11
|
+
function_name: str
|
|
12
|
+
message: str
|
|
13
|
+
evidence: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AsyncContextVisitor(ast.NodeVisitor):
|
|
17
|
+
"""Base visitor that tracks the name of the enclosing async function.
|
|
18
|
+
|
|
19
|
+
``_current_async_func`` is set to the function name while visiting its body
|
|
20
|
+
and restored on exit, supporting nested async defs correctly.
|
|
21
|
+
|
|
22
|
+
Use as the base for rules that detect misuse of asyncio primitives
|
|
23
|
+
(``create_task``, ``ensure_future``, ``gather``) where the bug exists
|
|
24
|
+
regardless of whether the call appears in a nested sync callable.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self.violations: List[Violation] = []
|
|
29
|
+
self._current_async_func: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
|
32
|
+
previous = self._current_async_func
|
|
33
|
+
self._current_async_func = node.name
|
|
34
|
+
self.generic_visit(node)
|
|
35
|
+
self._current_async_func = previous
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncBlockingCallVisitor(AsyncContextVisitor):
|
|
39
|
+
"""AsyncContextVisitor extended with context isolation for nested sync callables.
|
|
40
|
+
|
|
41
|
+
A synchronous ``def`` or ``lambda`` nested inside an ``async def`` resets
|
|
42
|
+
``_current_async_func`` to ``None``. The rationale: such callables may be
|
|
43
|
+
passed to ``run_in_executor`` / ``async_add_executor_job`` / thread pools
|
|
44
|
+
and therefore do *not* inherently block the event loop. Without this reset,
|
|
45
|
+
any blocking call (``requests.get``, ``time.sleep``, ``subprocess.run`` …)
|
|
46
|
+
inside the nested callable would be falsely flagged.
|
|
47
|
+
|
|
48
|
+
Use as the base for rules that detect synchronous blocking calls inside
|
|
49
|
+
async functions: I/O, HTTP, subprocesses, threading primitives, etc.
|
|
50
|
+
Do NOT use it for asyncio-primitive-misuse rules (``create_task``,
|
|
51
|
+
``ensure_future``, ``gather``) — those rules must inherit from
|
|
52
|
+
``AsyncContextVisitor`` directly.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def visit_FunctionDef(self, node: ast.FunctionDef):
|
|
56
|
+
previous = self._current_async_func
|
|
57
|
+
self._current_async_func = None
|
|
58
|
+
self.generic_visit(node)
|
|
59
|
+
self._current_async_func = previous
|
|
60
|
+
|
|
61
|
+
def visit_Lambda(self, node: ast.Lambda):
|
|
62
|
+
previous = self._current_async_func
|
|
63
|
+
self._current_async_func = None
|
|
64
|
+
self.generic_visit(node)
|
|
65
|
+
self._current_async_func = previous
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from typing import List
|
|
3
|
+
from pyvibe.rules.base import Violation
|
|
4
|
+
|
|
5
|
+
_LIMIT_KEYS = {"soft_time_limit", "time_limit"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CeleryTaskTimeLimitRule(ast.NodeVisitor):
|
|
9
|
+
"""
|
|
10
|
+
PYVIBE-005 — Celery task defined without soft_time_limit or time_limit
|
|
11
|
+
|
|
12
|
+
A Celery task with no time limit can hang indefinitely if the external
|
|
13
|
+
call it wraps never returns — blocking a worker process permanently.
|
|
14
|
+
Under load, all workers fill with hung tasks and the queue stalls.
|
|
15
|
+
|
|
16
|
+
Detected decorators: @app.task, @shared_task (bare or with kwargs).
|
|
17
|
+
Fix: @app.task(soft_time_limit=30, time_limit=60)
|
|
18
|
+
|
|
19
|
+
Known limitation — global configuration blind spot:
|
|
20
|
+
Celery allows setting task_time_limit and task_soft_time_limit globally
|
|
21
|
+
via app.conf.task_time_limit, app.conf.update(...), or in celeryconfig.py /
|
|
22
|
+
settings.py. When a project uses global limits every task inherits them
|
|
23
|
+
and per-task decorator arguments are redundant. This rule does NOT detect
|
|
24
|
+
global configuration: it only inspects the decorator arguments of the
|
|
25
|
+
current file. Projects with a project-wide task_time_limit will see
|
|
26
|
+
PYVIBE-005 warnings on tasks that are already covered by that global limit.
|
|
27
|
+
If that applies to your project, either add per-task limits (preferred —
|
|
28
|
+
self-documenting, immune to config drift) or suppress with # noqa: PYVIBE-005.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
RULE_ID = "PYVIBE-005"
|
|
32
|
+
SEVERITY = "CRITICAL"
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self.violations: List[Violation] = []
|
|
36
|
+
self._has_celery_import: bool = False
|
|
37
|
+
|
|
38
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
39
|
+
for alias in node.names:
|
|
40
|
+
if alias.name == "celery" or alias.name.startswith("celery."):
|
|
41
|
+
self._has_celery_import = True
|
|
42
|
+
break
|
|
43
|
+
self.generic_visit(node)
|
|
44
|
+
|
|
45
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
46
|
+
if node.module and (node.module == "celery" or node.module.startswith("celery.")):
|
|
47
|
+
self._has_celery_import = True
|
|
48
|
+
self.generic_visit(node)
|
|
49
|
+
|
|
50
|
+
def visit_FunctionDef(self, node: ast.FunctionDef):
|
|
51
|
+
self._check(node)
|
|
52
|
+
self.generic_visit(node)
|
|
53
|
+
|
|
54
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
|
55
|
+
self._check(node)
|
|
56
|
+
self.generic_visit(node)
|
|
57
|
+
|
|
58
|
+
# ── private ──────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
def _check(self, node):
|
|
61
|
+
for decorator in node.decorator_list:
|
|
62
|
+
if self._is_task_decorator(decorator) and not self._has_time_limit(decorator):
|
|
63
|
+
self.violations.append(Violation(
|
|
64
|
+
rule_id=self.RULE_ID,
|
|
65
|
+
severity=self.SEVERITY,
|
|
66
|
+
line=node.lineno,
|
|
67
|
+
function_name=node.name,
|
|
68
|
+
message="Celery task defined without soft_time_limit or time_limit — can hang forever",
|
|
69
|
+
evidence="Use @app.task(soft_time_limit=30, time_limit=60) to bound execution time",
|
|
70
|
+
))
|
|
71
|
+
break # one violation per function, even if decorated twice
|
|
72
|
+
|
|
73
|
+
def _is_task_decorator(self, decorator: ast.expr) -> bool:
|
|
74
|
+
# Unwrap @app.task(...) or @shared_task(...) to their func node
|
|
75
|
+
node = decorator.func if isinstance(decorator, ast.Call) else decorator
|
|
76
|
+
# @shared_task — Celery-specific; taskiq/huey/dramatiq don't use this name
|
|
77
|
+
if isinstance(node, ast.Name) and node.id == "shared_task":
|
|
78
|
+
return True
|
|
79
|
+
if isinstance(node, ast.Attribute) and node.attr == "task":
|
|
80
|
+
receiver = node.value
|
|
81
|
+
# @self.huey.task(), @self.app.task — chained attrs seen only in
|
|
82
|
+
# test infrastructure (Celery own tests, Huey tests). Skip.
|
|
83
|
+
if not isinstance(receiver, ast.Name):
|
|
84
|
+
return False
|
|
85
|
+
name = receiver.id.lower()
|
|
86
|
+
# @app.task / @importer_app.task — "app" strongly signals a Celery
|
|
87
|
+
# application object; no other common async task framework uses it
|
|
88
|
+
if "app" in name:
|
|
89
|
+
return True
|
|
90
|
+
# @celery.task / @celery_app.task / @my_celery.task
|
|
91
|
+
if "celery" in name:
|
|
92
|
+
return True
|
|
93
|
+
# @broker.task (taskiq), @huey.task, @dramatiq_broker.task etc. —
|
|
94
|
+
# only flag when the file explicitly imports from the celery package,
|
|
95
|
+
# confirming this isn't a different task framework
|
|
96
|
+
return self._has_celery_import
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
def _has_time_limit(self, decorator: ast.expr) -> bool:
|
|
100
|
+
# Bare decorator (@app.task with no call) → no kwargs possible
|
|
101
|
+
if not isinstance(decorator, ast.Call):
|
|
102
|
+
return False
|
|
103
|
+
return any(kw.arg in _LIMIT_KEYS for kw in decorator.keywords)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from typing import List, Set
|
|
3
|
+
from pyvibe.rules.base import Violation
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _shallow_walk(node: ast.AST):
|
|
7
|
+
"""Walk AST without recursing into nested function or class definitions."""
|
|
8
|
+
yield node
|
|
9
|
+
for child in ast.iter_child_nodes(node):
|
|
10
|
+
if not isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
11
|
+
yield from _shallow_walk(child)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ContextVarCleanupRule(ast.NodeVisitor):
|
|
15
|
+
"""
|
|
16
|
+
PYVIBE-006 — ContextVar.set() inside async def without guaranteed cleanup
|
|
17
|
+
|
|
18
|
+
ContextVar.set() returns a Token. Without reset(token) in a finally block,
|
|
19
|
+
the context value leaks into sibling async tasks that share the same context
|
|
20
|
+
— a common bug in FastAPI request handlers and async workers.
|
|
21
|
+
|
|
22
|
+
Fix: token = var.set(value) then var.reset(token) inside a finally block.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
RULE_ID = "PYVIBE-006"
|
|
26
|
+
SEVERITY = "CRITICAL"
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self.violations: List[Violation] = []
|
|
30
|
+
self._contextvar_names: Set[str] = set()
|
|
31
|
+
self._current_async_func: str = None
|
|
32
|
+
|
|
33
|
+
def visit_Module(self, node: ast.Module):
|
|
34
|
+
self._contextvar_names = self._collect_contextvar_names(node)
|
|
35
|
+
self.generic_visit(node)
|
|
36
|
+
|
|
37
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
|
38
|
+
previous = self._current_async_func
|
|
39
|
+
self._current_async_func = node.name
|
|
40
|
+
|
|
41
|
+
# Async generators (FastAPI yield dependencies) use yield semantics for
|
|
42
|
+
# cleanup — the code after yield runs on teardown. Skip them to avoid
|
|
43
|
+
# false positives on the common `token = var.set(v); yield; var.reset(token)` pattern.
|
|
44
|
+
if not self._is_async_generator(node):
|
|
45
|
+
set_calls = self._find_set_calls(node)
|
|
46
|
+
if set_calls and not self._has_finally_reset(node):
|
|
47
|
+
first = set_calls[0]
|
|
48
|
+
self.violations.append(Violation(
|
|
49
|
+
rule_id=self.RULE_ID,
|
|
50
|
+
severity=self.SEVERITY,
|
|
51
|
+
line=first.lineno,
|
|
52
|
+
function_name=node.name,
|
|
53
|
+
message="ContextVar.set() without guaranteed cleanup leaks state between async tasks",
|
|
54
|
+
evidence="Capture the token: `token = var.set(v)` then call `var.reset(token)` in a finally block",
|
|
55
|
+
))
|
|
56
|
+
|
|
57
|
+
self.generic_visit(node)
|
|
58
|
+
self._current_async_func = previous
|
|
59
|
+
|
|
60
|
+
# ── private ──────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def _collect_contextvar_names(self, module: ast.Module) -> Set[str]:
|
|
63
|
+
"""Return variable names assigned from ContextVar(...) anywhere in the module."""
|
|
64
|
+
names: Set[str] = set()
|
|
65
|
+
for node in ast.walk(module):
|
|
66
|
+
if isinstance(node, ast.Assign) and self._is_contextvar_call(node.value):
|
|
67
|
+
for target in node.targets:
|
|
68
|
+
if isinstance(target, ast.Name):
|
|
69
|
+
names.add(target.id)
|
|
70
|
+
elif (
|
|
71
|
+
isinstance(node, ast.AnnAssign)
|
|
72
|
+
and node.value
|
|
73
|
+
and self._is_contextvar_call(node.value)
|
|
74
|
+
and isinstance(node.target, ast.Name)
|
|
75
|
+
):
|
|
76
|
+
names.add(node.target.id)
|
|
77
|
+
return names
|
|
78
|
+
|
|
79
|
+
def _is_contextvar_call(self, node: ast.AST) -> bool:
|
|
80
|
+
if not isinstance(node, ast.Call):
|
|
81
|
+
return False
|
|
82
|
+
func = node.func
|
|
83
|
+
# ContextVar('name')
|
|
84
|
+
if isinstance(func, ast.Name) and func.id == "ContextVar":
|
|
85
|
+
return True
|
|
86
|
+
# contextvars.ContextVar('name')
|
|
87
|
+
if (
|
|
88
|
+
isinstance(func, ast.Attribute)
|
|
89
|
+
and func.attr == "ContextVar"
|
|
90
|
+
and isinstance(func.value, ast.Name)
|
|
91
|
+
and func.value.id == "contextvars"
|
|
92
|
+
):
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def _find_set_calls(self, func_node: ast.AsyncFunctionDef) -> list:
|
|
97
|
+
"""Find .set() calls on known ContextVar names, not crossing nested function boundaries."""
|
|
98
|
+
calls = []
|
|
99
|
+
for node in _shallow_walk(func_node):
|
|
100
|
+
if (
|
|
101
|
+
isinstance(node, ast.Call)
|
|
102
|
+
and isinstance(node.func, ast.Attribute)
|
|
103
|
+
and node.func.attr == "set"
|
|
104
|
+
and isinstance(node.func.value, ast.Name)
|
|
105
|
+
and node.func.value.id in self._contextvar_names
|
|
106
|
+
):
|
|
107
|
+
calls.append(node)
|
|
108
|
+
return calls
|
|
109
|
+
|
|
110
|
+
def _is_async_generator(self, func_node: ast.AsyncFunctionDef) -> bool:
|
|
111
|
+
"""True if the function contains a yield — async generator or FastAPI dependency."""
|
|
112
|
+
for node in _shallow_walk(func_node):
|
|
113
|
+
if isinstance(node, (ast.Yield, ast.YieldFrom)):
|
|
114
|
+
return True
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def _has_finally_reset(self, func_node: ast.AsyncFunctionDef) -> bool:
|
|
118
|
+
"""Return True if the function body contains a try/finally with a .reset() call."""
|
|
119
|
+
for node in _shallow_walk(func_node):
|
|
120
|
+
if isinstance(node, ast.Try) and node.finalbody:
|
|
121
|
+
for stmt in node.finalbody:
|
|
122
|
+
for child in ast.walk(stmt):
|
|
123
|
+
if (
|
|
124
|
+
isinstance(child, ast.Call)
|
|
125
|
+
and isinstance(child.func, ast.Attribute)
|
|
126
|
+
and child.func.attr == "reset"
|
|
127
|
+
):
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from pyvibe.rules.base import Violation, AsyncContextVisitor
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CreateTaskOrphanRule(AsyncContextVisitor):
|
|
6
|
+
"""
|
|
7
|
+
PYVIBE-012 — asyncio.create_task() with discarded return value
|
|
8
|
+
|
|
9
|
+
create_task() returns a Task. If the Task object is not retained by any
|
|
10
|
+
reference, the garbage collector can cancel it mid-execution. Any exception
|
|
11
|
+
raised inside the task is also silently lost — no traceback, no crash,
|
|
12
|
+
just missing work. AI-generated "fire-and-forget" code hits this constantly.
|
|
13
|
+
|
|
14
|
+
Fix: assign the task and await it, or use asyncio.TaskGroup (Python 3.11+).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
RULE_ID = "PYVIBE-012"
|
|
18
|
+
SEVERITY = "CRITICAL"
|
|
19
|
+
|
|
20
|
+
def visit_Expr(self, node: ast.Expr):
|
|
21
|
+
# ast.Expr (capital-E) is the *statement* wrapper for a discarded expression.
|
|
22
|
+
# If its direct value is create_task(), the Task reference is lost.
|
|
23
|
+
if self._current_async_func and self._is_create_task(node.value):
|
|
24
|
+
self.violations.append(Violation(
|
|
25
|
+
rule_id=self.RULE_ID,
|
|
26
|
+
severity=self.SEVERITY,
|
|
27
|
+
line=node.lineno,
|
|
28
|
+
function_name=self._current_async_func,
|
|
29
|
+
message="asyncio.create_task() return value discarded — task may be GC'd and silently cancelled",
|
|
30
|
+
evidence=(
|
|
31
|
+
"Assign and await: `task = asyncio.create_task(coro()); await task`, "
|
|
32
|
+
"or use `async with asyncio.TaskGroup() as tg: tg.create_task(coro())` (Python 3.11+)"
|
|
33
|
+
),
|
|
34
|
+
))
|
|
35
|
+
self.generic_visit(node)
|
|
36
|
+
|
|
37
|
+
def _is_create_task(self, node: ast.expr) -> bool:
|
|
38
|
+
return (
|
|
39
|
+
isinstance(node, ast.Call)
|
|
40
|
+
and isinstance(node.func, ast.Attribute)
|
|
41
|
+
and node.func.attr == "create_task"
|
|
42
|
+
and isinstance(node.func.value, ast.Name)
|
|
43
|
+
and node.func.value.id == "asyncio"
|
|
44
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from pyvibe.rules.base import Violation, AsyncContextVisitor
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class EnsureFutureOrphanRule(AsyncContextVisitor):
|
|
6
|
+
"""
|
|
7
|
+
PYVIBE-014 — asyncio.ensure_future() with discarded return value
|
|
8
|
+
|
|
9
|
+
ensure_future() (the pre-3.7 predecessor to create_task()) schedules a
|
|
10
|
+
coroutine and returns a Task/Future. If the return value is not retained,
|
|
11
|
+
the garbage collector can cancel the task mid-execution and any exception
|
|
12
|
+
is silently swallowed. Identical hazard to PYVIBE-012 but with a different
|
|
13
|
+
API that is still widespread in older codebases.
|
|
14
|
+
|
|
15
|
+
Fix: assign the result and await it, or migrate to asyncio.create_task()
|
|
16
|
+
(Python 3.7+) / asyncio.TaskGroup (Python 3.11+).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
RULE_ID = "PYVIBE-014"
|
|
20
|
+
SEVERITY = "CRITICAL"
|
|
21
|
+
|
|
22
|
+
def visit_Expr(self, node: ast.Expr):
|
|
23
|
+
if self._current_async_func and self._is_ensure_future(node.value):
|
|
24
|
+
self.violations.append(Violation(
|
|
25
|
+
rule_id=self.RULE_ID,
|
|
26
|
+
severity=self.SEVERITY,
|
|
27
|
+
line=node.lineno,
|
|
28
|
+
function_name=self._current_async_func,
|
|
29
|
+
message="asyncio.ensure_future() return value discarded — task may be GC'd and silently cancelled",
|
|
30
|
+
evidence=(
|
|
31
|
+
"Assign and await: `task = asyncio.ensure_future(coro()); await task`, "
|
|
32
|
+
"or migrate to `asyncio.create_task(coro())` (Python 3.7+)"
|
|
33
|
+
),
|
|
34
|
+
))
|
|
35
|
+
self.generic_visit(node)
|
|
36
|
+
|
|
37
|
+
def _is_ensure_future(self, node: ast.expr) -> bool:
|
|
38
|
+
return (
|
|
39
|
+
isinstance(node, ast.Call)
|
|
40
|
+
and isinstance(node.func, ast.Attribute)
|
|
41
|
+
and node.func.attr == "ensure_future"
|
|
42
|
+
and isinstance(node.func.value, ast.Name)
|
|
43
|
+
and node.func.value.id == "asyncio"
|
|
44
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from pyvibe.rules.base import Violation, AsyncContextVisitor
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class GatherNoReturnExceptionsRule(AsyncContextVisitor):
|
|
6
|
+
"""
|
|
7
|
+
PYVIBE-013 — asyncio.gather() without return_exceptions=True
|
|
8
|
+
|
|
9
|
+
Without return_exceptions=True, the first exception raised by any task
|
|
10
|
+
immediately propagates to the gather() caller. The remaining tasks are
|
|
11
|
+
NOT automatically cancelled — they keep running detached, leaking
|
|
12
|
+
resources and producing results nobody reads.
|
|
13
|
+
|
|
14
|
+
AI-generated code almost never adds return_exceptions=True because the
|
|
15
|
+
pattern "looks correct" in single-task testing where exceptions don't
|
|
16
|
+
race. The bug surfaces only under concurrent load.
|
|
17
|
+
|
|
18
|
+
Fix: add return_exceptions=True and inspect the result list, or use
|
|
19
|
+
asyncio.TaskGroup (Python 3.11+) for explicit structured concurrency.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
RULE_ID = "PYVIBE-013"
|
|
23
|
+
SEVERITY = "CRITICAL"
|
|
24
|
+
|
|
25
|
+
def visit_Call(self, node: ast.Call):
|
|
26
|
+
if self._current_async_func and self._is_gather(node) and not self._has_return_exceptions_true(node):
|
|
27
|
+
self.violations.append(Violation(
|
|
28
|
+
rule_id=self.RULE_ID,
|
|
29
|
+
severity=self.SEVERITY,
|
|
30
|
+
line=node.lineno,
|
|
31
|
+
function_name=self._current_async_func,
|
|
32
|
+
message="asyncio.gather() without return_exceptions=True — first exception leaks remaining tasks",
|
|
33
|
+
evidence=(
|
|
34
|
+
"Add return_exceptions=True and check results: "
|
|
35
|
+
"`results = await asyncio.gather(*coros, return_exceptions=True)`, "
|
|
36
|
+
"or use `async with asyncio.TaskGroup() as tg:` (Python 3.11+)"
|
|
37
|
+
),
|
|
38
|
+
))
|
|
39
|
+
self.generic_visit(node)
|
|
40
|
+
|
|
41
|
+
def _is_gather(self, node: ast.Call) -> bool:
|
|
42
|
+
return (
|
|
43
|
+
isinstance(node.func, ast.Attribute)
|
|
44
|
+
and node.func.attr == "gather"
|
|
45
|
+
and isinstance(node.func.value, ast.Name)
|
|
46
|
+
and node.func.value.id == "asyncio"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def _has_return_exceptions_true(self, node: ast.Call) -> bool:
|
|
50
|
+
for kw in node.keywords:
|
|
51
|
+
if (
|
|
52
|
+
kw.arg == "return_exceptions"
|
|
53
|
+
and isinstance(kw.value, ast.Constant)
|
|
54
|
+
and kw.value.value is True
|
|
55
|
+
):
|
|
56
|
+
return True
|
|
57
|
+
return False
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from typing import List
|
|
3
|
+
from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HttpxClientSyncRule(AsyncBlockingCallVisitor):
|
|
7
|
+
"""
|
|
8
|
+
PYVIBE-016 — httpx.Client() instantiated inside async def
|
|
9
|
+
|
|
10
|
+
httpx.Client is the synchronous client. Every request it makes blocks
|
|
11
|
+
the OS thread for the full HTTP round-trip, starving all other coroutines
|
|
12
|
+
on the event loop. AI-generated code frequently picks httpx.Client over
|
|
13
|
+
httpx.AsyncClient because the sync API looks simpler.
|
|
14
|
+
|
|
15
|
+
httpx.AsyncClient() has attr='AsyncClient', which is explicitly excluded.
|
|
16
|
+
|
|
17
|
+
Fix: use `async with httpx.AsyncClient() as client: response = await client.get(url)`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
RULE_ID = "PYVIBE-016"
|
|
21
|
+
SEVERITY = "CRITICAL"
|
|
22
|
+
|
|
23
|
+
def visit_Call(self, node: ast.Call):
|
|
24
|
+
if self._current_async_func and self._is_httpx_client_sync(node):
|
|
25
|
+
self.violations.append(Violation(
|
|
26
|
+
rule_id=self.RULE_ID,
|
|
27
|
+
severity=self.SEVERITY,
|
|
28
|
+
line=node.lineno,
|
|
29
|
+
function_name=self._current_async_func,
|
|
30
|
+
message="httpx.Client() is synchronous — blocks the event loop for every HTTP request",
|
|
31
|
+
evidence=(
|
|
32
|
+
"Use `async with httpx.AsyncClient() as client: response = await client.get(url)` instead"
|
|
33
|
+
),
|
|
34
|
+
))
|
|
35
|
+
self.generic_visit(node)
|
|
36
|
+
|
|
37
|
+
def _is_httpx_client_sync(self, node: ast.Call) -> bool:
|
|
38
|
+
return (
|
|
39
|
+
isinstance(node.func, ast.Attribute)
|
|
40
|
+
and node.func.attr == "Client"
|
|
41
|
+
and isinstance(node.func.value, ast.Name)
|
|
42
|
+
and node.func.value.id == "httpx"
|
|
43
|
+
)
|