python-vibe-guard 0.8.0__tar.gz → 0.10.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.10.0}/PKG-INFO +28 -5
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/README.md +27 -4
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyproject.toml +1 -1
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/PKG-INFO +28 -5
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/SOURCES.txt +3 -0
- python_vibe_guard-0.10.0/pyvibe/__init__.py +1 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/analyzer.py +22 -2
- python_vibe_guard-0.10.0/pyvibe/autofix.py +31 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/cli.py +118 -0
- python_vibe_guard-0.10.0/pyvibe/diff.py +121 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/async_requests.py +14 -2
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/async_sleep.py +13 -1
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/asyncio_run.py +15 -1
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/base.py +1 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/sqlite_async.py +11 -2
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/subprocess_async.py +43 -1
- python_vibe_guard-0.10.0/tests/test_diff.py +299 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.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.10.0}/python_vibe_guard.egg-info/dependency_links.txt +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/entry_points.txt +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/python_vibe_guard.egg-info/top_level.txt +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/__main__.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/explain.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rule_docs.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/__init__.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/celery_time_limit.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/contextvar_cleanup.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/create_task_orphan.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/ensure_future_orphan.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/gather_no_return_exceptions.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/httpx_client_sync.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/httpx_sync.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/loop_run_until_complete.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/open_async.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/os_blocking.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/queue_put_nowait.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/retry_no_backoff.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/silent_except.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/threading_lock.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/rules/while_true_no_await.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/pyvibe/sarif.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/setup.cfg +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/tests/test_exclude.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.0}/tests/test_explain.py +0 -0
- {python_vibe_guard-0.8.0 → python_vibe_guard-0.10.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.10.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.10.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.10.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`:
|
|
@@ -8,7 +8,9 @@ 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
|
|
13
|
+
pyvibe/diff.py
|
|
12
14
|
pyvibe/explain.py
|
|
13
15
|
pyvibe/rule_docs.py
|
|
14
16
|
pyvibe/sarif.py
|
|
@@ -34,6 +36,7 @@ pyvibe/rules/sqlite_async.py
|
|
|
34
36
|
pyvibe/rules/subprocess_async.py
|
|
35
37
|
pyvibe/rules/threading_lock.py
|
|
36
38
|
pyvibe/rules/while_true_no_await.py
|
|
39
|
+
tests/test_diff.py
|
|
37
40
|
tests/test_exclude.py
|
|
38
41
|
tests/test_explain.py
|
|
39
42
|
tests/test_rules.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.10.0"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import ast
|
|
2
2
|
from pathlib import Path
|
|
3
|
-
from typing import FrozenSet, List
|
|
3
|
+
from typing import FrozenSet, List, Optional
|
|
4
4
|
|
|
5
5
|
from pyvibe.rules.base import Violation
|
|
6
6
|
from pyvibe.rules.async_sleep import AsyncSleepRule
|
|
@@ -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)
|
|
@@ -119,9 +132,16 @@ def analyze_file(
|
|
|
119
132
|
path: Path,
|
|
120
133
|
*,
|
|
121
134
|
downgrade_in_tests: FrozenSet[str] = TEST_FILE_DOWNGRADE,
|
|
135
|
+
line_filter: Optional[FrozenSet[int]] = None,
|
|
122
136
|
) -> List[Violation]:
|
|
137
|
+
"""line_filter: if given, only violations whose `line` is in this set
|
|
138
|
+
are returned (used by `pyvibe review` to report only diff-touched lines).
|
|
139
|
+
"""
|
|
123
140
|
source = path.read_text(encoding="utf-8", errors="ignore")
|
|
124
|
-
|
|
141
|
+
violations = analyze_source(source, filepath=str(path), downgrade_in_tests=downgrade_in_tests)
|
|
142
|
+
if line_filter is not None:
|
|
143
|
+
violations = [v for v in violations if v.line in line_filter]
|
|
144
|
+
return violations
|
|
125
145
|
|
|
126
146
|
|
|
127
147
|
DEFAULT_EXCLUDES = frozenset({
|
|
@@ -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)
|
|
@@ -9,6 +9,8 @@ Usage:
|
|
|
9
9
|
python -m pyvibe <path> --no-test-files # skip test files entirely
|
|
10
10
|
python -m pyvibe <path> --downgrade-in-tests # WARNING instead of CRITICAL in all test files
|
|
11
11
|
python -m pyvibe explain PYVIBE-002 # show research evidence for a rule
|
|
12
|
+
python -m pyvibe review # PR review: only diff-touched lines (git diff HEAD~1)
|
|
13
|
+
python -m pyvibe review --base main # diff against another ref
|
|
12
14
|
"""
|
|
13
15
|
import argparse
|
|
14
16
|
import json
|
|
@@ -31,6 +33,10 @@ def main():
|
|
|
31
33
|
_main_explain(sys.argv[2:])
|
|
32
34
|
return
|
|
33
35
|
|
|
36
|
+
if len(sys.argv) > 1 and sys.argv[1] == "review":
|
|
37
|
+
_main_review(sys.argv[2:])
|
|
38
|
+
return
|
|
39
|
+
|
|
34
40
|
parser = argparse.ArgumentParser(
|
|
35
41
|
prog="pyvibe",
|
|
36
42
|
description="Detect runtime anti-patterns in async Python code",
|
|
@@ -132,6 +138,7 @@ def main():
|
|
|
132
138
|
"function": v.function_name,
|
|
133
139
|
"message": v.message,
|
|
134
140
|
"evidence": v.evidence,
|
|
141
|
+
"suggested_fix": v.suggested_fix,
|
|
135
142
|
})
|
|
136
143
|
print(json.dumps(output, indent=2))
|
|
137
144
|
else:
|
|
@@ -160,6 +167,10 @@ def _print_human(file_results: dict, total_violations: int, total_files: int):
|
|
|
160
167
|
print(f" Function : {v.function_name}()")
|
|
161
168
|
print(f" Problem : {v.message}")
|
|
162
169
|
print(f" Fix : {v.evidence}")
|
|
170
|
+
if v.suggested_fix:
|
|
171
|
+
print(" Suggested fix:")
|
|
172
|
+
for line in v.suggested_fix.splitlines():
|
|
173
|
+
print(f" {line}")
|
|
163
174
|
print()
|
|
164
175
|
|
|
165
176
|
print(" ─────────────────────────────────────────────")
|
|
@@ -167,6 +178,113 @@ def _print_human(file_results: dict, total_violations: int, total_files: int):
|
|
|
167
178
|
print()
|
|
168
179
|
|
|
169
180
|
|
|
181
|
+
_SEVERITY_ICON = {"CRITICAL": "❌", "WARNING": "⚠️"}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _main_review(argv):
|
|
185
|
+
parser = argparse.ArgumentParser(
|
|
186
|
+
prog="pyvibe review",
|
|
187
|
+
description="PR review mode: analyze only lines added/modified in a git diff",
|
|
188
|
+
)
|
|
189
|
+
parser.add_argument(
|
|
190
|
+
"--base",
|
|
191
|
+
default="HEAD~1",
|
|
192
|
+
help="Git ref to diff against (default: HEAD~1)",
|
|
193
|
+
)
|
|
194
|
+
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
195
|
+
parser.add_argument(
|
|
196
|
+
"--sarif",
|
|
197
|
+
action="store_true",
|
|
198
|
+
help="Also write SARIF 2.1.0 output (for GitHub Code Scanning)",
|
|
199
|
+
)
|
|
200
|
+
parser.add_argument(
|
|
201
|
+
"--sarif-output",
|
|
202
|
+
metavar="PATH",
|
|
203
|
+
default="results.sarif",
|
|
204
|
+
help="Path to write SARIF output to (default: results.sarif)",
|
|
205
|
+
)
|
|
206
|
+
args = parser.parse_args(argv)
|
|
207
|
+
|
|
208
|
+
from pyvibe.diff import GitDiffError, get_changed_python_files, get_repo_root
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
repo_root = get_repo_root()
|
|
212
|
+
changed = get_changed_python_files(args.base, cwd=repo_root)
|
|
213
|
+
except GitDiffError as e:
|
|
214
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
215
|
+
sys.exit(2)
|
|
216
|
+
|
|
217
|
+
file_results = {}
|
|
218
|
+
for rel_path, added_lines in changed.items():
|
|
219
|
+
abs_path = repo_root / rel_path
|
|
220
|
+
if not abs_path.exists():
|
|
221
|
+
continue
|
|
222
|
+
violations = analyze_file(abs_path, line_filter=added_lines)
|
|
223
|
+
if violations:
|
|
224
|
+
file_results[rel_path] = violations
|
|
225
|
+
|
|
226
|
+
total_violations = sum(len(v) for v in file_results.values())
|
|
227
|
+
|
|
228
|
+
if args.sarif:
|
|
229
|
+
from pyvibe.sarif import write_sarif
|
|
230
|
+
|
|
231
|
+
write_sarif(file_results, args.sarif_output)
|
|
232
|
+
print(f"SARIF results written to {args.sarif_output}")
|
|
233
|
+
|
|
234
|
+
if args.json:
|
|
235
|
+
output = []
|
|
236
|
+
for path, violations in file_results.items():
|
|
237
|
+
for v in violations:
|
|
238
|
+
output.append({
|
|
239
|
+
"file": str(path),
|
|
240
|
+
"rule": v.rule_id,
|
|
241
|
+
"severity": v.severity,
|
|
242
|
+
"line": v.line,
|
|
243
|
+
"function": v.function_name,
|
|
244
|
+
"message": v.message,
|
|
245
|
+
"evidence": v.evidence,
|
|
246
|
+
"suggested_fix": v.suggested_fix,
|
|
247
|
+
})
|
|
248
|
+
print(json.dumps(output, indent=2))
|
|
249
|
+
else:
|
|
250
|
+
_print_review(file_results, changed, total_violations)
|
|
251
|
+
|
|
252
|
+
sys.exit(1 if total_violations > 0 else 0)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _print_review(file_results: dict, changed: dict, total_violations: int):
|
|
256
|
+
print()
|
|
257
|
+
print(" 🐍 python-vibe-guard — PR Review")
|
|
258
|
+
print(" ─────────────────────────────────")
|
|
259
|
+
print(f" Analyzing {len(changed)} changed file(s)...")
|
|
260
|
+
print()
|
|
261
|
+
|
|
262
|
+
if not file_results:
|
|
263
|
+
print(" No violations found in new/modified code")
|
|
264
|
+
print()
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
for path, violations in file_results.items():
|
|
268
|
+
lines_reviewed = len(changed.get(path, frozenset()))
|
|
269
|
+
print(f" 📄 {path} (+{lines_reviewed} lines reviewed)")
|
|
270
|
+
print()
|
|
271
|
+
for v in violations:
|
|
272
|
+
icon = _SEVERITY_ICON.get(v.severity, "•")
|
|
273
|
+
print(f" {icon} {v.severity} [{v.rule_id}] — line {v.line} (NEW)")
|
|
274
|
+
print(f" Function : {v.function_name}()")
|
|
275
|
+
print(f" Problem : {v.message}")
|
|
276
|
+
print(f" Fix : {v.evidence}")
|
|
277
|
+
if v.suggested_fix:
|
|
278
|
+
print(" Suggested fix:")
|
|
279
|
+
for line in v.suggested_fix.splitlines():
|
|
280
|
+
print(f" {line}")
|
|
281
|
+
print()
|
|
282
|
+
|
|
283
|
+
print(" ─────────────────────────────────")
|
|
284
|
+
print(f" {total_violations} violation(s) in new/modified code")
|
|
285
|
+
print()
|
|
286
|
+
|
|
287
|
+
|
|
170
288
|
def _main_explain(argv):
|
|
171
289
|
parser = argparse.ArgumentParser(
|
|
172
290
|
prog="pyvibe explain",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Diff parsing for `pyvibe review` — extracts changed .py files and the
|
|
2
|
+
new-file line numbers touched by a `git diff`, so review only reports
|
|
3
|
+
violations on lines that are actually part of the diff (deleted lines and
|
|
4
|
+
untouched context are never reported).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, FrozenSet, Optional
|
|
12
|
+
|
|
13
|
+
_PLUS_PLUS_RE = re.compile(r"^\+\+\+ (?:b/(?P<path>.+)|(?P<devnull>/dev/null))$")
|
|
14
|
+
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(?P<new_start>\d+)(?:,\d+)? @@")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GitDiffError(Exception):
|
|
18
|
+
"""Raised when `git diff`/`git rev-parse` can't be run or the base ref is invalid."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_repo_root(cwd: Optional[Path] = None) -> Path:
|
|
22
|
+
"""Resolve the top-level directory of the current git repository.
|
|
23
|
+
|
|
24
|
+
Needed because `git diff` always reports paths relative to the repo
|
|
25
|
+
root, not the caller's cwd — file paths from the diff must be resolved
|
|
26
|
+
against this root, not against Path.cwd().
|
|
27
|
+
"""
|
|
28
|
+
result = subprocess.run(
|
|
29
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
30
|
+
cwd=cwd,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
)
|
|
34
|
+
if result.returncode != 0:
|
|
35
|
+
raise GitDiffError("Not inside a git repository")
|
|
36
|
+
return Path(result.stdout.strip())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_git_diff(base: str, cwd: Optional[Path] = None) -> str:
|
|
40
|
+
"""Run `git diff <base> -- '*.py'` and return its raw text output.
|
|
41
|
+
|
|
42
|
+
Only ever invoked with a list-form subprocess call (no shell=True), so
|
|
43
|
+
`base` can't break out into arbitrary shell commands. It's still
|
|
44
|
+
rejected up front if it looks like a flag, since git itself would
|
|
45
|
+
otherwise interpret e.g. "--upload-pack=..." as an option rather than
|
|
46
|
+
a ref.
|
|
47
|
+
"""
|
|
48
|
+
if base.startswith("-"):
|
|
49
|
+
raise GitDiffError(f"Invalid base ref: {base!r}")
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
["git", "diff", base, "--", "*.py"],
|
|
54
|
+
cwd=cwd,
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True,
|
|
57
|
+
)
|
|
58
|
+
except FileNotFoundError as e:
|
|
59
|
+
raise GitDiffError("git executable not found") from e
|
|
60
|
+
|
|
61
|
+
if result.returncode != 0:
|
|
62
|
+
raise GitDiffError(result.stderr.strip() or f"git diff failed for base {base!r}")
|
|
63
|
+
|
|
64
|
+
return result.stdout
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_diff(diff_text: str) -> Dict[str, FrozenSet[int]]:
|
|
68
|
+
"""Parse unified `git diff` output into {file_path: {added_line_numbers}}.
|
|
69
|
+
|
|
70
|
+
Only .py files are kept. Deleted lines (prefix "-") and unchanged
|
|
71
|
+
context lines never appear in the result — only lines that are actually
|
|
72
|
+
added/modified in the new version of the file.
|
|
73
|
+
"""
|
|
74
|
+
files: Dict[str, set] = {}
|
|
75
|
+
current_file: Optional[str] = None
|
|
76
|
+
current_line: Optional[int] = None
|
|
77
|
+
|
|
78
|
+
for line in diff_text.splitlines():
|
|
79
|
+
if line.startswith("diff --git "):
|
|
80
|
+
current_file = None
|
|
81
|
+
current_line = None
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
if line.startswith("+++ "):
|
|
85
|
+
m = _PLUS_PLUS_RE.match(line)
|
|
86
|
+
current_file = m.group("path") if (m and m.group("path")) else None
|
|
87
|
+
current_line = None
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
if current_file is None or not current_file.endswith(".py"):
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
hunk_match = _HUNK_RE.match(line)
|
|
94
|
+
if hunk_match:
|
|
95
|
+
current_line = int(hunk_match.group("new_start"))
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
if current_line is None:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
if line.startswith("\\"):
|
|
102
|
+
continue # ""
|
|
103
|
+
elif line.startswith("+"):
|
|
104
|
+
files.setdefault(current_file, set()).add(current_line)
|
|
105
|
+
current_line += 1
|
|
106
|
+
elif line.startswith("-"):
|
|
107
|
+
continue # removed line — doesn't exist in the new file
|
|
108
|
+
else:
|
|
109
|
+
current_line += 1 # unchanged context line
|
|
110
|
+
|
|
111
|
+
return {path: frozenset(lines) for path, lines in files.items()}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_changed_python_files(base: str, cwd: Optional[Path] = None) -> Dict[str, FrozenSet[int]]:
|
|
115
|
+
"""High-level entry point used by `pyvibe review`.
|
|
116
|
+
|
|
117
|
+
Returns {repo-relative path: {added line numbers}} for every .py file
|
|
118
|
+
touched in `git diff <base>`.
|
|
119
|
+
"""
|
|
120
|
+
diff_text = get_git_diff(base, cwd=cwd)
|
|
121
|
+
return parse_diff(diff_text)
|
|
@@ -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 (
|