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,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
+ )