python-vibe-guard 0.7.1__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.7.1/PKG-INFO +286 -0
- python_vibe_guard-0.7.1/README.md +271 -0
- python_vibe_guard-0.7.1/pyproject.toml +27 -0
- python_vibe_guard-0.7.1/python_vibe_guard.egg-info/PKG-INFO +286 -0
- python_vibe_guard-0.7.1/python_vibe_guard.egg-info/SOURCES.txt +35 -0
- python_vibe_guard-0.7.1/python_vibe_guard.egg-info/dependency_links.txt +1 -0
- python_vibe_guard-0.7.1/python_vibe_guard.egg-info/entry_points.txt +2 -0
- python_vibe_guard-0.7.1/python_vibe_guard.egg-info/top_level.txt +1 -0
- python_vibe_guard-0.7.1/pyvibe/__init__.py +1 -0
- python_vibe_guard-0.7.1/pyvibe/__main__.py +3 -0
- python_vibe_guard-0.7.1/pyvibe/analyzer.py +163 -0
- python_vibe_guard-0.7.1/pyvibe/cli.py +148 -0
- python_vibe_guard-0.7.1/pyvibe/rules/__init__.py +0 -0
- python_vibe_guard-0.7.1/pyvibe/rules/async_requests.py +65 -0
- python_vibe_guard-0.7.1/pyvibe/rules/async_sleep.py +44 -0
- python_vibe_guard-0.7.1/pyvibe/rules/asyncio_run.py +48 -0
- python_vibe_guard-0.7.1/pyvibe/rules/base.py +65 -0
- python_vibe_guard-0.7.1/pyvibe/rules/celery_time_limit.py +103 -0
- python_vibe_guard-0.7.1/pyvibe/rules/contextvar_cleanup.py +129 -0
- python_vibe_guard-0.7.1/pyvibe/rules/create_task_orphan.py +44 -0
- python_vibe_guard-0.7.1/pyvibe/rules/ensure_future_orphan.py +44 -0
- python_vibe_guard-0.7.1/pyvibe/rules/gather_no_return_exceptions.py +57 -0
- python_vibe_guard-0.7.1/pyvibe/rules/httpx_client_sync.py +43 -0
- python_vibe_guard-0.7.1/pyvibe/rules/httpx_sync.py +52 -0
- python_vibe_guard-0.7.1/pyvibe/rules/loop_run_until_complete.py +37 -0
- python_vibe_guard-0.7.1/pyvibe/rules/open_async.py +39 -0
- python_vibe_guard-0.7.1/pyvibe/rules/os_blocking.py +49 -0
- python_vibe_guard-0.7.1/pyvibe/rules/queue_put_nowait.py +98 -0
- python_vibe_guard-0.7.1/pyvibe/rules/retry_no_backoff.py +255 -0
- python_vibe_guard-0.7.1/pyvibe/rules/silent_except.py +107 -0
- python_vibe_guard-0.7.1/pyvibe/rules/sqlite_async.py +82 -0
- python_vibe_guard-0.7.1/pyvibe/rules/subprocess_async.py +54 -0
- python_vibe_guard-0.7.1/pyvibe/rules/threading_lock.py +89 -0
- python_vibe_guard-0.7.1/pyvibe/rules/while_true_no_await.py +111 -0
- python_vibe_guard-0.7.1/setup.cfg +4 -0
- python_vibe_guard-0.7.1/tests/test_exclude.py +174 -0
- python_vibe_guard-0.7.1/tests/test_rules.py +2440 -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,271 @@
|
|
|
1
|
+
# python-vibe-guard
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Joaquinriosheredia/python-vibe-guard/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
## The incident
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
> Scenario based on a common pattern observed in async Python codebases.
|
|
10
|
+
> Reproduced in controlled testing with locust against a FastAPI service.
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
The code passed every unit test. It passed the integration tests. It shipped to production in a Friday deploy.
|
|
15
|
+
|
|
16
|
+
**python-vibe-guard catches this in CI before it reaches staging.**
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## What it detects
|
|
21
|
+
|
|
22
|
+
Twenty patterns that AI models generate repeatedly, that pass all static checks, and that silently destroy async performance under real load:
|
|
23
|
+
|
|
24
|
+
### A. Event Loop Blocking
|
|
25
|
+
|
|
26
|
+
Synchronous calls inside `async def` — each stalls the event loop for its entire duration, serializing all concurrent requests.
|
|
27
|
+
|
|
28
|
+
| Rule | Pattern | Gate | Runtime effect |
|
|
29
|
+
|------|---------|------|----------------|
|
|
30
|
+
| PYVIBE-001 | `time.sleep()` inside `async def` | `async def` | Freezes entire event loop for sleep duration |
|
|
31
|
+
| PYVIBE-002 | `requests.*` inside `async def` | `async def` | Blocks OS thread, serializes all concurrent I/O |
|
|
32
|
+
| PYVIBE-007 | `subprocess.run/call/check_output/Popen` inside `async def` | `async def` | Blocks OS thread for entire subprocess duration |
|
|
33
|
+
| PYVIBE-008 | `sqlite3.connect()` inside `async def` | `async def` | Synchronous file I/O blocks the event loop |
|
|
34
|
+
| PYVIBE-009 | `open()` builtin inside `async def` | `async def` | Synchronous file I/O blocks the event loop |
|
|
35
|
+
| PYVIBE-010 | `httpx.get/post/put/…` inside `async def` | `async def` | httpx sync API blocks OS thread for full HTTP round-trip |
|
|
36
|
+
| PYVIBE-011 | `os.system/popen/waitpid` inside `async def` | `async def` | Blocking OS calls with no direct async equivalent |
|
|
37
|
+
| PYVIBE-016 | `httpx.Client()` instantiated inside `async def` | `async def` | Sync client blocks OS thread per request; `httpx.AsyncClient()` is excluded |
|
|
38
|
+
|
|
39
|
+
### B. Async Lifecycle Misuse
|
|
40
|
+
|
|
41
|
+
Incorrect use of asyncio primitives that raise `RuntimeError` at runtime or silently discard tasks mid-execution.
|
|
42
|
+
|
|
43
|
+
| Rule | Pattern | Gate | Runtime effect |
|
|
44
|
+
|------|---------|------|----------------|
|
|
45
|
+
| PYVIBE-003 | `asyncio.run()` inside `async def` | `async def` | `RuntimeError: This event loop is already running` |
|
|
46
|
+
| PYVIBE-012 | `asyncio.create_task()` with discarded return value | `async def` | Task GC'd mid-execution; exceptions silently swallowed |
|
|
47
|
+
| PYVIBE-013 | `asyncio.gather()` without `return_exceptions=True` | `async def` | First exception leaks remaining tasks; no per-task error handling |
|
|
48
|
+
| 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 |
|
|
49
|
+
| PYVIBE-015 | `loop.run_until_complete()` inside `async def` | `async def` | `RuntimeError: This event loop is already running` |
|
|
50
|
+
|
|
51
|
+
### C. Concurrency & State Hazards
|
|
52
|
+
|
|
53
|
+
Patterns that introduce data races, swallowed errors, or runaway loops under concurrent async load.
|
|
54
|
+
|
|
55
|
+
| Rule | Pattern | Gate | Runtime effect |
|
|
56
|
+
|------|---------|------|----------------|
|
|
57
|
+
| PYVIBE-004 | `threading.Lock/RLock/…` inside `async def` | `async def` | Blocks event loop under contention |
|
|
58
|
+
| PYVIBE-005 | `@app.task` / `@shared_task` without `soft_time_limit` or `time_limit` | decorator | Worker hangs forever if external call never returns |
|
|
59
|
+
| PYVIBE-006 | `ContextVar.set()` inside `async def` without `try/finally reset()` | `async def` | Context leaks into sibling async tasks |
|
|
60
|
+
| PYVIBE-017 | `except Exception: pass` / bare `except: pass` (empty body) | any | Swallows all errors silently; bare except also catches `KeyboardInterrupt`/`SystemExit` |
|
|
61
|
+
| PYVIBE-018 | `while True:` inside `async def` with no `await` in body | `async def` | Event loop blocked indefinitely; CPU hits 100% |
|
|
62
|
+
| 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 |
|
|
63
|
+
| PYVIBE-020 | `put_nowait()` without `asyncio.QueueFull` handler | any | `QueueFull` propagates unhandled; item is silently lost on bounded queues |
|
|
64
|
+
|
|
65
|
+
**Severity notes:**
|
|
66
|
+
- 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`.
|
|
67
|
+
- 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.
|
|
68
|
+
- PYVIBE-017: bare `except` → `CRITICAL` (catches `KeyboardInterrupt`/`SystemExit`); `except Exception` with empty body → `WARNING`. Specific exceptions (`except ValueError: pass`) are not flagged.
|
|
69
|
+
- 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.
|
|
70
|
+
- 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.
|
|
71
|
+
- 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`.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Validation
|
|
76
|
+
|
|
77
|
+
### 100-repo sweep (automated, v0.7.0)
|
|
78
|
+
|
|
79
|
+
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).
|
|
80
|
+
|
|
81
|
+
| Metric | Value |
|
|
82
|
+
|--------|-------|
|
|
83
|
+
| Repos scanned | 100 |
|
|
84
|
+
| .py files | 64,335 |
|
|
85
|
+
| Total violations | 3,639 |
|
|
86
|
+
|
|
87
|
+
**Rule prevalence (top 10):**
|
|
88
|
+
|
|
89
|
+
| Rule | Hits | Repos affected | % of repos |
|
|
90
|
+
|------|------|----------------|-----------|
|
|
91
|
+
| PYVIBE-017 `except Exception: pass` | 999 | 57 | **57%** |
|
|
92
|
+
| PYVIBE-013 `gather()` no return_exceptions | 766 | 47 | **47%** |
|
|
93
|
+
| PYVIBE-019 retry loop no backoff | 408 | 43 | **43%** |
|
|
94
|
+
| PYVIBE-009 `open()` in async def | 178 | 33 | **33%** |
|
|
95
|
+
| PYVIBE-005 `@task` no time_limit | 882 | 15 | 15% |
|
|
96
|
+
| PYVIBE-001 `time.sleep` in async | 55 | 16 | 16% |
|
|
97
|
+
| PYVIBE-012 orphaned `create_task()` | 58 | 13 | 13% |
|
|
98
|
+
| PYVIBE-008 `sqlite3` in async def | 121 | 10 | 10% |
|
|
99
|
+
| PYVIBE-018 `while True` no await | 36 | 10 | 10% |
|
|
100
|
+
| PYVIBE-006 ContextVar no reset | 19 | 10 | 10% |
|
|
101
|
+
|
|
102
|
+
Full data: [`validation/aggregate.json`](validation/aggregate.json) · [`validation/massive-results.md`](validation/massive-results.md)
|
|
103
|
+
|
|
104
|
+
### Reference scan — 4 repos (reproducible)
|
|
105
|
+
|
|
106
|
+
| Repo | .py files | Violations |
|
|
107
|
+
|------|-----------|-----------|
|
|
108
|
+
| [fastapi/fastapi](https://github.com/tiangolo/fastapi) | 1 121 | 5 |
|
|
109
|
+
| [celery/celery](https://github.com/celery/celery) | 416 | 363 |
|
|
110
|
+
| [aio-libs/aiohttp](https://github.com/aio-libs/aiohttp) | 164 | 29 |
|
|
111
|
+
| [encode/httpx](https://github.com/encode/httpx) | 60 | 0 |
|
|
112
|
+
| **Total** | **1 761** | **397** |
|
|
113
|
+
|
|
114
|
+
Raw data: [`validation/breakdown.json`](validation/breakdown.json)
|
|
115
|
+
|
|
116
|
+
**Notable findings:**
|
|
117
|
+
|
|
118
|
+
- **home-assistant/core** — 418 violations across 17,702 files; 331 PYVIBE-013, 21 PYVIBE-018.
|
|
119
|
+
- **Celery** — 352 tasks without `time_limit`; workers can hang indefinitely on broker timeouts.
|
|
120
|
+
- **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).
|
|
121
|
+
- **aiohttp examples** — `open()` in async WebSocket handlers; synchronous I/O in production-facing code.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Installation
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pip install python-vibe-guard
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Or run without installing:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pip install -e .
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Usage
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
# Scan a file
|
|
143
|
+
python -m pyvibe path/to/file.py
|
|
144
|
+
|
|
145
|
+
# Scan a directory recursively
|
|
146
|
+
python -m pyvibe src/
|
|
147
|
+
|
|
148
|
+
# JSON output for CI/CD pipelines
|
|
149
|
+
python -m pyvibe src/ --json
|
|
150
|
+
|
|
151
|
+
# Exclude directories (adds to built-in defaults: venv, .venv, __pycache__, …)
|
|
152
|
+
python -m pyvibe src/ --exclude tests
|
|
153
|
+
|
|
154
|
+
# Exit code: 0 = clean, 1 = violations found, 2 = path error
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Example output
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
python-vibe-guard
|
|
161
|
+
─────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
demo/bad_async.py
|
|
164
|
+
|
|
165
|
+
[CRITICAL] [PYVIBE-001] — line 14
|
|
166
|
+
Function : process_order()
|
|
167
|
+
Problem : time.sleep() blocks the entire event loop
|
|
168
|
+
Fix : Use `await asyncio.sleep(n)` instead
|
|
169
|
+
|
|
170
|
+
[CRITICAL] [PYVIBE-002] — line 20
|
|
171
|
+
Function : fetch_user()
|
|
172
|
+
Problem : requests.get() is synchronous — blocks the event loop
|
|
173
|
+
Fix : Use `async with httpx.AsyncClient() as c: await c.get(url)`
|
|
174
|
+
|
|
175
|
+
[CRITICAL] [PYVIBE-003] — line 26
|
|
176
|
+
Function : orchestrate()
|
|
177
|
+
Problem : asyncio.run() inside async def raises RuntimeError at runtime
|
|
178
|
+
Fix : Use `await coroutine()` directly — asyncio.run() is for sync entrypoints only
|
|
179
|
+
|
|
180
|
+
[CRITICAL] [PYVIBE-004] — line 32
|
|
181
|
+
Function : update_counter()
|
|
182
|
+
Problem : threading.Lock() blocks the event loop under contention
|
|
183
|
+
Fix : Use `asyncio.Lock()` with `async with lock:` instead
|
|
184
|
+
|
|
185
|
+
─────────────────────────────────────────────
|
|
186
|
+
4 violation(s) in 1 file(s)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## CI/CD integration
|
|
192
|
+
|
|
193
|
+
Add to your GitHub Actions workflow:
|
|
194
|
+
|
|
195
|
+
```yaml
|
|
196
|
+
- name: python-vibe-guard scan
|
|
197
|
+
run: |
|
|
198
|
+
pip install python-vibe-guard
|
|
199
|
+
python -m pyvibe src/ --json
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The scanner exits with code `1` when violations are found, failing the CI job.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Pre-commit integration
|
|
207
|
+
|
|
208
|
+
Add to your `.pre-commit-config.yaml`:
|
|
209
|
+
|
|
210
|
+
```yaml
|
|
211
|
+
repos:
|
|
212
|
+
- repo: https://github.com/Joaquinriosheredia/python-vibe-guard
|
|
213
|
+
rev: v0.7.0
|
|
214
|
+
hooks:
|
|
215
|
+
- id: python-vibe-guard
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Then install the hook:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
pre-commit install
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The hook runs on every `git commit`, scans all Python files in the project, and blocks the commit if any violations are found.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Design
|
|
229
|
+
|
|
230
|
+
- **Pure AST, zero runtime dependencies** — uses only Python's built-in `ast` module
|
|
231
|
+
- **Each rule is an independent `ast.NodeVisitor`** — easy to add, disable, or extend
|
|
232
|
+
- **Gate on `async def`** — most rules check `_current_async_func` before firing; PYVIBE-017 (silent except) fires in any function context
|
|
233
|
+
- **No import resolution** — works on any Python file without installing its dependencies
|
|
234
|
+
- **Test-aware severity** — PYVIBE-013 is automatically downgraded to WARNING in test files
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Run the demo
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
python -m pyvibe demo/bad_async.py
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
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.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Run the tests
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
python -m pytest tests/ -v
|
|
252
|
+
# or
|
|
253
|
+
python tests/test_rules.py
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
123 tests: true positives + false-positive guards for every rule.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Ecosystem
|
|
261
|
+
|
|
262
|
+
This project is part of the **vibe-guard** family of runtime anti-pattern scanners:
|
|
263
|
+
|
|
264
|
+
- [java-vibe-guard](https://github.com/jouninno/java-vibe-guard) — Spring Boot / async Java (blocking Kafka, @Transactional + Virtual Threads)
|
|
265
|
+
- **python-vibe-guard** — FastAPI / asyncio (this project)
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## License
|
|
270
|
+
|
|
271
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-vibe-guard"
|
|
7
|
+
version = "0.7.1"
|
|
8
|
+
description = "Runtime anti-pattern scanner for async Python — detects what AI-generated code gets wrong"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
keywords = ["async", "linter", "fastapi", "asyncio", "static-analysis"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
pyvibe = "pyvibe.cli:main"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["."]
|
|
27
|
+
include = ["pyvibe*"]
|