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.
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/PKG-INFO +28 -5
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/README.md +27 -4
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyproject.toml +1 -1
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/python_vibe_guard.egg-info/PKG-INFO +28 -5
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/python_vibe_guard.egg-info/SOURCES.txt +1 -0
- python_vibe_guard-0.9.0/pyvibe/__init__.py +1 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/analyzer.py +13 -0
- python_vibe_guard-0.9.0/pyvibe/autofix.py +31 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/cli.py +5 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/async_requests.py +14 -2
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/async_sleep.py +13 -1
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/asyncio_run.py +15 -1
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/base.py +1 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/sqlite_async.py +11 -2
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/subprocess_async.py +43 -1
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/tests/test_rules.py +69 -0
- python_vibe_guard-0.8.0/pyvibe/__init__.py +0 -1
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/python_vibe_guard.egg-info/dependency_links.txt +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/python_vibe_guard.egg-info/entry_points.txt +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/python_vibe_guard.egg-info/top_level.txt +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/__main__.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/explain.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rule_docs.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/__init__.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/celery_time_limit.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/contextvar_cleanup.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/create_task_orphan.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/ensure_future_orphan.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/gather_no_return_exceptions.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/httpx_client_sync.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/httpx_sync.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/loop_run_until_complete.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/open_async.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/os_blocking.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/queue_put_nowait.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/retry_no_backoff.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/silent_except.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/threading_lock.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/while_true_no_await.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/sarif.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/setup.cfg +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/tests/test_exclude.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/tests/test_explain.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`:
|
|
@@ -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 (
|
|
@@ -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"
|
{python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/python_vibe_guard.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/python_vibe_guard.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/python_vibe_guard.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_vibe_guard-0.8.0 → python_vibe_guard-0.9.0}/pyvibe/rules/gather_no_return_exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|