python-vibe-guard 0.8.0__tar.gz → 0.9.0__tar.gz

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.
Files changed (44) hide show
  1. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/PKG-INFO +28 -5
  2. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/README.md +27 -4
  3. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyproject.toml +1 -1
  4. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/python_vibe_guard.egg-info/PKG-INFO +28 -5
  5. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/python_vibe_guard.egg-info/SOURCES.txt +1 -0
  6. python_vibe_guard-0.9.0/pyvibe/__init__.py +1 -0
  7. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/analyzer.py +13 -0
  8. python_vibe_guard-0.9.0/pyvibe/autofix.py +31 -0
  9. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/cli.py +5 -0
  10. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/async_requests.py +14 -2
  11. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/async_sleep.py +13 -1
  12. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/asyncio_run.py +15 -1
  13. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/base.py +1 -0
  14. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/sqlite_async.py +11 -2
  15. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/subprocess_async.py +43 -1
  16. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/tests/test_rules.py +69 -0
  17. python_vibe_guard-0.8.0/pyvibe/__init__.py +0 -1
  18. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/python_vibe_guard.egg-info/dependency_links.txt +0 -0
  19. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/python_vibe_guard.egg-info/entry_points.txt +0 -0
  20. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/python_vibe_guard.egg-info/top_level.txt +0 -0
  21. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/__main__.py +0 -0
  22. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/explain.py +0 -0
  23. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rule_docs.py +0 -0
  24. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/__init__.py +0 -0
  25. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/celery_time_limit.py +0 -0
  26. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/contextvar_cleanup.py +0 -0
  27. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/create_task_orphan.py +0 -0
  28. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/ensure_future_orphan.py +0 -0
  29. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/gather_no_return_exceptions.py +0 -0
  30. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/httpx_client_sync.py +0 -0
  31. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/httpx_sync.py +0 -0
  32. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/loop_run_until_complete.py +0 -0
  33. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/open_async.py +0 -0
  34. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/os_blocking.py +0 -0
  35. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/queue_put_nowait.py +0 -0
  36. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/retry_no_backoff.py +0 -0
  37. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/silent_except.py +0 -0
  38. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/threading_lock.py +0 -0
  39. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/while_true_no_await.py +0 -0
  40. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/sarif.py +0 -0
  41. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/setup.cfg +0 -0
  42. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/tests/test_exclude.py +0 -0
  43. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/tests/test_explain.py +0 -0
  44. {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/tests/test_sarif.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-vibe-guard
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Runtime anti-pattern scanner for async Python — detects what AI-generated code gets wrong
5
5
  License: MIT
6
6
  Keywords: async,linter,fastapi,asyncio,static-analysis
@@ -184,22 +184,29 @@ python -m pyvibe explain PYVIBE-002
184
184
 
185
185
  demo/bad_async.py
186
186
 
187
- [CRITICAL] [PYVIBE-001] — line 14
187
+ [CRITICAL] [PYVIBE-001] — line 27
188
188
  Function : process_order()
189
189
  Problem : time.sleep() blocks the entire event loop
190
190
  Fix : Use `await asyncio.sleep(n)` instead
191
+ Suggested fix:
192
+ await asyncio.sleep(2)
191
193
 
192
- [CRITICAL] [PYVIBE-002] — line 20
194
+ [CRITICAL] [PYVIBE-002] — line 33
193
195
  Function : fetch_user()
194
196
  Problem : requests.get() is synchronous — blocks the event loop
195
197
  Fix : Use `async with httpx.AsyncClient() as c: await c.get(url)`
198
+ Suggested fix:
199
+ async with httpx.AsyncClient() as client:
200
+ response = await client.get(f"https://api.example.com/users/{user_id}")
196
201
 
197
- [CRITICAL] [PYVIBE-003] — line 26
202
+ [CRITICAL] [PYVIBE-003] — line 39
198
203
  Function : orchestrate()
199
204
  Problem : asyncio.run() inside async def raises RuntimeError at runtime
200
205
  Fix : Use `await coroutine()` directly — asyncio.run() is for sync entrypoints only
206
+ Suggested fix:
207
+ await process_order(42)
201
208
 
202
- [CRITICAL] [PYVIBE-004] — line 32
209
+ [CRITICAL] [PYVIBE-004] — line 45
203
210
  Function : update_counter()
204
211
  Problem : threading.Lock() blocks the event loop under contention
205
212
  Fix : Use `asyncio.Lock()` with `async with lock:` instead
@@ -208,6 +215,10 @@ python -m pyvibe explain PYVIBE-002
208
215
  4 violation(s) in 1 file(s)
209
216
  ```
210
217
 
218
+ `Suggested fix:` blocks are generated from the real code on the flagged line — they are
219
+ printed to the terminal / JSON output only and are never written back to your files.
220
+ Currently available for PYVIBE-001, 002, 003, 007, and 008.
221
+
211
222
  ### `pyvibe explain`
212
223
 
213
224
  Every rule's accuracy claims come from `research/accepted/PYVIBE-XXX.md` — a per-rule
@@ -279,6 +290,18 @@ each linking back to its `research/accepted/PYVIBE-XXX.md` evidence file via `he
279
290
 
280
291
  ---
281
292
 
293
+ ## GitHub Action
294
+
295
+ - name: python-vibe-guard
296
+ uses: joaquinriosheredia/python-vibe-guard-action@v1
297
+ with:
298
+ path: src/
299
+
300
+ Violations appear directly in GitHub Security tab (SARIF).
301
+ Link: https://github.com/Joaquinriosheredia/python-vibe-guard-action
302
+
303
+ ---
304
+
282
305
  ## Pre-commit integration
283
306
 
284
307
  Add to your `.pre-commit-config.yaml`:
@@ -169,22 +169,29 @@ python -m pyvibe explain PYVIBE-002
169
169
 
170
170
  demo/bad_async.py
171
171
 
172
- [CRITICAL] [PYVIBE-001] — line 14
172
+ [CRITICAL] [PYVIBE-001] — line 27
173
173
  Function : process_order()
174
174
  Problem : time.sleep() blocks the entire event loop
175
175
  Fix : Use `await asyncio.sleep(n)` instead
176
+ Suggested fix:
177
+ await asyncio.sleep(2)
176
178
 
177
- [CRITICAL] [PYVIBE-002] — line 20
179
+ [CRITICAL] [PYVIBE-002] — line 33
178
180
  Function : fetch_user()
179
181
  Problem : requests.get() is synchronous — blocks the event loop
180
182
  Fix : Use `async with httpx.AsyncClient() as c: await c.get(url)`
183
+ Suggested fix:
184
+ async with httpx.AsyncClient() as client:
185
+ response = await client.get(f"https://api.example.com/users/{user_id}")
181
186
 
182
- [CRITICAL] [PYVIBE-003] — line 26
187
+ [CRITICAL] [PYVIBE-003] — line 39
183
188
  Function : orchestrate()
184
189
  Problem : asyncio.run() inside async def raises RuntimeError at runtime
185
190
  Fix : Use `await coroutine()` directly — asyncio.run() is for sync entrypoints only
191
+ Suggested fix:
192
+ await process_order(42)
186
193
 
187
- [CRITICAL] [PYVIBE-004] — line 32
194
+ [CRITICAL] [PYVIBE-004] — line 45
188
195
  Function : update_counter()
189
196
  Problem : threading.Lock() blocks the event loop under contention
190
197
  Fix : Use `asyncio.Lock()` with `async with lock:` instead
@@ -193,6 +200,10 @@ python -m pyvibe explain PYVIBE-002
193
200
  4 violation(s) in 1 file(s)
194
201
  ```
195
202
 
203
+ `Suggested fix:` blocks are generated from the real code on the flagged line — they are
204
+ printed to the terminal / JSON output only and are never written back to your files.
205
+ Currently available for PYVIBE-001, 002, 003, 007, and 008.
206
+
196
207
  ### `pyvibe explain`
197
208
 
198
209
  Every rule's accuracy claims come from `research/accepted/PYVIBE-XXX.md` — a per-rule
@@ -264,6 +275,18 @@ each linking back to its `research/accepted/PYVIBE-XXX.md` evidence file via `he
264
275
 
265
276
  ---
266
277
 
278
+ ## GitHub Action
279
+
280
+ - name: python-vibe-guard
281
+ uses: joaquinriosheredia/python-vibe-guard-action@v1
282
+ with:
283
+ path: src/
284
+
285
+ Violations appear directly in GitHub Security tab (SARIF).
286
+ Link: https://github.com/Joaquinriosheredia/python-vibe-guard-action
287
+
288
+ ---
289
+
267
290
  ## Pre-commit integration
268
291
 
269
292
  Add to your `.pre-commit-config.yaml`:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-vibe-guard"
7
- version = "0.8.0"
7
+ version = "0.9.0"
8
8
  description = "Runtime anti-pattern scanner for async Python — detects what AI-generated code gets wrong"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-vibe-guard
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Runtime anti-pattern scanner for async Python — detects what AI-generated code gets wrong
5
5
  License: MIT
6
6
  Keywords: async,linter,fastapi,asyncio,static-analysis
@@ -184,22 +184,29 @@ python -m pyvibe explain PYVIBE-002
184
184
 
185
185
  demo/bad_async.py
186
186
 
187
- [CRITICAL] [PYVIBE-001] — line 14
187
+ [CRITICAL] [PYVIBE-001] — line 27
188
188
  Function : process_order()
189
189
  Problem : time.sleep() blocks the entire event loop
190
190
  Fix : Use `await asyncio.sleep(n)` instead
191
+ Suggested fix:
192
+ await asyncio.sleep(2)
191
193
 
192
- [CRITICAL] [PYVIBE-002] — line 20
194
+ [CRITICAL] [PYVIBE-002] — line 33
193
195
  Function : fetch_user()
194
196
  Problem : requests.get() is synchronous — blocks the event loop
195
197
  Fix : Use `async with httpx.AsyncClient() as c: await c.get(url)`
198
+ Suggested fix:
199
+ async with httpx.AsyncClient() as client:
200
+ response = await client.get(f"https://api.example.com/users/{user_id}")
196
201
 
197
- [CRITICAL] [PYVIBE-003] — line 26
202
+ [CRITICAL] [PYVIBE-003] — line 39
198
203
  Function : orchestrate()
199
204
  Problem : asyncio.run() inside async def raises RuntimeError at runtime
200
205
  Fix : Use `await coroutine()` directly — asyncio.run() is for sync entrypoints only
206
+ Suggested fix:
207
+ await process_order(42)
201
208
 
202
- [CRITICAL] [PYVIBE-004] — line 32
209
+ [CRITICAL] [PYVIBE-004] — line 45
203
210
  Function : update_counter()
204
211
  Problem : threading.Lock() blocks the event loop under contention
205
212
  Fix : Use `asyncio.Lock()` with `async with lock:` instead
@@ -208,6 +215,10 @@ python -m pyvibe explain PYVIBE-002
208
215
  4 violation(s) in 1 file(s)
209
216
  ```
210
217
 
218
+ `Suggested fix:` blocks are generated from the real code on the flagged line — they are
219
+ printed to the terminal / JSON output only and are never written back to your files.
220
+ Currently available for PYVIBE-001, 002, 003, 007, and 008.
221
+
211
222
  ### `pyvibe explain`
212
223
 
213
224
  Every rule's accuracy claims come from `research/accepted/PYVIBE-XXX.md` — a per-rule
@@ -279,6 +290,18 @@ each linking back to its `research/accepted/PYVIBE-XXX.md` evidence file via `he
279
290
 
280
291
  ---
281
292
 
293
+ ## GitHub Action
294
+
295
+ - name: python-vibe-guard
296
+ uses: joaquinriosheredia/python-vibe-guard-action@v1
297
+ with:
298
+ path: src/
299
+
300
+ Violations appear directly in GitHub Security tab (SARIF).
301
+ Link: https://github.com/Joaquinriosheredia/python-vibe-guard-action
302
+
303
+ ---
304
+
282
305
  ## Pre-commit integration
283
306
 
284
307
  Add to your `.pre-commit-config.yaml`:
@@ -8,6 +8,7 @@ python_vibe_guard.egg-info/top_level.txt
8
8
  pyvibe/__init__.py
9
9
  pyvibe/__main__.py
10
10
  pyvibe/analyzer.py
11
+ pyvibe/autofix.py
11
12
  pyvibe/cli.py
12
13
  pyvibe/explain.py
13
14
  pyvibe/rule_docs.py
@@ -0,0 +1 @@
1
+ __version__ = "0.9.0"
@@ -49,6 +49,17 @@ ALL_RULES = [
49
49
 
50
50
  ALL_RULE_IDS: FrozenSet[str] = frozenset(r.RULE_ID for r in ALL_RULES)
51
51
 
52
+ # Rules that build a concrete "Suggested fix" snippet from the real source
53
+ # text (see pyvibe/autofix.py) and therefore need the full source passed
54
+ # into their constructor.
55
+ _NEEDS_SOURCE: FrozenSet[type] = frozenset({
56
+ AsyncSleepRule,
57
+ AsyncRequestsRule,
58
+ AsyncioRunRule,
59
+ SubprocessAsyncRule,
60
+ SqliteAsyncRule,
61
+ })
62
+
52
63
  # Rules downgraded CRITICAL → WARNING when the violation is inside a test file.
53
64
  # time.sleep in fixtures, subprocess in service-startup helpers, open() in
54
65
  # async test helpers (e.g. asyncssh, aiofiles own test suites), and Celery task
@@ -101,6 +112,8 @@ def analyze_source(
101
112
  for RuleClass in ALL_RULES:
102
113
  if RuleClass is SilentExceptRule:
103
114
  visitor = RuleClass(source_lines)
115
+ elif RuleClass in _NEEDS_SOURCE:
116
+ visitor = RuleClass(source)
104
117
  else:
105
118
  visitor = RuleClass()
106
119
  visitor.visit(tree)
@@ -0,0 +1,31 @@
1
+ """Shared helpers for building concrete "Suggested fix" snippets from real source context.
2
+
3
+ Rules that support autofix suggestions receive the original source text and use
4
+ `ast.get_source_segment` to pull the exact expressions the developer wrote
5
+ (variable names, literals, kwargs) into the suggested replacement, instead of
6
+ a synthetic placeholder like `...`.
7
+ """
8
+ import ast
9
+ from typing import Optional
10
+
11
+
12
+ def render_node(source: str, node: ast.AST) -> Optional[str]:
13
+ """Return the literal source text of an AST node, or None if unavailable."""
14
+ return ast.get_source_segment(source, node)
15
+
16
+
17
+ def render_call_args(source: str, node: ast.Call) -> str:
18
+ """Reconstruct the literal argument-list text of a Call node from source."""
19
+ parts = []
20
+ for arg in node.args:
21
+ if isinstance(arg, ast.Starred):
22
+ seg = render_node(source, arg.value)
23
+ parts.append(f"*{seg}" if seg else "*args")
24
+ else:
25
+ seg = render_node(source, arg)
26
+ parts.append(seg if seg is not None else "...")
27
+ for kw in node.keywords:
28
+ seg = render_node(source, kw.value)
29
+ seg = seg if seg is not None else "..."
30
+ parts.append(f"**{seg}" if kw.arg is None else f"{kw.arg}={seg}")
31
+ return ", ".join(parts)
@@ -132,6 +132,7 @@ def main():
132
132
  "function": v.function_name,
133
133
  "message": v.message,
134
134
  "evidence": v.evidence,
135
+ "suggested_fix": v.suggested_fix,
135
136
  })
136
137
  print(json.dumps(output, indent=2))
137
138
  else:
@@ -160,6 +161,10 @@ def _print_human(file_results: dict, total_violations: int, total_files: int):
160
161
  print(f" Function : {v.function_name}()")
161
162
  print(f" Problem : {v.message}")
162
163
  print(f" Fix : {v.evidence}")
164
+ if v.suggested_fix:
165
+ print(" Suggested fix:")
166
+ for line in v.suggested_fix.splitlines():
167
+ print(f" {line}")
163
168
  print()
164
169
 
165
170
  print(" ─────────────────────────────────────────────")
@@ -1,5 +1,6 @@
1
1
  import ast
2
- from typing import List, Set
2
+ from typing import List, Optional, Set
3
+ from pyvibe.autofix import render_call_args
3
4
  from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
4
5
 
5
6
  REQUESTS_METHODS = {"get", "post", "put", "patch", "delete", "head", "options", "request"}
@@ -19,8 +20,9 @@ class AsyncRequestsRule(AsyncBlockingCallVisitor):
19
20
  RULE_ID = "PYVIBE-002"
20
21
  SEVERITY = "CRITICAL"
21
22
 
22
- def __init__(self):
23
+ def __init__(self, source: Optional[str] = None):
23
24
  super().__init__()
25
+ self._source = source
24
26
  # Names bound to the `requests` module via `import requests [as X]`
25
27
  self._requests_aliases: Set[str] = set()
26
28
 
@@ -44,10 +46,20 @@ class AsyncRequestsRule(AsyncBlockingCallVisitor):
44
46
  function_name=self._current_async_func,
45
47
  message=f"requests.{method}() is synchronous — blocks the event loop",
46
48
  evidence=f"Use `async with httpx.AsyncClient() as c: await c.{method}(url)`",
49
+ suggested_fix=self._suggest_fix(node, method),
47
50
  ))
48
51
 
49
52
  self.generic_visit(node)
50
53
 
54
+ def _suggest_fix(self, node: ast.Call, method: str) -> Optional[str]:
55
+ if not self._source:
56
+ return None
57
+ args_src = render_call_args(self._source, node)
58
+ return (
59
+ "async with httpx.AsyncClient() as client:\n"
60
+ f" response = await client.{method}({args_src})"
61
+ )
62
+
51
63
  def _get_requests_method(self, node: ast.Call):
52
64
  # requests.get(...), requests.post(...), etc. — qualified form only.
53
65
  # Guard against NAME_COLLISION: only flag when the receiver name is
@@ -1,5 +1,6 @@
1
1
  import ast
2
- from typing import List
2
+ from typing import List, Optional
3
+ from pyvibe.autofix import render_call_args
3
4
  from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
4
5
 
5
6
 
@@ -17,6 +18,10 @@ class AsyncSleepRule(AsyncBlockingCallVisitor):
17
18
  RULE_ID = "PYVIBE-001"
18
19
  SEVERITY = "CRITICAL"
19
20
 
21
+ def __init__(self, source: Optional[str] = None):
22
+ super().__init__()
23
+ self._source = source
24
+
20
25
  def visit_Call(self, node: ast.Call):
21
26
  if self._current_async_func is None:
22
27
  self.generic_visit(node)
@@ -30,10 +35,17 @@ class AsyncSleepRule(AsyncBlockingCallVisitor):
30
35
  function_name=self._current_async_func,
31
36
  message="time.sleep() blocks the entire event loop",
32
37
  evidence="Use `await asyncio.sleep(n)` instead",
38
+ suggested_fix=self._suggest_fix(node),
33
39
  ))
34
40
 
35
41
  self.generic_visit(node)
36
42
 
43
+ def _suggest_fix(self, node: ast.Call) -> Optional[str]:
44
+ if not self._source:
45
+ return None
46
+ args_src = render_call_args(self._source, node)
47
+ return f"await asyncio.sleep({args_src})"
48
+
37
49
  def _is_time_sleep(self, node: ast.Call) -> bool:
38
50
  # time.sleep(n) — qualified form only; bare sleep() excluded (false positives)
39
51
  return (
@@ -1,5 +1,6 @@
1
1
  import ast
2
- from typing import List
2
+ from typing import List, Optional
3
+ from pyvibe.autofix import render_node
3
4
  from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
4
5
 
5
6
 
@@ -21,6 +22,10 @@ class AsyncioRunRule(AsyncBlockingCallVisitor):
21
22
  RULE_ID = "PYVIBE-003"
22
23
  SEVERITY = "CRITICAL"
23
24
 
25
+ def __init__(self, source: Optional[str] = None):
26
+ super().__init__()
27
+ self._source = source
28
+
24
29
  def visit_Call(self, node: ast.Call):
25
30
  if self._current_async_func is None:
26
31
  self.generic_visit(node)
@@ -34,10 +39,19 @@ class AsyncioRunRule(AsyncBlockingCallVisitor):
34
39
  function_name=self._current_async_func,
35
40
  message="asyncio.run() inside async def raises RuntimeError at runtime",
36
41
  evidence="Use `await coroutine()` directly — asyncio.run() is for sync entrypoints only",
42
+ suggested_fix=self._suggest_fix(node),
37
43
  ))
38
44
 
39
45
  self.generic_visit(node)
40
46
 
47
+ def _suggest_fix(self, node: ast.Call) -> Optional[str]:
48
+ if not self._source or not node.args:
49
+ return None
50
+ coro_src = render_node(self._source, node.args[0])
51
+ if not coro_src:
52
+ return None
53
+ return f"await {coro_src}"
54
+
41
55
  def _is_asyncio_run(self, node: ast.Call) -> bool:
42
56
  # asyncio.run(...)
43
57
  return (
@@ -11,6 +11,7 @@ class Violation:
11
11
  function_name: str
12
12
  message: str
13
13
  evidence: str
14
+ suggested_fix: Optional[str] = None
14
15
 
15
16
 
16
17
  class AsyncContextVisitor(ast.NodeVisitor):
@@ -1,5 +1,6 @@
1
1
  import ast
2
- from typing import List, Set
2
+ from typing import List, Optional, Set
3
+ from pyvibe.autofix import render_call_args
3
4
  from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
4
5
 
5
6
 
@@ -17,8 +18,9 @@ class SqliteAsyncRule(AsyncBlockingCallVisitor):
17
18
  RULE_ID = "PYVIBE-008"
18
19
  SEVERITY = "CRITICAL"
19
20
 
20
- def __init__(self):
21
+ def __init__(self, source: Optional[str] = None):
21
22
  super().__init__()
23
+ self._source = source
22
24
  # Names bound to the `sqlite3` module via `import sqlite3 [as X]`
23
25
  self._sqlite3_aliases: Set[str] = set()
24
26
  # Names imported directly from sqlite3: `from sqlite3 import connect`
@@ -50,10 +52,17 @@ class SqliteAsyncRule(AsyncBlockingCallVisitor):
50
52
  function_name=self._current_async_func,
51
53
  message="sqlite3.connect() is synchronous — blocks the event loop during I/O",
52
54
  evidence="Use `async with aiosqlite.connect('db.sqlite3') as db:` instead",
55
+ suggested_fix=self._suggest_fix(node),
53
56
  ))
54
57
 
55
58
  self.generic_visit(node)
56
59
 
60
+ def _suggest_fix(self, node: ast.Call) -> Optional[str]:
61
+ if not self._source:
62
+ return None
63
+ args_src = render_call_args(self._source, node)
64
+ return f"async with aiosqlite.connect({args_src}) as db:"
65
+
57
66
  def _is_sqlite_connect(self, node: ast.Call) -> bool:
58
67
  # sqlite3.connect(...)
59
68
  # Guard against NAME_COLLISION: only flag when the receiver name is
@@ -1,5 +1,6 @@
1
1
  import ast
2
- from typing import List
2
+ from typing import List, Optional
3
+ from pyvibe.autofix import render_call_args, render_node
3
4
  from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
4
5
 
5
6
  SUBPROCESS_BLOCKING = {"run", "call", "check_output", "Popen"}
@@ -21,6 +22,10 @@ class SubprocessAsyncRule(AsyncBlockingCallVisitor):
21
22
  RULE_ID = "PYVIBE-007"
22
23
  SEVERITY = "CRITICAL"
23
24
 
25
+ def __init__(self, source: Optional[str] = None):
26
+ super().__init__()
27
+ self._source = source
28
+
24
29
  def visit_Call(self, node: ast.Call):
25
30
  if self._current_async_func is None:
26
31
  self.generic_visit(node)
@@ -35,10 +40,47 @@ class SubprocessAsyncRule(AsyncBlockingCallVisitor):
35
40
  function_name=self._current_async_func,
36
41
  message=f"subprocess.{method}() blocks the event loop for the entire subprocess duration",
37
42
  evidence="Use `proc = await asyncio.create_subprocess_exec(*cmd)` then `await proc.communicate()`",
43
+ suggested_fix=self._suggest_fix(node, method),
38
44
  ))
39
45
 
40
46
  self.generic_visit(node)
41
47
 
48
+ def _cmd_source(self, node: ast.Call) -> str:
49
+ """Render the command portion of the call, unpacking list literals."""
50
+ if not node.args:
51
+ return "*cmd"
52
+ first = node.args[0]
53
+ if isinstance(first, ast.List):
54
+ elts_src = ", ".join(render_node(self._source, e) or "..." for e in first.elts)
55
+ return elts_src
56
+ seg = render_node(self._source, first)
57
+ return f"*{seg}" if seg else "*cmd"
58
+
59
+ def _suggest_fix(self, node: ast.Call, method: str) -> Optional[str]:
60
+ if not self._source:
61
+ return None
62
+
63
+ cmd_src = self._cmd_source(node)
64
+
65
+ if method == "run":
66
+ return (
67
+ f"proc = await asyncio.create_subprocess_exec({cmd_src})\n"
68
+ "stdout, stderr = await proc.communicate()"
69
+ )
70
+ if method == "call":
71
+ return (
72
+ f"proc = await asyncio.create_subprocess_exec({cmd_src})\n"
73
+ "returncode = await proc.wait()"
74
+ )
75
+ if method == "check_output":
76
+ return (
77
+ f"proc = await asyncio.create_subprocess_exec({cmd_src}, stdout=asyncio.subprocess.PIPE)\n"
78
+ "stdout, _ = await proc.communicate()"
79
+ )
80
+ if method == "Popen":
81
+ return f"proc = await asyncio.create_subprocess_exec({cmd_src})"
82
+ return None
83
+
42
84
  def _get_subprocess_method(self, node: ast.Call):
43
85
  # subprocess.run(...), subprocess.Popen(...), etc. — qualified form only
44
86
  # Bare names (run, call…) excluded: 'call' is too generic and caused a
@@ -46,6 +46,16 @@ def sync_handler():
46
46
  assert len(violations) == 0
47
47
 
48
48
 
49
+ def test_001_suggested_fix_uses_real_args():
50
+ src = """
51
+ import time
52
+ async def handler():
53
+ time.sleep(5)
54
+ """
55
+ violations = analyze_source(src)
56
+ assert violations[0].suggested_fix == "await asyncio.sleep(5)"
57
+
58
+
49
59
  # ─── PYVIBE-002 ──────────────────────────────────────────────────────────────
50
60
 
51
61
  def test_002_detects_requests_get_in_async():
@@ -80,6 +90,19 @@ def sync_fetch():
80
90
  assert len(violations) == 0
81
91
 
82
92
 
93
+ def test_002_suggested_fix_uses_real_args():
94
+ src = """
95
+ import requests
96
+ async def fetch():
97
+ return requests.get("https://example.com", timeout=5)
98
+ """
99
+ violations = analyze_source(src)
100
+ assert violations[0].suggested_fix == (
101
+ "async with httpx.AsyncClient() as client:\n"
102
+ ' response = await client.get("https://example.com", timeout=5)'
103
+ )
104
+
105
+
83
106
  # ─── PYVIBE-003 ──────────────────────────────────────────────────────────────
84
107
 
85
108
  def test_003_detects_asyncio_run_in_async():
@@ -118,6 +141,16 @@ async def outer():
118
141
  assert len(violations) == 0
119
142
 
120
143
 
144
+ def test_003_suggested_fix_uses_real_coroutine_call():
145
+ src = """
146
+ import asyncio
147
+ async def bad():
148
+ asyncio.run(some_coroutine(1, 2))
149
+ """
150
+ violations = analyze_source(src)
151
+ assert violations[0].suggested_fix == "await some_coroutine(1, 2)"
152
+
153
+
121
154
  # ─── PYVIBE-004 ──────────────────────────────────────────────────────────────
122
155
 
123
156
  def test_004_detects_threading_lock_in_async():
@@ -396,6 +429,31 @@ async def async_process(path):
396
429
  assert len(violations) == 0
397
430
 
398
431
 
432
+ def test_007_suggested_fix_unpacks_list_literal_for_run():
433
+ src = """
434
+ import subprocess
435
+ async def process_file(path):
436
+ subprocess.run(["ffmpeg", "-i", path, "output.mp4"])
437
+ """
438
+ violations = analyze_source(src)
439
+ assert violations[0].suggested_fix == (
440
+ 'proc = await asyncio.create_subprocess_exec("ffmpeg", "-i", path, "output.mp4")\n'
441
+ "stdout, stderr = await proc.communicate()"
442
+ )
443
+
444
+
445
+ def test_007_suggested_fix_for_popen():
446
+ src = """
447
+ import subprocess
448
+ async def get_output():
449
+ proc = subprocess.Popen(["git", "log"], stdout=-1)
450
+ """
451
+ violations = analyze_source(src)
452
+ assert violations[0].suggested_fix == (
453
+ 'proc = await asyncio.create_subprocess_exec("git", "log")'
454
+ )
455
+
456
+
399
457
  # ─── PYVIBE-008 ──────────────────────────────────────────────────────────────
400
458
 
401
459
  def test_008_detects_sqlite3_connect_in_async():
@@ -433,6 +491,17 @@ async def async_get_users():
433
491
  assert len(violations) == 0
434
492
 
435
493
 
494
+ def test_008_suggested_fix_uses_real_args():
495
+ src = """
496
+ import sqlite3
497
+ async def get_users():
498
+ conn = sqlite3.connect("db.sqlite3")
499
+ return conn.cursor().fetchall()
500
+ """
501
+ violations = analyze_source(src)
502
+ assert violations[0].suggested_fix == 'async with aiosqlite.connect("db.sqlite3") as db:'
503
+
504
+
436
505
  # ─── PYVIBE-009 ──────────────────────────────────────────────────────────────
437
506
 
438
507
  def test_009_detects_open_in_async():
@@ -1 +0,0 @@
1
- __version__ = "0.8.0"