python-vibe-guard 0.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_vibe_guard-0.7.1.dist-info/METADATA +286 -0
- python_vibe_guard-0.7.1.dist-info/RECORD +31 -0
- python_vibe_guard-0.7.1.dist-info/WHEEL +5 -0
- python_vibe_guard-0.7.1.dist-info/entry_points.txt +2 -0
- python_vibe_guard-0.7.1.dist-info/top_level.txt +1 -0
- pyvibe/__init__.py +1 -0
- pyvibe/__main__.py +3 -0
- pyvibe/analyzer.py +163 -0
- pyvibe/cli.py +148 -0
- pyvibe/rules/__init__.py +0 -0
- pyvibe/rules/async_requests.py +65 -0
- pyvibe/rules/async_sleep.py +44 -0
- pyvibe/rules/asyncio_run.py +48 -0
- pyvibe/rules/base.py +65 -0
- pyvibe/rules/celery_time_limit.py +103 -0
- pyvibe/rules/contextvar_cleanup.py +129 -0
- pyvibe/rules/create_task_orphan.py +44 -0
- pyvibe/rules/ensure_future_orphan.py +44 -0
- pyvibe/rules/gather_no_return_exceptions.py +57 -0
- pyvibe/rules/httpx_client_sync.py +43 -0
- pyvibe/rules/httpx_sync.py +52 -0
- pyvibe/rules/loop_run_until_complete.py +37 -0
- pyvibe/rules/open_async.py +39 -0
- pyvibe/rules/os_blocking.py +49 -0
- pyvibe/rules/queue_put_nowait.py +98 -0
- pyvibe/rules/retry_no_backoff.py +255 -0
- pyvibe/rules/silent_except.py +107 -0
- pyvibe/rules/sqlite_async.py +82 -0
- pyvibe/rules/subprocess_async.py +54 -0
- pyvibe/rules/threading_lock.py +89 -0
- pyvibe/rules/while_true_no_await.py +111 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-vibe-guard
|
|
3
|
+
Version: 0.7.1
|
|
4
|
+
Summary: Runtime anti-pattern scanner for async Python — detects what AI-generated code gets wrong
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: async,linter,fastapi,asyncio,static-analysis
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# python-vibe-guard
|
|
17
|
+
|
|
18
|
+
[](https://github.com/Joaquinriosheredia/python-vibe-guard/actions/workflows/ci.yml)
|
|
19
|
+
|
|
20
|
+
## The incident
|
|
21
|
+
|
|
22
|
+
A FastAPI service that handled 50 concurrent requests in staging started timing out in production at 200 rps. The team spent two days adding replicas, tweaking Gunicorn workers, and profiling CPU — nothing helped. The p99 latency was 8 seconds for an endpoint that should take 80ms.
|
|
23
|
+
|
|
24
|
+
> Scenario based on a common pattern observed in async Python codebases.
|
|
25
|
+
> Reproduced in controlled testing with locust against a FastAPI service.
|
|
26
|
+
|
|
27
|
+
Root cause: one engineer had asked an AI assistant to "add a retry with backoff" to an async handler. The AI generated `time.sleep(2)` inside the `async def`. In staging with a handful of requests it was invisible. In production it froze the entire event loop for 2 seconds per request, serializing all 200 concurrent calls through a single bottleneck.
|
|
28
|
+
|
|
29
|
+
The code passed every unit test. It passed the integration tests. It shipped to production in a Friday deploy.
|
|
30
|
+
|
|
31
|
+
**python-vibe-guard catches this in CI before it reaches staging.**
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## What it detects
|
|
36
|
+
|
|
37
|
+
Twenty patterns that AI models generate repeatedly, that pass all static checks, and that silently destroy async performance under real load:
|
|
38
|
+
|
|
39
|
+
### A. Event Loop Blocking
|
|
40
|
+
|
|
41
|
+
Synchronous calls inside `async def` — each stalls the event loop for its entire duration, serializing all concurrent requests.
|
|
42
|
+
|
|
43
|
+
| Rule | Pattern | Gate | Runtime effect |
|
|
44
|
+
|------|---------|------|----------------|
|
|
45
|
+
| PYVIBE-001 | `time.sleep()` inside `async def` | `async def` | Freezes entire event loop for sleep duration |
|
|
46
|
+
| PYVIBE-002 | `requests.*` inside `async def` | `async def` | Blocks OS thread, serializes all concurrent I/O |
|
|
47
|
+
| PYVIBE-007 | `subprocess.run/call/check_output/Popen` inside `async def` | `async def` | Blocks OS thread for entire subprocess duration |
|
|
48
|
+
| PYVIBE-008 | `sqlite3.connect()` inside `async def` | `async def` | Synchronous file I/O blocks the event loop |
|
|
49
|
+
| PYVIBE-009 | `open()` builtin inside `async def` | `async def` | Synchronous file I/O blocks the event loop |
|
|
50
|
+
| PYVIBE-010 | `httpx.get/post/put/…` inside `async def` | `async def` | httpx sync API blocks OS thread for full HTTP round-trip |
|
|
51
|
+
| PYVIBE-011 | `os.system/popen/waitpid` inside `async def` | `async def` | Blocking OS calls with no direct async equivalent |
|
|
52
|
+
| PYVIBE-016 | `httpx.Client()` instantiated inside `async def` | `async def` | Sync client blocks OS thread per request; `httpx.AsyncClient()` is excluded |
|
|
53
|
+
|
|
54
|
+
### B. Async Lifecycle Misuse
|
|
55
|
+
|
|
56
|
+
Incorrect use of asyncio primitives that raise `RuntimeError` at runtime or silently discard tasks mid-execution.
|
|
57
|
+
|
|
58
|
+
| Rule | Pattern | Gate | Runtime effect |
|
|
59
|
+
|------|---------|------|----------------|
|
|
60
|
+
| PYVIBE-003 | `asyncio.run()` inside `async def` | `async def` | `RuntimeError: This event loop is already running` |
|
|
61
|
+
| PYVIBE-012 | `asyncio.create_task()` with discarded return value | `async def` | Task GC'd mid-execution; exceptions silently swallowed |
|
|
62
|
+
| PYVIBE-013 | `asyncio.gather()` without `return_exceptions=True` | `async def` | First exception leaks remaining tasks; no per-task error handling |
|
|
63
|
+
| PYVIBE-014 | `asyncio.ensure_future()` with discarded return value | `async def` | Same GC hazard as PYVIBE-012; pre-3.7 API still common in older codebases |
|
|
64
|
+
| PYVIBE-015 | `loop.run_until_complete()` inside `async def` | `async def` | `RuntimeError: This event loop is already running` |
|
|
65
|
+
|
|
66
|
+
### C. Concurrency & State Hazards
|
|
67
|
+
|
|
68
|
+
Patterns that introduce data races, swallowed errors, or runaway loops under concurrent async load.
|
|
69
|
+
|
|
70
|
+
| Rule | Pattern | Gate | Runtime effect |
|
|
71
|
+
|------|---------|------|----------------|
|
|
72
|
+
| PYVIBE-004 | `threading.Lock/RLock/…` inside `async def` | `async def` | Blocks event loop under contention |
|
|
73
|
+
| PYVIBE-005 | `@app.task` / `@shared_task` without `soft_time_limit` or `time_limit` | decorator | Worker hangs forever if external call never returns |
|
|
74
|
+
| PYVIBE-006 | `ContextVar.set()` inside `async def` without `try/finally reset()` | `async def` | Context leaks into sibling async tasks |
|
|
75
|
+
| PYVIBE-017 | `except Exception: pass` / bare `except: pass` (empty body) | any | Swallows all errors silently; bare except also catches `KeyboardInterrupt`/`SystemExit` |
|
|
76
|
+
| PYVIBE-018 | `while True:` inside `async def` with no `await` in body | `async def` | Event loop blocked indefinitely; CPU hits 100% |
|
|
77
|
+
| PYVIBE-019 | retry `for`/`while` loop in `async def` with no backoff in `except` | `async def` | Tight retry loop on failure: thousands of failed requests/sec, cascading failures |
|
|
78
|
+
| PYVIBE-020 | `put_nowait()` without `asyncio.QueueFull` handler | any | `QueueFull` propagates unhandled; item is silently lost on bounded queues |
|
|
79
|
+
|
|
80
|
+
**Severity notes:**
|
|
81
|
+
- PYVIBE-005: `CRITICAL` — checks per-task decorator arguments only. **If your project sets a global `task_time_limit` via `app.conf.task_time_limit`, `app.conf.update(...)`, or in `celeryconfig.py` / `settings.py`, tasks already covered by that global limit will still be flagged.** Add per-task limits (self-documenting, immune to config drift) or suppress with `# noqa: PYVIBE-005`.
|
|
82
|
+
- PYVIBE-009: `CRITICAL` in production files; automatically downgraded to `WARNING` in test files (`test_*.py`, `*_test.py`, `tests/`). **Context matters:** `open()` in a hot-path request handler blocks all concurrent coroutines (CRITICAL); `open()` in a startup/lifespan function runs before requests are served and has zero practical impact. The rule cannot distinguish these contexts via AST — if you use `open()` in an `async def` lifespan or one-time initializer, the idiomatic fix is to use plain `def` instead (FastAPI's own docs do this), which avoids the flag entirely.
|
|
83
|
+
- PYVIBE-017: bare `except` → `CRITICAL` (catches `KeyboardInterrupt`/`SystemExit`); `except Exception` with empty body → `WARNING`. Specific exceptions (`except ValueError: pass`) are not flagged.
|
|
84
|
+
- PYVIBE-013 in test files: automatically downgraded to `WARNING` in files matching `test_*.py`, `*_test.py`, or paths under `tests/` — exceptions should propagate for assertions in test code.
|
|
85
|
+
- PYVIBE-019: `WARNING` — flags `except` that ends with `continue` or is solely `pass` with no sleep/backoff call. Suppressed when an escalation pattern (`if … : raise/break`) is present.
|
|
86
|
+
- PYVIBE-020: `WARNING` — fires in any function context (sync and async). Suppressed when the `put_nowait()` is inside a `try` whose handlers include `asyncio.QueueFull`, `QueueFull`, bare `except`, or `Exception`.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Validation
|
|
91
|
+
|
|
92
|
+
### 100-repo sweep (automated, v0.7.0)
|
|
93
|
+
|
|
94
|
+
Automated scan of 100 GitHub repos (`stars:>100`, updated last year, async Python keywords). Script: [`validation/run_massive_scan.py`](validation/run_massive_scan.py).
|
|
95
|
+
|
|
96
|
+
| Metric | Value |
|
|
97
|
+
|--------|-------|
|
|
98
|
+
| Repos scanned | 100 |
|
|
99
|
+
| .py files | 64,335 |
|
|
100
|
+
| Total violations | 3,639 |
|
|
101
|
+
|
|
102
|
+
**Rule prevalence (top 10):**
|
|
103
|
+
|
|
104
|
+
| Rule | Hits | Repos affected | % of repos |
|
|
105
|
+
|------|------|----------------|-----------|
|
|
106
|
+
| PYVIBE-017 `except Exception: pass` | 999 | 57 | **57%** |
|
|
107
|
+
| PYVIBE-013 `gather()` no return_exceptions | 766 | 47 | **47%** |
|
|
108
|
+
| PYVIBE-019 retry loop no backoff | 408 | 43 | **43%** |
|
|
109
|
+
| PYVIBE-009 `open()` in async def | 178 | 33 | **33%** |
|
|
110
|
+
| PYVIBE-005 `@task` no time_limit | 882 | 15 | 15% |
|
|
111
|
+
| PYVIBE-001 `time.sleep` in async | 55 | 16 | 16% |
|
|
112
|
+
| PYVIBE-012 orphaned `create_task()` | 58 | 13 | 13% |
|
|
113
|
+
| PYVIBE-008 `sqlite3` in async def | 121 | 10 | 10% |
|
|
114
|
+
| PYVIBE-018 `while True` no await | 36 | 10 | 10% |
|
|
115
|
+
| PYVIBE-006 ContextVar no reset | 19 | 10 | 10% |
|
|
116
|
+
|
|
117
|
+
Full data: [`validation/aggregate.json`](validation/aggregate.json) · [`validation/massive-results.md`](validation/massive-results.md)
|
|
118
|
+
|
|
119
|
+
### Reference scan — 4 repos (reproducible)
|
|
120
|
+
|
|
121
|
+
| Repo | .py files | Violations |
|
|
122
|
+
|------|-----------|-----------|
|
|
123
|
+
| [fastapi/fastapi](https://github.com/tiangolo/fastapi) | 1 121 | 5 |
|
|
124
|
+
| [celery/celery](https://github.com/celery/celery) | 416 | 363 |
|
|
125
|
+
| [aio-libs/aiohttp](https://github.com/aio-libs/aiohttp) | 164 | 29 |
|
|
126
|
+
| [encode/httpx](https://github.com/encode/httpx) | 60 | 0 |
|
|
127
|
+
| **Total** | **1 761** | **397** |
|
|
128
|
+
|
|
129
|
+
Raw data: [`validation/breakdown.json`](validation/breakdown.json)
|
|
130
|
+
|
|
131
|
+
**Notable findings:**
|
|
132
|
+
|
|
133
|
+
- **home-assistant/core** — 418 violations across 17,702 files; 331 PYVIBE-013, 21 PYVIBE-018.
|
|
134
|
+
- **Celery** — 352 tasks without `time_limit`; workers can hang indefinitely on broker timeouts.
|
|
135
|
+
- **aiortc, anyio, uvicorn** — `while True` loops without `await` in production async I/O code (PYVIBE-018, 29 confirmed real hits after async-generator FP fix).
|
|
136
|
+
- **aiohttp examples** — `open()` in async WebSocket handlers; synchronous I/O in production-facing code.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Installation
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
pip install python-vibe-guard
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Or run without installing:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
pip install -e .
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Usage
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
# Scan a file
|
|
158
|
+
python -m pyvibe path/to/file.py
|
|
159
|
+
|
|
160
|
+
# Scan a directory recursively
|
|
161
|
+
python -m pyvibe src/
|
|
162
|
+
|
|
163
|
+
# JSON output for CI/CD pipelines
|
|
164
|
+
python -m pyvibe src/ --json
|
|
165
|
+
|
|
166
|
+
# Exclude directories (adds to built-in defaults: venv, .venv, __pycache__, …)
|
|
167
|
+
python -m pyvibe src/ --exclude tests
|
|
168
|
+
|
|
169
|
+
# Exit code: 0 = clean, 1 = violations found, 2 = path error
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Example output
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
python-vibe-guard
|
|
176
|
+
─────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
demo/bad_async.py
|
|
179
|
+
|
|
180
|
+
[CRITICAL] [PYVIBE-001] — line 14
|
|
181
|
+
Function : process_order()
|
|
182
|
+
Problem : time.sleep() blocks the entire event loop
|
|
183
|
+
Fix : Use `await asyncio.sleep(n)` instead
|
|
184
|
+
|
|
185
|
+
[CRITICAL] [PYVIBE-002] — line 20
|
|
186
|
+
Function : fetch_user()
|
|
187
|
+
Problem : requests.get() is synchronous — blocks the event loop
|
|
188
|
+
Fix : Use `async with httpx.AsyncClient() as c: await c.get(url)`
|
|
189
|
+
|
|
190
|
+
[CRITICAL] [PYVIBE-003] — line 26
|
|
191
|
+
Function : orchestrate()
|
|
192
|
+
Problem : asyncio.run() inside async def raises RuntimeError at runtime
|
|
193
|
+
Fix : Use `await coroutine()` directly — asyncio.run() is for sync entrypoints only
|
|
194
|
+
|
|
195
|
+
[CRITICAL] [PYVIBE-004] — line 32
|
|
196
|
+
Function : update_counter()
|
|
197
|
+
Problem : threading.Lock() blocks the event loop under contention
|
|
198
|
+
Fix : Use `asyncio.Lock()` with `async with lock:` instead
|
|
199
|
+
|
|
200
|
+
─────────────────────────────────────────────
|
|
201
|
+
4 violation(s) in 1 file(s)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## CI/CD integration
|
|
207
|
+
|
|
208
|
+
Add to your GitHub Actions workflow:
|
|
209
|
+
|
|
210
|
+
```yaml
|
|
211
|
+
- name: python-vibe-guard scan
|
|
212
|
+
run: |
|
|
213
|
+
pip install python-vibe-guard
|
|
214
|
+
python -m pyvibe src/ --json
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The scanner exits with code `1` when violations are found, failing the CI job.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Pre-commit integration
|
|
222
|
+
|
|
223
|
+
Add to your `.pre-commit-config.yaml`:
|
|
224
|
+
|
|
225
|
+
```yaml
|
|
226
|
+
repos:
|
|
227
|
+
- repo: https://github.com/Joaquinriosheredia/python-vibe-guard
|
|
228
|
+
rev: v0.7.0
|
|
229
|
+
hooks:
|
|
230
|
+
- id: python-vibe-guard
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Then install the hook:
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
pre-commit install
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The hook runs on every `git commit`, scans all Python files in the project, and blocks the commit if any violations are found.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Design
|
|
244
|
+
|
|
245
|
+
- **Pure AST, zero runtime dependencies** — uses only Python's built-in `ast` module
|
|
246
|
+
- **Each rule is an independent `ast.NodeVisitor`** — easy to add, disable, or extend
|
|
247
|
+
- **Gate on `async def`** — most rules check `_current_async_func` before firing; PYVIBE-017 (silent except) fires in any function context
|
|
248
|
+
- **No import resolution** — works on any Python file without installing its dependencies
|
|
249
|
+
- **Test-aware severity** — PYVIBE-013 is automatically downgraded to WARNING in test files
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Run the demo
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
python -m pyvibe demo/bad_async.py
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Expected: 20 findings (17 CRITICAL + 3 WARNING for PYVIBE-017 `except Exception`, PYVIBE-019 retry without backoff, and PYVIBE-020 `put_nowait` without handler), one per rule. `demo/bad_async.py` also contains a sync function that mirrors the async-specific patterns — those produce zero findings.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Run the tests
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
python -m pytest tests/ -v
|
|
267
|
+
# or
|
|
268
|
+
python tests/test_rules.py
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
123 tests: true positives + false-positive guards for every rule.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Ecosystem
|
|
276
|
+
|
|
277
|
+
This project is part of the **vibe-guard** family of runtime anti-pattern scanners:
|
|
278
|
+
|
|
279
|
+
- [java-vibe-guard](https://github.com/jouninno/java-vibe-guard) — Spring Boot / async Java (blocking Kafka, @Transactional + Virtual Threads)
|
|
280
|
+
- **python-vibe-guard** — FastAPI / asyncio (this project)
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## License
|
|
285
|
+
|
|
286
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
pyvibe/__init__.py,sha256=RaANGbRu5e-vehwXI1-Qe2ggPPfs1TQaZj072JdbLk4,22
|
|
2
|
+
pyvibe/__main__.py,sha256=pHY5xMbHOrlR3yByOHZAONVsv7p3V_7nBGGrQ5pzRv0,36
|
|
3
|
+
pyvibe/analyzer.py,sha256=DY9pvWuKY8ITxERQM9AUY5m6joKf-1A5tCpKycJp94Y,5851
|
|
4
|
+
pyvibe/cli.py,sha256=3xd5cCrXgIRQlRNB6QjIxKnHvVyqwaInnHeNuwHAjOM,4832
|
|
5
|
+
pyvibe/rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
pyvibe/rules/async_requests.py,sha256=9Z3Hj5Ic-BqlqyVffHxkAW8_RcT97H0jkj6L7BurJ54,2463
|
|
7
|
+
pyvibe/rules/async_sleep.py,sha256=puUEjX7y2HT7X8a2MMTBhEQK9M1IywI1RNB36zD9V10,1459
|
|
8
|
+
pyvibe/rules/asyncio_run.py,sha256=UvNKWW0yaLkRdC2yr_zEvBAgz9jEGB-lv7p2rYOCrQs,1649
|
|
9
|
+
pyvibe/rules/base.py,sha256=kdzLvia9wgECAIYFcMqlxwTQumZFTQyIokMMAYeKFuE,2424
|
|
10
|
+
pyvibe/rules/celery_time_limit.py,sha256=mauGXJm6iSzMyih1P-4XNFkbMUD31TJkKcCUyNCzNRc,4864
|
|
11
|
+
pyvibe/rules/contextvar_cleanup.py,sha256=bQF9NITk4UpZUzwGZsb1Pty7B55Xqzqj88zRTUeVvLI,5478
|
|
12
|
+
pyvibe/rules/create_task_orphan.py,sha256=d3kTOypIbPgJHWVt7BIJQTi3IPEgs74e3l92EEpjw3g,1884
|
|
13
|
+
pyvibe/rules/ensure_future_orphan.py,sha256=T9_bxaKh-yxMFzdgK5cyNfbUnv_FdLAMH4hZ-hlmDLE,1809
|
|
14
|
+
pyvibe/rules/gather_no_return_exceptions.py,sha256=-330iwoBgQmxJpFjc7VTVa2pNX0ngqdS2LsibiX0qwQ,2311
|
|
15
|
+
pyvibe/rules/httpx_client_sync.py,sha256=JuB0B_33V4ebsvDtjd3TccyUKwhkCiWWSq6g_I20jYI,1671
|
|
16
|
+
pyvibe/rules/httpx_sync.py,sha256=rJsrkVCGR1gDfIR77KsgWP-9mDMj83RyLut40sV9LvQ,1849
|
|
17
|
+
pyvibe/rules/loop_run_until_complete.py,sha256=f1DY1cuqAQ0Lrlra_-BHuk3Zyro36OgC_P0_K9Y2NXY,1571
|
|
18
|
+
pyvibe/rules/open_async.py,sha256=4PQ-Qc1PALp-cOBqSxxFUgxLb0u8uXYLSKYnOFaZ1Qw,1432
|
|
19
|
+
pyvibe/rules/os_blocking.py,sha256=vSxQXwnv0sHIfWjjm3l1Vev7Ju3epqk61sBN2Q6iH84,1688
|
|
20
|
+
pyvibe/rules/queue_put_nowait.py,sha256=hxOkxSczLFqZsWGYGoFAiQwZcC13qp1LRwmyJDnV6TQ,3813
|
|
21
|
+
pyvibe/rules/retry_no_backoff.py,sha256=W9Ckm1rijRP91bigZazVfJTALX7DHidSTATHRBwpIHk,10077
|
|
22
|
+
pyvibe/rules/silent_except.py,sha256=C8Xmhg2M9oZa_yMDTICFTn4BXEBCZyr7BHxy03cmWDw,3623
|
|
23
|
+
pyvibe/rules/sqlite_async.py,sha256=FlgCwgEscFrQ6M4mJPcOHRSSfRGb0v4h-iXgW-Mif58,3242
|
|
24
|
+
pyvibe/rules/subprocess_async.py,sha256=K-Bt-QFSNH_-KvCnDuB_z7yBf6RwTV1bbyaP5t9rzK4,2001
|
|
25
|
+
pyvibe/rules/threading_lock.py,sha256=Qf947NaTfdys4pqwb5DMB8O9aNgtC3QaN_20ukwa8Yk,3621
|
|
26
|
+
pyvibe/rules/while_true_no_await.py,sha256=bHV7Quf2_1kNu_pFOMjt-AbQkBW8-ue3uwd6qDHtoFE,4366
|
|
27
|
+
python_vibe_guard-0.7.1.dist-info/METADATA,sha256=GvTxw7r3tnRnMZn2KxCtP7EuyY2RNcand8WDX7Ed0vQ,13154
|
|
28
|
+
python_vibe_guard-0.7.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
29
|
+
python_vibe_guard-0.7.1.dist-info/entry_points.txt,sha256=ASoyE-lbzauH_Ow-_tgfXHl1EJ5ANP0eHpgfq-dDNbQ,43
|
|
30
|
+
python_vibe_guard-0.7.1.dist-info/top_level.txt,sha256=5yep6lXEG4duf4CyVYiCK9c_9p3iBBnkFjqgxr0t2iw,7
|
|
31
|
+
python_vibe_guard-0.7.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyvibe
|
pyvibe/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.7.0"
|
pyvibe/__main__.py
ADDED
pyvibe/analyzer.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import FrozenSet, List
|
|
4
|
+
|
|
5
|
+
from pyvibe.rules.base import Violation
|
|
6
|
+
from pyvibe.rules.async_sleep import AsyncSleepRule
|
|
7
|
+
from pyvibe.rules.async_requests import AsyncRequestsRule
|
|
8
|
+
from pyvibe.rules.asyncio_run import AsyncioRunRule
|
|
9
|
+
from pyvibe.rules.threading_lock import ThreadingLockRule
|
|
10
|
+
from pyvibe.rules.contextvar_cleanup import ContextVarCleanupRule
|
|
11
|
+
from pyvibe.rules.celery_time_limit import CeleryTaskTimeLimitRule
|
|
12
|
+
from pyvibe.rules.subprocess_async import SubprocessAsyncRule
|
|
13
|
+
from pyvibe.rules.sqlite_async import SqliteAsyncRule
|
|
14
|
+
from pyvibe.rules.open_async import OpenAsyncRule
|
|
15
|
+
from pyvibe.rules.httpx_sync import HttpxSyncRule
|
|
16
|
+
from pyvibe.rules.os_blocking import OsBlockingRule
|
|
17
|
+
from pyvibe.rules.create_task_orphan import CreateTaskOrphanRule
|
|
18
|
+
from pyvibe.rules.gather_no_return_exceptions import GatherNoReturnExceptionsRule
|
|
19
|
+
from pyvibe.rules.ensure_future_orphan import EnsureFutureOrphanRule
|
|
20
|
+
from pyvibe.rules.loop_run_until_complete import LoopRunUntilCompleteRule
|
|
21
|
+
from pyvibe.rules.httpx_client_sync import HttpxClientSyncRule
|
|
22
|
+
from pyvibe.rules.silent_except import SilentExceptRule
|
|
23
|
+
from pyvibe.rules.while_true_no_await import WhileTrueNoAwaitRule
|
|
24
|
+
from pyvibe.rules.retry_no_backoff import RetryNoBackoffRule
|
|
25
|
+
from pyvibe.rules.queue_put_nowait import QueuePutNowaitRule
|
|
26
|
+
|
|
27
|
+
ALL_RULES = [
|
|
28
|
+
AsyncSleepRule,
|
|
29
|
+
AsyncRequestsRule,
|
|
30
|
+
AsyncioRunRule,
|
|
31
|
+
ThreadingLockRule,
|
|
32
|
+
CeleryTaskTimeLimitRule,
|
|
33
|
+
SubprocessAsyncRule,
|
|
34
|
+
SqliteAsyncRule,
|
|
35
|
+
OpenAsyncRule,
|
|
36
|
+
HttpxSyncRule,
|
|
37
|
+
OsBlockingRule,
|
|
38
|
+
ContextVarCleanupRule,
|
|
39
|
+
CreateTaskOrphanRule,
|
|
40
|
+
GatherNoReturnExceptionsRule,
|
|
41
|
+
EnsureFutureOrphanRule,
|
|
42
|
+
LoopRunUntilCompleteRule,
|
|
43
|
+
HttpxClientSyncRule,
|
|
44
|
+
SilentExceptRule,
|
|
45
|
+
WhileTrueNoAwaitRule,
|
|
46
|
+
RetryNoBackoffRule,
|
|
47
|
+
QueuePutNowaitRule,
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
ALL_RULE_IDS: FrozenSet[str] = frozenset(r.RULE_ID for r in ALL_RULES)
|
|
51
|
+
|
|
52
|
+
# Rules downgraded CRITICAL → WARNING when the violation is inside a test file.
|
|
53
|
+
# time.sleep in fixtures, subprocess in service-startup helpers, open() in
|
|
54
|
+
# async test helpers (e.g. asyncssh, aiofiles own test suites), and Celery task
|
|
55
|
+
# fixtures in test suites (e.g. celery/t/) that intentionally omit time_limit.
|
|
56
|
+
TEST_FILE_DOWNGRADE: FrozenSet[str] = frozenset({
|
|
57
|
+
"PYVIBE-001", # time.sleep — valid in timing tests
|
|
58
|
+
"PYVIBE-005", # celery task — fixtures intentionally omit time_limit
|
|
59
|
+
"PYVIBE-006", # ContextVar.set — test subjects verifying propagation
|
|
60
|
+
"PYVIBE-007", # subprocess.run — launching test servers/processes
|
|
61
|
+
"PYVIBE-008", # sqlite3.connect — smoke tests using real sqlite
|
|
62
|
+
"PYVIBE-009", # open() — test helpers, aiofiles own test suite
|
|
63
|
+
"PYVIBE-012", # create_task orphan — concurrent setup in tests
|
|
64
|
+
"PYVIBE-013", # gather no return_exceptions — test stubs
|
|
65
|
+
"PYVIBE-014", # ensure_future orphan — concurrent setup in tests
|
|
66
|
+
"PYVIBE-016", # httpx.Client sync — test transport fixtures
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_test_file(filepath: str) -> bool:
|
|
71
|
+
p = Path(filepath)
|
|
72
|
+
name = p.name
|
|
73
|
+
parts = p.parts
|
|
74
|
+
return (
|
|
75
|
+
name.startswith("test_")
|
|
76
|
+
or name.endswith("_test.py")
|
|
77
|
+
or "tests" in parts
|
|
78
|
+
or "test" in parts
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def analyze_source(
|
|
83
|
+
source: str,
|
|
84
|
+
filepath: str = "<string>",
|
|
85
|
+
*,
|
|
86
|
+
downgrade_in_tests: FrozenSet[str] = TEST_FILE_DOWNGRADE,
|
|
87
|
+
) -> List[Violation]:
|
|
88
|
+
"""Parse source and run all rules. Returns list of violations.
|
|
89
|
+
|
|
90
|
+
downgrade_in_tests: rule IDs whose severity is lowered to WARNING when
|
|
91
|
+
the file is detected as a test file. Pass frozenset() to disable all
|
|
92
|
+
downgrading, or ALL_RULE_IDS to downgrade every rule.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
tree = ast.parse(source)
|
|
96
|
+
except SyntaxError:
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
source_lines = source.splitlines()
|
|
100
|
+
violations = []
|
|
101
|
+
for RuleClass in ALL_RULES:
|
|
102
|
+
if RuleClass is SilentExceptRule:
|
|
103
|
+
visitor = RuleClass(source_lines)
|
|
104
|
+
else:
|
|
105
|
+
visitor = RuleClass()
|
|
106
|
+
visitor.visit(tree)
|
|
107
|
+
violations.extend(visitor.violations)
|
|
108
|
+
|
|
109
|
+
if downgrade_in_tests and _is_test_file(filepath):
|
|
110
|
+
for v in violations:
|
|
111
|
+
if v.rule_id in downgrade_in_tests:
|
|
112
|
+
v.severity = "WARNING"
|
|
113
|
+
|
|
114
|
+
violations.sort(key=lambda v: v.line)
|
|
115
|
+
return violations
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def analyze_file(
|
|
119
|
+
path: Path,
|
|
120
|
+
*,
|
|
121
|
+
downgrade_in_tests: FrozenSet[str] = TEST_FILE_DOWNGRADE,
|
|
122
|
+
) -> List[Violation]:
|
|
123
|
+
source = path.read_text(encoding="utf-8", errors="ignore")
|
|
124
|
+
return analyze_source(source, filepath=str(path), downgrade_in_tests=downgrade_in_tests)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
DEFAULT_EXCLUDES = frozenset({
|
|
128
|
+
"venv", ".venv", "env", "__pycache__", ".git",
|
|
129
|
+
"node_modules", ".tox", "dist", "build", ".eggs",
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def analyze_directory(
|
|
134
|
+
root: Path,
|
|
135
|
+
exclude: frozenset = DEFAULT_EXCLUDES,
|
|
136
|
+
*,
|
|
137
|
+
skip_test_files: bool = False,
|
|
138
|
+
downgrade_in_tests: FrozenSet[str] = TEST_FILE_DOWNGRADE,
|
|
139
|
+
) -> dict:
|
|
140
|
+
"""Walk directory and analyze all .py files. Returns {path: [violations]}.
|
|
141
|
+
|
|
142
|
+
Directories whose *name* appears in `exclude` are skipped entirely.
|
|
143
|
+
skip_test_files: if True, files matching test_*.py / *_test.py / tests/* are
|
|
144
|
+
omitted from results entirely rather than downgraded.
|
|
145
|
+
"""
|
|
146
|
+
results = {}
|
|
147
|
+
for py_file in _walk(root, exclude):
|
|
148
|
+
if skip_test_files and _is_test_file(str(py_file)):
|
|
149
|
+
continue
|
|
150
|
+
violations = analyze_file(py_file, downgrade_in_tests=downgrade_in_tests)
|
|
151
|
+
if violations:
|
|
152
|
+
results[py_file] = violations
|
|
153
|
+
return results
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _walk(root: Path, exclude: frozenset):
|
|
157
|
+
"""Yield .py files under root, skipping any directory in exclude."""
|
|
158
|
+
for entry in sorted(root.iterdir()):
|
|
159
|
+
if entry.is_dir():
|
|
160
|
+
if entry.name not in exclude:
|
|
161
|
+
yield from _walk(entry, exclude)
|
|
162
|
+
elif entry.suffix == ".py":
|
|
163
|
+
yield entry
|
pyvibe/cli.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
python-vibe-guard — runtime anti-pattern scanner for async Python
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python -m pyvibe <path> # file or directory
|
|
7
|
+
python -m pyvibe <path> --json # machine-readable output
|
|
8
|
+
python -m pyvibe <path> --no-test-files # skip test files entirely
|
|
9
|
+
python -m pyvibe <path> --downgrade-in-tests # WARNING instead of CRITICAL in all test files
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from pyvibe import __version__
|
|
17
|
+
from pyvibe.analyzer import (
|
|
18
|
+
analyze_file,
|
|
19
|
+
analyze_directory,
|
|
20
|
+
DEFAULT_EXCLUDES,
|
|
21
|
+
ALL_RULE_IDS,
|
|
22
|
+
TEST_FILE_DOWNGRADE,
|
|
23
|
+
_is_test_file,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main():
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="pyvibe",
|
|
30
|
+
description="Detect runtime anti-patterns in async Python code",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
33
|
+
parser.add_argument("path", help="File or directory to scan")
|
|
34
|
+
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--exclude",
|
|
37
|
+
metavar="DIR",
|
|
38
|
+
action="append",
|
|
39
|
+
default=[],
|
|
40
|
+
help=(
|
|
41
|
+
"Directory name to exclude (can be repeated). "
|
|
42
|
+
"Added on top of the built-in defaults: "
|
|
43
|
+
+ ", ".join(sorted(DEFAULT_EXCLUDES))
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--no-test-files",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help=(
|
|
50
|
+
"Exclude test files from the scan entirely "
|
|
51
|
+
"(files matching test_*.py, *_test.py, or under tests/ / test/)"
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--downgrade-in-tests",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help=(
|
|
58
|
+
"Downgrade ALL violations in test files from CRITICAL to WARNING. "
|
|
59
|
+
"Default: only PYVIBE-001, PYVIBE-007, and PYVIBE-013 are downgraded."
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
args = parser.parse_args()
|
|
63
|
+
|
|
64
|
+
target = Path(args.path)
|
|
65
|
+
if not target.exists():
|
|
66
|
+
print(f"Error: {target} does not exist", file=sys.stderr)
|
|
67
|
+
sys.exit(2)
|
|
68
|
+
|
|
69
|
+
exclude = DEFAULT_EXCLUDES | frozenset(args.exclude)
|
|
70
|
+
|
|
71
|
+
# Resolve test-file handling flags (mutually exclusive in effect)
|
|
72
|
+
if args.no_test_files:
|
|
73
|
+
skip_test_files = True
|
|
74
|
+
downgrade_in_tests = frozenset()
|
|
75
|
+
elif args.downgrade_in_tests:
|
|
76
|
+
skip_test_files = False
|
|
77
|
+
downgrade_in_tests = ALL_RULE_IDS
|
|
78
|
+
else:
|
|
79
|
+
skip_test_files = False
|
|
80
|
+
downgrade_in_tests = TEST_FILE_DOWNGRADE
|
|
81
|
+
|
|
82
|
+
if target.is_file():
|
|
83
|
+
if target.suffix != ".py":
|
|
84
|
+
file_results = {}
|
|
85
|
+
elif skip_test_files and _is_test_file(str(target)):
|
|
86
|
+
file_results = {}
|
|
87
|
+
else:
|
|
88
|
+
file_results = {target: analyze_file(target, downgrade_in_tests=downgrade_in_tests)}
|
|
89
|
+
else:
|
|
90
|
+
file_results = analyze_directory(
|
|
91
|
+
target,
|
|
92
|
+
exclude=exclude,
|
|
93
|
+
skip_test_files=skip_test_files,
|
|
94
|
+
downgrade_in_tests=downgrade_in_tests,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
total_violations = sum(len(v) for v in file_results.values())
|
|
98
|
+
total_files = sum(1 for v in file_results.values() if v)
|
|
99
|
+
|
|
100
|
+
if args.json:
|
|
101
|
+
output = []
|
|
102
|
+
for path, violations in file_results.items():
|
|
103
|
+
for v in violations:
|
|
104
|
+
output.append({
|
|
105
|
+
"file": str(path),
|
|
106
|
+
"rule": v.rule_id,
|
|
107
|
+
"severity": v.severity,
|
|
108
|
+
"line": v.line,
|
|
109
|
+
"function": v.function_name,
|
|
110
|
+
"message": v.message,
|
|
111
|
+
"evidence": v.evidence,
|
|
112
|
+
})
|
|
113
|
+
print(json.dumps(output, indent=2))
|
|
114
|
+
else:
|
|
115
|
+
_print_human(file_results, total_violations, total_files)
|
|
116
|
+
|
|
117
|
+
sys.exit(1 if total_violations > 0 else 0)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _print_human(file_results: dict, total_violations: int, total_files: int):
|
|
121
|
+
print()
|
|
122
|
+
print(" python-vibe-guard")
|
|
123
|
+
print(" ─────────────────────────────────────────────")
|
|
124
|
+
print()
|
|
125
|
+
|
|
126
|
+
if not file_results:
|
|
127
|
+
print(" No violations found\n")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
for path, violations in file_results.items():
|
|
131
|
+
if not violations:
|
|
132
|
+
continue
|
|
133
|
+
print(f" {path}")
|
|
134
|
+
print()
|
|
135
|
+
for v in violations:
|
|
136
|
+
print(f" [{v.severity}] [{v.rule_id}] — line {v.line}")
|
|
137
|
+
print(f" Function : {v.function_name}()")
|
|
138
|
+
print(f" Problem : {v.message}")
|
|
139
|
+
print(f" Fix : {v.evidence}")
|
|
140
|
+
print()
|
|
141
|
+
|
|
142
|
+
print(" ─────────────────────────────────────────────")
|
|
143
|
+
print(f" {total_violations} violation(s) in {total_files} file(s)")
|
|
144
|
+
print()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
main()
|
pyvibe/rules/__init__.py
ADDED
|
File without changes
|