codehound 1.4.1__tar.gz → 1.5.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.
- {codehound-1.4.1 → codehound-1.5.0}/PKG-INFO +59 -21
- {codehound-1.4.1 → codehound-1.5.0}/README.md +58 -20
- codehound-1.5.0/src/codehound/__init__.py +26 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/__init__.py +4 -0
- codehound-1.5.0/src/codehound/checks/asyncio_coroutine_decorator.py +70 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/lru_cache_on_method.py +71 -0
- codehound-1.5.0/src/codehound/checks/removed_stdlib_module.py +142 -0
- {codehound-1.4.1 → codehound-1.5.0}/tests/test_checks.py +194 -0
- codehound-1.4.1/src/codehound/__init__.py +0 -25
- {codehound-1.4.1 → codehound-1.5.0}/.gitignore +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/LICENSE +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/pyproject.toml +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/async_property.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/bare_except.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/blocking_async.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/collections_abc_import.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/datetime_utcnow.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/discarded_future.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/floating_process.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/floating_task.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/floating_thread.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/get_event_loop.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/loop_closure_capture.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/mutable_defaults.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/removed_getargspec.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/resource_leak.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/unclosed_socket.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/unprotected_lock.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/cli.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/core.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/sarif.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/src/codehound/terminal.py +0 -0
- {codehound-1.4.1 → codehound-1.5.0}/tests/test_output_formats.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: codehound
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: An AST-based static analyzer that hunts real correctness and async-safety bugs in Python code.
|
|
5
5
|
Project-URL: Homepage, https://github.com/kratos0718/codehound
|
|
6
6
|
Project-URL: Issues, https://github.com/kratos0718/codehound/issues
|
|
@@ -24,7 +24,7 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
|
|
25
25
|
<h1 align="center">codehound</h1>
|
|
26
26
|
|
|
27
|
-
**An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty checks,
|
|
27
|
+
**An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty-two checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
|
|
28
28
|
|
|
29
29
|
[](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
|
|
30
30
|
[](https://pypi.org/project/codehound/)
|
|
@@ -71,7 +71,7 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
|
|
|
71
71
|
pip install codehound
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
Zero dependencies — it's ~2,
|
|
74
|
+
Zero dependencies — it's ~2,600 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
|
|
75
75
|
|
|
76
76
|
<details>
|
|
77
77
|
<summary>From a clone instead (for development)</summary>
|
|
@@ -132,7 +132,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
|
|
|
132
132
|
```yaml
|
|
133
133
|
repos:
|
|
134
134
|
- repo: https://github.com/kratos0718/codehound
|
|
135
|
-
rev: v1.
|
|
135
|
+
rev: v1.5.0
|
|
136
136
|
hooks:
|
|
137
137
|
- id: codehound
|
|
138
138
|
```
|
|
@@ -153,7 +153,7 @@ repos:
|
|
|
153
153
|
| **CH008** | `asyncio-run-in-running-loop` | `asyncio.run(...)` called from inside an `async def` — always raises `RuntimeError`, immediately, every time. | hardening rule — zero corpus hits (see below) |
|
|
154
154
|
| **CH009** | `floating-thread` | A non-daemon `threading.Thread` that's `.start()`ed but never `.join()`ed — the thread analog of CH006. | hardening rule — see below |
|
|
155
155
|
| **CH010** | `loop-closure-capture` | A `lambda` inside a `for` loop (or comprehension) that's *stored* (appended, assigned, returned) and captures the loop variable by reference — every stored instance ends up sharing the loop's **final** value. | **accelerate** (HuggingFace) — `MegatronEngine.get_module_config`'s `param_sync_func` list, PR #4273 |
|
|
156
|
-
| **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. | **optuna** — `_FanovaTree`'s node-lookup methods leaked every tree built for a `get_param_importances()` call; **llama_index** — `VectaraIndex._get_corpus_key` leaked the index *and* broke its own `__del__`-based HTTP session cleanup |
|
|
156
|
+
| **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. | **optuna** — `_FanovaTree`'s node-lookup methods leaked every tree built for a `get_param_importances()` call; **llama_index** — `VectaraIndex._get_corpus_key` leaked the index *and* broke its own `__del__`-based HTTP session cleanup; **litellm** — `Router._cached_get_model_group_info` leaked every `Router` even after its own documented `discard()` cleanup |
|
|
157
157
|
| **CH012** | `floating-process` | A non-daemon `multiprocessing.Process` that's `.start()`ed but never `.join()`ed — the process analog of CH009. | hardening rule |
|
|
158
158
|
| **CH013** | `discarded-future` | `ThreadPoolExecutor`/`ProcessPoolExecutor.submit(...)` called as a bare statement — the returned `Future` (and any exception raised inside the submitted work) is silently discarded. | hardening rule — real hits in litellm, accelerate, langchain |
|
|
159
159
|
| **CH014** | `unprotected-lock-acquire` | `lock.acquire()` outside a `with`, whose matching `.release()` isn't inside a `finally:` — an exception between acquire and release deadlocks every future caller of that lock. | hardening rule — real hits in vllm, accelerate, torchtune |
|
|
@@ -163,10 +163,12 @@ repos:
|
|
|
163
163
|
| **CH018** | `removed-asyncio-task-methods` | `asyncio.Task.current_task()` / `.all_tasks()` — both removed in Python 3.9; use `asyncio.current_task()` / `asyncio.all_tasks()`. | hardening rule |
|
|
164
164
|
| **CH019** | `removed-getargspec` | `inspect.getargspec(...)` — removed in Python 3.11 after a decade-plus deprecation; use `inspect.signature(...)`. | hardening rule |
|
|
165
165
|
| **CH020** | `bare-except` | A bare `except:` (or unused `except BaseException:`) — also catches `KeyboardInterrupt`/`SystemExit`, so Ctrl-C stops working and `sys.exit()` gets silently absorbed. | hardening rule — real hits in agno, llama_index, marimo, litellm |
|
|
166
|
+
| **CH021** | `removed-stdlib-module` | `import distutils` (removed 3.12) or any of the 19 PEP 594 "dead battery" modules (`cgi`, `imghdr`, `telnetlib`, `nntplib`, …, removed 3.13) — `ImportError` the moment the module loads. | hardening rule — real hit in agno (already guarded, see below) |
|
|
167
|
+
| **CH022** | `removed-asyncio-coroutine-decorator` | `@asyncio.coroutine` — removed in Python 3.11 after a generator-based-coroutine bridge that predates `async def`; `AttributeError` the moment the decorator line runs. | hardening rule |
|
|
166
168
|
|
|
167
169
|
`codehound list` prints this from the source of truth.
|
|
168
170
|
|
|
169
|
-
CH007-
|
|
171
|
+
CH007-CH022 don't have found-and-merged bugs behind all of them the way
|
|
170
172
|
CH001-CH006 do - most are hardening rules for well-known Python
|
|
171
173
|
correctness gotchas rather than something this project personally
|
|
172
174
|
tracked down first. CH010 and CH011 are the exceptions: both found
|
|
@@ -210,20 +212,38 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
|
|
|
210
212
|
match same-file names by design, and most real cases of either are
|
|
211
213
|
plausibly cross-module.
|
|
212
214
|
|
|
213
|
-
**The optuna and
|
|
214
|
-
None)`
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
`
|
|
224
|
-
|
|
215
|
+
**The optuna, llama_index, and litellm finds (CH011):** all three are
|
|
216
|
+
`@lru_cache(maxsize=None)` (or a fixed `maxsize`) decorating an instance
|
|
217
|
+
method - a strong reference to `self` retained for the life of the
|
|
218
|
+
process. In optuna, `_FanovaTree`'s node-lookup methods leak every tree
|
|
219
|
+
built for a `get_param_importances()` call (one per random-forest
|
|
220
|
+
estimator). In llama_index, `VectaraIndex._get_corpus_key` leaks the
|
|
221
|
+
index itself - and since `VectaraIndex.__del__` exists specifically to
|
|
222
|
+
close the index's `requests.Session` on garbage collection, the leak
|
|
223
|
+
silently disables that cleanup too, so an HTTP session leaks along with
|
|
224
|
+
every index. In litellm, `Router._cached_get_model_group_info` leaks
|
|
225
|
+
every `Router` that's ever served a request through it - proved this
|
|
226
|
+
survives even a correctly-called `router.discard()` (Router's own
|
|
227
|
+
documented cleanup method), so it isn't a "you forgot to clean up" bug.
|
|
228
|
+
All three fixed the same way: move the cache from a class-level decorator
|
|
229
|
+
to a per-instance one built in `__init__`, so it's freed with the
|
|
230
|
+
instance instead of outliving it - the exact pattern litellm's own
|
|
231
|
+
`cached_deployment_model_info` sibling method already used, just not yet
|
|
232
|
+
applied to this one. Each has a regression test verified to fail pre-fix
|
|
233
|
+
and pass post-fix.
|
|
225
234
|
PRs: [optuna/optuna#6859](https://github.com/optuna/optuna/pull/6859),
|
|
226
|
-
[run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089)
|
|
235
|
+
[run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089),
|
|
236
|
+
[BerriAI/litellm#41582](https://github.com/BerriAI/litellm/pull/41582).
|
|
237
|
+
|
|
238
|
+
**A third CH011 shape needed a guard instead of a PR:** dspy's `Image` (a
|
|
239
|
+
pydantic model) caches `format()` the same way, but `Image` is frozen
|
|
240
|
+
(`model_config = ConfigDict(frozen=True)`), which makes it hashable and
|
|
241
|
+
equal *by field value*, not identity. Checked directly rather than
|
|
242
|
+
assumed: two separately constructed instances with equal fields hash
|
|
243
|
+
equal, and the second one's call is served from the first's cache entry
|
|
244
|
+
without ever being inserted itself — bounded, value-based memoization,
|
|
245
|
+
not a leak. CH011 now recognizes a frozen `@dataclass` or a frozen
|
|
246
|
+
pydantic model and skips it.
|
|
227
247
|
|
|
228
248
|
**CH016 found and fixed its own false positive the day it shipped.** The
|
|
229
249
|
first real-corpus scan of `unclosed-socket` turned up three hits in
|
|
@@ -237,6 +257,20 @@ as escaped when it's returned as part of a tuple/list or passed as an
|
|
|
237
257
|
argument to any call. A full corpus rescan afterward found zero remaining
|
|
238
258
|
CH016 hits.
|
|
239
259
|
|
|
260
|
+
**CH021 did the same thing twice, minutes apart.** The first real-corpus
|
|
261
|
+
scan found a false positive in vllm - `from .chunk import
|
|
262
|
+
chunk_gated_delta_rule`, a relative import of vllm's own local `chunk.py`
|
|
263
|
+
sibling module, not the removed stdlib `chunk`. `ast.ImportFrom.module`
|
|
264
|
+
is `"chunk"` either way; only `node.level` (the leading-dot count) tells
|
|
265
|
+
a relative import apart from an absolute one, and the check wasn't
|
|
266
|
+
checking it. Fixed, rescanned, and found a *second* false positive in
|
|
267
|
+
agno: `try: import imghdr except ImportError: import filetype` - a
|
|
268
|
+
real, deliberate fallback that already anticipates this exact removal,
|
|
269
|
+
not a bug waiting to happen. Added a second guard: skip an import inside
|
|
270
|
+
a `try:` body whose `except` catches `ImportError` (or anything
|
|
271
|
+
broader). A full corpus rescan after both fixes found zero remaining
|
|
272
|
+
CH021 hits.
|
|
273
|
+
|
|
240
274
|
**Two checks we built and did not ship.** `exception-chaining` (`except X
|
|
241
275
|
as e: raise Y(...)` with no `from e`, discarding the real traceback -
|
|
242
276
|
overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
|
|
@@ -292,12 +326,14 @@ codehound/
|
|
|
292
326
|
├── collections_abc_import.py (CH017)
|
|
293
327
|
├── removed_asyncio_task_methods.py (CH018)
|
|
294
328
|
├── removed_getargspec.py (CH019)
|
|
295
|
-
|
|
329
|
+
├── bare_except.py (CH020)
|
|
330
|
+
├── removed_stdlib_module.py (CH021)
|
|
331
|
+
└── asyncio_coroutine_decorator.py (CH022)
|
|
296
332
|
```
|
|
297
333
|
|
|
298
334
|
Each check receives a parsed `ast` tree plus the precomputed parent map and returns `Finding`s. Adding a rule is one file + one registry line + a test. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for a full walkthrough of the engine, the parent map, and the design decisions.
|
|
299
335
|
|
|
300
|
-
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
336
|
+
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
301
337
|
|
|
302
338
|
---
|
|
303
339
|
|
|
@@ -327,6 +363,8 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
327
363
|
- [x] 20 checks — memory leaks (`lru_cache` on methods), floating processes,
|
|
328
364
|
discarded futures, unprotected locks, async properties, unclosed
|
|
329
365
|
sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
|
|
366
|
+
- [x] 22 checks — removed stdlib modules (`distutils`, PEP 594 "dead
|
|
367
|
+
batteries"), removed `@asyncio.coroutine` decorator — CH021-CH022
|
|
330
368
|
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
331
369
|
- [ ] Sync HTTP clients constructed inside async request handlers
|
|
332
370
|
- [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<h1 align="center">codehound</h1>
|
|
6
6
|
|
|
7
|
-
**An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty checks,
|
|
7
|
+
**An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty-two checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
|
|
8
8
|
|
|
9
9
|
[](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
|
|
10
10
|
[](https://pypi.org/project/codehound/)
|
|
@@ -51,7 +51,7 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
|
|
|
51
51
|
pip install codehound
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
Zero dependencies — it's ~2,
|
|
54
|
+
Zero dependencies — it's ~2,600 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
|
|
55
55
|
|
|
56
56
|
<details>
|
|
57
57
|
<summary>From a clone instead (for development)</summary>
|
|
@@ -112,7 +112,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
|
|
|
112
112
|
```yaml
|
|
113
113
|
repos:
|
|
114
114
|
- repo: https://github.com/kratos0718/codehound
|
|
115
|
-
rev: v1.
|
|
115
|
+
rev: v1.5.0
|
|
116
116
|
hooks:
|
|
117
117
|
- id: codehound
|
|
118
118
|
```
|
|
@@ -133,7 +133,7 @@ repos:
|
|
|
133
133
|
| **CH008** | `asyncio-run-in-running-loop` | `asyncio.run(...)` called from inside an `async def` — always raises `RuntimeError`, immediately, every time. | hardening rule — zero corpus hits (see below) |
|
|
134
134
|
| **CH009** | `floating-thread` | A non-daemon `threading.Thread` that's `.start()`ed but never `.join()`ed — the thread analog of CH006. | hardening rule — see below |
|
|
135
135
|
| **CH010** | `loop-closure-capture` | A `lambda` inside a `for` loop (or comprehension) that's *stored* (appended, assigned, returned) and captures the loop variable by reference — every stored instance ends up sharing the loop's **final** value. | **accelerate** (HuggingFace) — `MegatronEngine.get_module_config`'s `param_sync_func` list, PR #4273 |
|
|
136
|
-
| **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. | **optuna** — `_FanovaTree`'s node-lookup methods leaked every tree built for a `get_param_importances()` call; **llama_index** — `VectaraIndex._get_corpus_key` leaked the index *and* broke its own `__del__`-based HTTP session cleanup |
|
|
136
|
+
| **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. | **optuna** — `_FanovaTree`'s node-lookup methods leaked every tree built for a `get_param_importances()` call; **llama_index** — `VectaraIndex._get_corpus_key` leaked the index *and* broke its own `__del__`-based HTTP session cleanup; **litellm** — `Router._cached_get_model_group_info` leaked every `Router` even after its own documented `discard()` cleanup |
|
|
137
137
|
| **CH012** | `floating-process` | A non-daemon `multiprocessing.Process` that's `.start()`ed but never `.join()`ed — the process analog of CH009. | hardening rule |
|
|
138
138
|
| **CH013** | `discarded-future` | `ThreadPoolExecutor`/`ProcessPoolExecutor.submit(...)` called as a bare statement — the returned `Future` (and any exception raised inside the submitted work) is silently discarded. | hardening rule — real hits in litellm, accelerate, langchain |
|
|
139
139
|
| **CH014** | `unprotected-lock-acquire` | `lock.acquire()` outside a `with`, whose matching `.release()` isn't inside a `finally:` — an exception between acquire and release deadlocks every future caller of that lock. | hardening rule — real hits in vllm, accelerate, torchtune |
|
|
@@ -143,10 +143,12 @@ repos:
|
|
|
143
143
|
| **CH018** | `removed-asyncio-task-methods` | `asyncio.Task.current_task()` / `.all_tasks()` — both removed in Python 3.9; use `asyncio.current_task()` / `asyncio.all_tasks()`. | hardening rule |
|
|
144
144
|
| **CH019** | `removed-getargspec` | `inspect.getargspec(...)` — removed in Python 3.11 after a decade-plus deprecation; use `inspect.signature(...)`. | hardening rule |
|
|
145
145
|
| **CH020** | `bare-except` | A bare `except:` (or unused `except BaseException:`) — also catches `KeyboardInterrupt`/`SystemExit`, so Ctrl-C stops working and `sys.exit()` gets silently absorbed. | hardening rule — real hits in agno, llama_index, marimo, litellm |
|
|
146
|
+
| **CH021** | `removed-stdlib-module` | `import distutils` (removed 3.12) or any of the 19 PEP 594 "dead battery" modules (`cgi`, `imghdr`, `telnetlib`, `nntplib`, …, removed 3.13) — `ImportError` the moment the module loads. | hardening rule — real hit in agno (already guarded, see below) |
|
|
147
|
+
| **CH022** | `removed-asyncio-coroutine-decorator` | `@asyncio.coroutine` — removed in Python 3.11 after a generator-based-coroutine bridge that predates `async def`; `AttributeError` the moment the decorator line runs. | hardening rule |
|
|
146
148
|
|
|
147
149
|
`codehound list` prints this from the source of truth.
|
|
148
150
|
|
|
149
|
-
CH007-
|
|
151
|
+
CH007-CH022 don't have found-and-merged bugs behind all of them the way
|
|
150
152
|
CH001-CH006 do - most are hardening rules for well-known Python
|
|
151
153
|
correctness gotchas rather than something this project personally
|
|
152
154
|
tracked down first. CH010 and CH011 are the exceptions: both found
|
|
@@ -190,20 +192,38 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
|
|
|
190
192
|
match same-file names by design, and most real cases of either are
|
|
191
193
|
plausibly cross-module.
|
|
192
194
|
|
|
193
|
-
**The optuna and
|
|
194
|
-
None)`
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
`
|
|
204
|
-
|
|
195
|
+
**The optuna, llama_index, and litellm finds (CH011):** all three are
|
|
196
|
+
`@lru_cache(maxsize=None)` (or a fixed `maxsize`) decorating an instance
|
|
197
|
+
method - a strong reference to `self` retained for the life of the
|
|
198
|
+
process. In optuna, `_FanovaTree`'s node-lookup methods leak every tree
|
|
199
|
+
built for a `get_param_importances()` call (one per random-forest
|
|
200
|
+
estimator). In llama_index, `VectaraIndex._get_corpus_key` leaks the
|
|
201
|
+
index itself - and since `VectaraIndex.__del__` exists specifically to
|
|
202
|
+
close the index's `requests.Session` on garbage collection, the leak
|
|
203
|
+
silently disables that cleanup too, so an HTTP session leaks along with
|
|
204
|
+
every index. In litellm, `Router._cached_get_model_group_info` leaks
|
|
205
|
+
every `Router` that's ever served a request through it - proved this
|
|
206
|
+
survives even a correctly-called `router.discard()` (Router's own
|
|
207
|
+
documented cleanup method), so it isn't a "you forgot to clean up" bug.
|
|
208
|
+
All three fixed the same way: move the cache from a class-level decorator
|
|
209
|
+
to a per-instance one built in `__init__`, so it's freed with the
|
|
210
|
+
instance instead of outliving it - the exact pattern litellm's own
|
|
211
|
+
`cached_deployment_model_info` sibling method already used, just not yet
|
|
212
|
+
applied to this one. Each has a regression test verified to fail pre-fix
|
|
213
|
+
and pass post-fix.
|
|
205
214
|
PRs: [optuna/optuna#6859](https://github.com/optuna/optuna/pull/6859),
|
|
206
|
-
[run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089)
|
|
215
|
+
[run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089),
|
|
216
|
+
[BerriAI/litellm#41582](https://github.com/BerriAI/litellm/pull/41582).
|
|
217
|
+
|
|
218
|
+
**A third CH011 shape needed a guard instead of a PR:** dspy's `Image` (a
|
|
219
|
+
pydantic model) caches `format()` the same way, but `Image` is frozen
|
|
220
|
+
(`model_config = ConfigDict(frozen=True)`), which makes it hashable and
|
|
221
|
+
equal *by field value*, not identity. Checked directly rather than
|
|
222
|
+
assumed: two separately constructed instances with equal fields hash
|
|
223
|
+
equal, and the second one's call is served from the first's cache entry
|
|
224
|
+
without ever being inserted itself — bounded, value-based memoization,
|
|
225
|
+
not a leak. CH011 now recognizes a frozen `@dataclass` or a frozen
|
|
226
|
+
pydantic model and skips it.
|
|
207
227
|
|
|
208
228
|
**CH016 found and fixed its own false positive the day it shipped.** The
|
|
209
229
|
first real-corpus scan of `unclosed-socket` turned up three hits in
|
|
@@ -217,6 +237,20 @@ as escaped when it's returned as part of a tuple/list or passed as an
|
|
|
217
237
|
argument to any call. A full corpus rescan afterward found zero remaining
|
|
218
238
|
CH016 hits.
|
|
219
239
|
|
|
240
|
+
**CH021 did the same thing twice, minutes apart.** The first real-corpus
|
|
241
|
+
scan found a false positive in vllm - `from .chunk import
|
|
242
|
+
chunk_gated_delta_rule`, a relative import of vllm's own local `chunk.py`
|
|
243
|
+
sibling module, not the removed stdlib `chunk`. `ast.ImportFrom.module`
|
|
244
|
+
is `"chunk"` either way; only `node.level` (the leading-dot count) tells
|
|
245
|
+
a relative import apart from an absolute one, and the check wasn't
|
|
246
|
+
checking it. Fixed, rescanned, and found a *second* false positive in
|
|
247
|
+
agno: `try: import imghdr except ImportError: import filetype` - a
|
|
248
|
+
real, deliberate fallback that already anticipates this exact removal,
|
|
249
|
+
not a bug waiting to happen. Added a second guard: skip an import inside
|
|
250
|
+
a `try:` body whose `except` catches `ImportError` (or anything
|
|
251
|
+
broader). A full corpus rescan after both fixes found zero remaining
|
|
252
|
+
CH021 hits.
|
|
253
|
+
|
|
220
254
|
**Two checks we built and did not ship.** `exception-chaining` (`except X
|
|
221
255
|
as e: raise Y(...)` with no `from e`, discarding the real traceback -
|
|
222
256
|
overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
|
|
@@ -272,12 +306,14 @@ codehound/
|
|
|
272
306
|
├── collections_abc_import.py (CH017)
|
|
273
307
|
├── removed_asyncio_task_methods.py (CH018)
|
|
274
308
|
├── removed_getargspec.py (CH019)
|
|
275
|
-
|
|
309
|
+
├── bare_except.py (CH020)
|
|
310
|
+
├── removed_stdlib_module.py (CH021)
|
|
311
|
+
└── asyncio_coroutine_decorator.py (CH022)
|
|
276
312
|
```
|
|
277
313
|
|
|
278
314
|
Each check receives a parsed `ast` tree plus the precomputed parent map and returns `Finding`s. Adding a rule is one file + one registry line + a test. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for a full walkthrough of the engine, the parent map, and the design decisions.
|
|
279
315
|
|
|
280
|
-
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
316
|
+
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
281
317
|
|
|
282
318
|
---
|
|
283
319
|
|
|
@@ -307,6 +343,8 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
307
343
|
- [x] 20 checks — memory leaks (`lru_cache` on methods), floating processes,
|
|
308
344
|
discarded futures, unprotected locks, async properties, unclosed
|
|
309
345
|
sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
|
|
346
|
+
- [x] 22 checks — removed stdlib modules (`distutils`, PEP 594 "dead
|
|
347
|
+
batteries"), removed `@asyncio.coroutine` decorator — CH021-CH022
|
|
310
348
|
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
311
349
|
- [ ] Sync HTTP clients constructed inside async request handlers
|
|
312
350
|
- [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""codehound - an AST-based static analyzer that hunts real bugs in Python code.
|
|
2
|
+
|
|
3
|
+
Twenty-two checks. Eight are each backed by a bug that was actually found
|
|
4
|
+
and fixed (or opened as a PR) in a popular open-source AI framework
|
|
5
|
+
(agno, crewAI, mem0, llama_index, accelerate, optuna, litellm). The rest
|
|
6
|
+
(CH007-CH009, CH012-CH022) are hardening rules verified against real
|
|
7
|
+
false positives across a ~29-framework validation corpus instead - see
|
|
8
|
+
docs/FINDINGS.md.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from codehound.checks import ALL_CHECKS, get_checks
|
|
14
|
+
from codehound.core import Check, Finding, scan_file, scan_path
|
|
15
|
+
|
|
16
|
+
__version__ = "1.5.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ALL_CHECKS",
|
|
20
|
+
"get_checks",
|
|
21
|
+
"Check",
|
|
22
|
+
"Finding",
|
|
23
|
+
"scan_file",
|
|
24
|
+
"scan_path",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from codehound.checks.async_property import AsyncProperty
|
|
6
|
+
from codehound.checks.asyncio_coroutine_decorator import AsyncioCoroutineDecorator
|
|
6
7
|
from codehound.checks.asyncio_run_in_loop import AsyncioRunInRunningLoop
|
|
7
8
|
from codehound.checks.bare_except import BareExcept
|
|
8
9
|
from codehound.checks.blocking_async import BlockingCallInAsync
|
|
@@ -18,6 +19,7 @@ from codehound.checks.lru_cache_on_method import LruCacheOnMethod
|
|
|
18
19
|
from codehound.checks.mutable_defaults import MutableDefaultArgument
|
|
19
20
|
from codehound.checks.removed_asyncio_task_methods import RemovedAsyncioTaskMethods
|
|
20
21
|
from codehound.checks.removed_getargspec import RemovedGetargspec
|
|
22
|
+
from codehound.checks.removed_stdlib_module import RemovedStdlibModule
|
|
21
23
|
from codehound.checks.resource_leak import UnclosedFileHandle
|
|
22
24
|
from codehound.checks.unawaited_coroutine import UnawaitedCoroutineCall
|
|
23
25
|
from codehound.checks.unclosed_socket import UnclosedSocket
|
|
@@ -45,6 +47,8 @@ ALL_CHECKS: list[type[Check]] = [
|
|
|
45
47
|
RemovedAsyncioTaskMethods,
|
|
46
48
|
RemovedGetargspec,
|
|
47
49
|
BareExcept,
|
|
50
|
+
RemovedStdlibModule,
|
|
51
|
+
AsyncioCoroutineDecorator,
|
|
48
52
|
]
|
|
49
53
|
|
|
50
54
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""CH022 - ``@asyncio.coroutine`` was removed in Python 3.11.
|
|
2
|
+
|
|
3
|
+
The generator-based coroutine decorator predates `async def` (added in
|
|
4
|
+
3.5) and was kept around for years as a bridge for old code, deprecated
|
|
5
|
+
since 3.8 with an explicit `DeprecationWarning`, then removed outright in
|
|
6
|
+
3.11 - `AttributeError: module 'asyncio' has no attribute 'coroutine'`
|
|
7
|
+
the moment the decorator line runs, not something that surfaces later.
|
|
8
|
+
`async def` is the direct replacement; there's no decorator to swap in.
|
|
9
|
+
|
|
10
|
+
A bare `@coroutine` is only trusted to mean `asyncio.coroutine` if the
|
|
11
|
+
file actually imported it via `from asyncio import coroutine` - the same
|
|
12
|
+
name-collision guard CH018 uses for `Task`, since `coroutine` alone is
|
|
13
|
+
common enough to plausibly be someone's own decorator.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ast
|
|
19
|
+
|
|
20
|
+
from codehound.core import Check, Finding
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _imports_coroutine_from_asyncio(tree: ast.AST) -> bool:
|
|
24
|
+
for node in ast.walk(tree):
|
|
25
|
+
if isinstance(node, ast.ImportFrom) and node.module == "asyncio":
|
|
26
|
+
if any(alias.name == "coroutine" for alias in node.names):
|
|
27
|
+
return True
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_asyncio_coroutine_attr(node: ast.expr) -> bool:
|
|
32
|
+
return (
|
|
33
|
+
isinstance(node, ast.Attribute)
|
|
34
|
+
and node.attr == "coroutine"
|
|
35
|
+
and isinstance(node.value, ast.Name)
|
|
36
|
+
and node.value.id == "asyncio"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AsyncioCoroutineDecorator(Check):
|
|
41
|
+
code = "CH022"
|
|
42
|
+
name = "removed-asyncio-coroutine-decorator"
|
|
43
|
+
description = "@asyncio.coroutine was removed in Python 3.11; use `async def` instead."
|
|
44
|
+
|
|
45
|
+
def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
|
|
46
|
+
findings: list[Finding] = []
|
|
47
|
+
trust_bare_name = _imports_coroutine_from_asyncio(tree)
|
|
48
|
+
for node in ast.walk(tree):
|
|
49
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
50
|
+
continue
|
|
51
|
+
for dec in node.decorator_list:
|
|
52
|
+
is_match = _is_asyncio_coroutine_attr(dec) or (
|
|
53
|
+
trust_bare_name and isinstance(dec, ast.Name) and dec.id == "coroutine"
|
|
54
|
+
)
|
|
55
|
+
if not is_match:
|
|
56
|
+
continue
|
|
57
|
+
findings.append(
|
|
58
|
+
Finding(
|
|
59
|
+
path=path,
|
|
60
|
+
line=dec.lineno,
|
|
61
|
+
col=dec.col_offset,
|
|
62
|
+
code=self.code,
|
|
63
|
+
message=(
|
|
64
|
+
f"`@{ast.unparse(dec)}` on `{node.name}` - removed in Python 3.11; "
|
|
65
|
+
f"define `{node.name}` with `async def` instead."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
break
|
|
70
|
+
return findings
|
|
@@ -12,6 +12,19 @@ instance alive), not a style nit.
|
|
|
12
12
|
Only fires on a method - a plain module-level function decorated the same
|
|
13
13
|
way is exactly what ``lru_cache`` is for, and caches correctly with no
|
|
14
14
|
leak (nothing but the arguments themselves are held).
|
|
15
|
+
|
|
16
|
+
Also doesn't fire on a *frozen* value class (a stdlib ``@dataclass(frozen=
|
|
17
|
+
True)`` or a pydantic model with ``model_config = ConfigDict(frozen=True)``
|
|
18
|
+
/ ``class Config: frozen = True``). A frozen class is hashable and equal by
|
|
19
|
+
its field values, not by identity - verified directly: two separately
|
|
20
|
+
constructed instances with the same field values hash equal, and a second,
|
|
21
|
+
distinct instance's call is served straight from the first instance's
|
|
22
|
+
cache entry without ever being inserted itself (dspy's `Image.format()`,
|
|
23
|
+
checked this way, turned out to be exactly this - real, bounded-by-value
|
|
24
|
+
memoization on an immutable type, not a leak). The cache still only ever
|
|
25
|
+
holds as many distinct instances as there are distinct field-value
|
|
26
|
+
combinations seen, capped at `maxsize`, which is the same bound an
|
|
27
|
+
equivalent free function cached by value would have.
|
|
15
28
|
"""
|
|
16
29
|
|
|
17
30
|
from __future__ import annotations
|
|
@@ -32,6 +45,62 @@ def _is_cache_decorator(node: ast.expr) -> bool:
|
|
|
32
45
|
return False
|
|
33
46
|
|
|
34
47
|
|
|
48
|
+
def _is_frozen_dataclass(decorators: list[ast.expr]) -> bool:
|
|
49
|
+
for d in decorators:
|
|
50
|
+
if not isinstance(d, ast.Call):
|
|
51
|
+
continue
|
|
52
|
+
target = d.func
|
|
53
|
+
name = target.id if isinstance(target, ast.Name) else getattr(target, "attr", None)
|
|
54
|
+
if name != "dataclass":
|
|
55
|
+
continue
|
|
56
|
+
for kw in d.keywords:
|
|
57
|
+
if kw.arg == "frozen" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
58
|
+
return True
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_frozen_pydantic_model(cls: ast.ClassDef) -> bool:
|
|
63
|
+
for stmt in cls.body:
|
|
64
|
+
# model_config = ConfigDict(..., frozen=True, ...) or {"frozen": True}
|
|
65
|
+
if (
|
|
66
|
+
isinstance(stmt, ast.Assign)
|
|
67
|
+
and len(stmt.targets) == 1
|
|
68
|
+
and isinstance(stmt.targets[0], ast.Name)
|
|
69
|
+
and stmt.targets[0].id == "model_config"
|
|
70
|
+
):
|
|
71
|
+
value = stmt.value
|
|
72
|
+
if isinstance(value, ast.Call):
|
|
73
|
+
for kw in value.keywords:
|
|
74
|
+
if kw.arg == "frozen" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
75
|
+
return True
|
|
76
|
+
elif isinstance(value, ast.Dict):
|
|
77
|
+
for k, v in zip(value.keys, value.values):
|
|
78
|
+
if (
|
|
79
|
+
isinstance(k, ast.Constant)
|
|
80
|
+
and k.value == "frozen"
|
|
81
|
+
and isinstance(v, ast.Constant)
|
|
82
|
+
and v.value is True
|
|
83
|
+
):
|
|
84
|
+
return True
|
|
85
|
+
# Old-style pydantic v1: `class Config: frozen = True`
|
|
86
|
+
if isinstance(stmt, ast.ClassDef) and stmt.name == "Config":
|
|
87
|
+
for inner in stmt.body:
|
|
88
|
+
if (
|
|
89
|
+
isinstance(inner, ast.Assign)
|
|
90
|
+
and len(inner.targets) == 1
|
|
91
|
+
and isinstance(inner.targets[0], ast.Name)
|
|
92
|
+
and inner.targets[0].id == "frozen"
|
|
93
|
+
and isinstance(inner.value, ast.Constant)
|
|
94
|
+
and inner.value.value is True
|
|
95
|
+
):
|
|
96
|
+
return True
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_frozen_value_class(cls: ast.ClassDef) -> bool:
|
|
101
|
+
return _is_frozen_dataclass(cls.decorator_list) or _is_frozen_pydantic_model(cls)
|
|
102
|
+
|
|
103
|
+
|
|
35
104
|
def _is_static_or_classmethod(decorators: list[ast.expr]) -> bool:
|
|
36
105
|
for d in decorators:
|
|
37
106
|
target = d.func if isinstance(d, ast.Call) else d
|
|
@@ -56,6 +125,8 @@ class LruCacheOnMethod(Check):
|
|
|
56
125
|
continue
|
|
57
126
|
if _is_static_or_classmethod(node.decorator_list):
|
|
58
127
|
continue
|
|
128
|
+
if _is_frozen_value_class(parent):
|
|
129
|
+
continue
|
|
59
130
|
if not node.args.args or node.args.args[0].arg not in ("self",):
|
|
60
131
|
continue
|
|
61
132
|
for dec in node.decorator_list:
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""CH021 - importing a stdlib module that no longer exists.
|
|
2
|
+
|
|
3
|
+
``distutils`` was deprecated in 3.10 and removed outright in 3.12 (PEP
|
|
4
|
+
632) - still one of the most common "why won't this install on the new
|
|
5
|
+
Python" reports, since it was the default build backend for `setup.py`
|
|
6
|
+
for two decades. `asynchat`/`asyncore`/`imp` were removed the same
|
|
7
|
+
release. Python 3.13 then removed the 19 modules PEP 594 called the
|
|
8
|
+
standard library's "dead batteries" - old data-format, multimedia, and
|
|
9
|
+
platform-specific modules with long-unmaintained implementations and
|
|
10
|
+
better replacements on PyPI (`aifc`, `audioop`, `cgi`, `cgitb`, `chunk`,
|
|
11
|
+
`crypt`, `imghdr`, `mailcap`, `msilib`, `nis`, `nntplib`, `ossaudiodev`,
|
|
12
|
+
`pipes`, `sndhdr`, `spwd`, `sunau`, `telnetlib`, `uu`, `xdrlib`).
|
|
13
|
+
|
|
14
|
+
Same failure mode as CH017/CH019: `ImportError` the moment the module is
|
|
15
|
+
loaded on the Python version it was removed in, not a subtle runtime
|
|
16
|
+
bug - but real, and common in anything with an older dependency tree or
|
|
17
|
+
a `setup.py` that hasn't been touched since distutils was normal.
|
|
18
|
+
|
|
19
|
+
Not flagged when the import sits in a `try:` body whose `except` catches
|
|
20
|
+
`ImportError` (or anything broader) - a real, deliberate pattern found in
|
|
21
|
+
agno: `try: import imghdr except ImportError: import filetype` explicitly
|
|
22
|
+
anticipates and falls back from exactly this removal, so it isn't a bug
|
|
23
|
+
waiting to happen, it's already handled.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import ast
|
|
29
|
+
|
|
30
|
+
from codehound.core import Check, Finding
|
|
31
|
+
|
|
32
|
+
_IMPORT_ERROR_NAMES = {"ImportError", "ModuleNotFoundError", "Exception", "BaseException"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _handles_import_error(handler: ast.ExceptHandler) -> bool:
|
|
36
|
+
if handler.type is None:
|
|
37
|
+
return True
|
|
38
|
+
candidates = handler.type.elts if isinstance(handler.type, ast.Tuple) else [handler.type]
|
|
39
|
+
return any(isinstance(c, ast.Name) and c.id in _IMPORT_ERROR_NAMES for c in candidates)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_guarded_by_import_error_handler(node: ast.AST, parents: dict) -> bool:
|
|
43
|
+
"""True if `node` sits in the `try:` body of a Try with a matching
|
|
44
|
+
handler, at any enclosing level up to the function/class/module
|
|
45
|
+
boundary."""
|
|
46
|
+
cur = node
|
|
47
|
+
while cur is not None:
|
|
48
|
+
p = parents.get(id(cur))
|
|
49
|
+
if p is None:
|
|
50
|
+
return False
|
|
51
|
+
if isinstance(p, ast.Try) and cur in p.body and any(_handles_import_error(h) for h in p.handlers):
|
|
52
|
+
return True
|
|
53
|
+
if isinstance(p, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Module)):
|
|
54
|
+
return False
|
|
55
|
+
cur = p
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
_REMOVED_IN: dict[str, str] = {
|
|
59
|
+
"distutils": "3.12",
|
|
60
|
+
"asynchat": "3.12",
|
|
61
|
+
"asyncore": "3.12",
|
|
62
|
+
"imp": "3.12",
|
|
63
|
+
"aifc": "3.13",
|
|
64
|
+
"audioop": "3.13",
|
|
65
|
+
"cgi": "3.13",
|
|
66
|
+
"cgitb": "3.13",
|
|
67
|
+
"chunk": "3.13",
|
|
68
|
+
"crypt": "3.13",
|
|
69
|
+
"imghdr": "3.13",
|
|
70
|
+
"mailcap": "3.13",
|
|
71
|
+
"msilib": "3.13",
|
|
72
|
+
"nis": "3.13",
|
|
73
|
+
"nntplib": "3.13",
|
|
74
|
+
"ossaudiodev": "3.13",
|
|
75
|
+
"pipes": "3.13",
|
|
76
|
+
"sndhdr": "3.13",
|
|
77
|
+
"spwd": "3.13",
|
|
78
|
+
"sunau": "3.13",
|
|
79
|
+
"telnetlib": "3.13",
|
|
80
|
+
"uu": "3.13",
|
|
81
|
+
"xdrlib": "3.13",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RemovedStdlibModule(Check):
|
|
86
|
+
code = "CH021"
|
|
87
|
+
name = "removed-stdlib-module"
|
|
88
|
+
description = "Importing a stdlib module removed in a later Python version (distutils, PEP 594 modules, etc)."
|
|
89
|
+
|
|
90
|
+
def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
|
|
91
|
+
findings: list[Finding] = []
|
|
92
|
+
for node in ast.walk(tree):
|
|
93
|
+
if isinstance(node, ast.Import):
|
|
94
|
+
if _is_guarded_by_import_error_handler(node, parents):
|
|
95
|
+
continue
|
|
96
|
+
for alias in node.names:
|
|
97
|
+
top_level = alias.name.split(".")[0]
|
|
98
|
+
version = _REMOVED_IN.get(top_level)
|
|
99
|
+
if version is not None:
|
|
100
|
+
findings.append(
|
|
101
|
+
Finding(
|
|
102
|
+
path=path,
|
|
103
|
+
line=node.lineno,
|
|
104
|
+
col=node.col_offset,
|
|
105
|
+
code=self.code,
|
|
106
|
+
message=(
|
|
107
|
+
f"`import {alias.name}` - the `{top_level}` module was removed "
|
|
108
|
+
f"in Python {version}; there is no drop-in stdlib replacement, "
|
|
109
|
+
f"see its migration notes."
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
elif (
|
|
114
|
+
isinstance(node, ast.ImportFrom)
|
|
115
|
+
and node.module is not None
|
|
116
|
+
and node.level == 0
|
|
117
|
+
and not _is_guarded_by_import_error_handler(node, parents)
|
|
118
|
+
):
|
|
119
|
+
# `node.level` is the number of leading dots (0 = absolute). A
|
|
120
|
+
# relative `from .chunk import x` has `node.module == "chunk"`
|
|
121
|
+
# too - indistinguishable from the real stdlib module by name
|
|
122
|
+
# alone. Real false positive found in vllm's own local
|
|
123
|
+
# `fla/ops/chunk.py` sibling module, imported via `from .chunk
|
|
124
|
+
# import chunk_gated_delta_rule`.
|
|
125
|
+
top_level = node.module.split(".")[0]
|
|
126
|
+
version = _REMOVED_IN.get(top_level)
|
|
127
|
+
if version is not None:
|
|
128
|
+
names = ", ".join(alias.name for alias in node.names)
|
|
129
|
+
findings.append(
|
|
130
|
+
Finding(
|
|
131
|
+
path=path,
|
|
132
|
+
line=node.lineno,
|
|
133
|
+
col=node.col_offset,
|
|
134
|
+
code=self.code,
|
|
135
|
+
message=(
|
|
136
|
+
f"`from {node.module} import {names}` - the `{top_level}` module "
|
|
137
|
+
f"was removed in Python {version}; there is no drop-in stdlib "
|
|
138
|
+
f"replacement, see its migration notes."
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return findings
|
|
@@ -517,6 +517,92 @@ def test_ch011_ignores_uncached_instance_method():
|
|
|
517
517
|
assert _run(code, ["CH011"]) == []
|
|
518
518
|
|
|
519
519
|
|
|
520
|
+
def test_ch011_ignores_frozen_dataclass():
|
|
521
|
+
# A frozen dataclass is hashable and equal by field value, not identity -
|
|
522
|
+
# verified empirically: two separately constructed instances with the
|
|
523
|
+
# same field values hash equal, and lru_cache serves the second one
|
|
524
|
+
# straight from the first's cache entry without ever storing it. Bounded
|
|
525
|
+
# by maxsize the same way an equivalent free function cached by value
|
|
526
|
+
# would be - not a leak.
|
|
527
|
+
code = (
|
|
528
|
+
"from dataclasses import dataclass\n"
|
|
529
|
+
"from functools import lru_cache\n"
|
|
530
|
+
"@dataclass(frozen=True)\n"
|
|
531
|
+
"class Point:\n"
|
|
532
|
+
" x: int\n"
|
|
533
|
+
" @lru_cache\n"
|
|
534
|
+
" def scaled(self, factor):\n"
|
|
535
|
+
" return self.x * factor\n"
|
|
536
|
+
)
|
|
537
|
+
assert _run(code, ["CH011"]) == []
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def test_ch011_ignores_frozen_pydantic_model():
|
|
541
|
+
# Real false positive found in dspy: Image (model_config =
|
|
542
|
+
# ConfigDict(frozen=True)) caches format() with @lru_cache(maxsize=32) -
|
|
543
|
+
# confirmed via a REPL check that this is value-based memoization, not a
|
|
544
|
+
# per-instance leak.
|
|
545
|
+
code = (
|
|
546
|
+
"import pydantic\n"
|
|
547
|
+
"from functools import lru_cache\n"
|
|
548
|
+
"class Image(pydantic.BaseModel):\n"
|
|
549
|
+
" url: str\n"
|
|
550
|
+
" model_config = pydantic.ConfigDict(frozen=True)\n"
|
|
551
|
+
" @lru_cache(maxsize=32)\n"
|
|
552
|
+
" def format(self):\n"
|
|
553
|
+
" return self.url.upper()\n"
|
|
554
|
+
)
|
|
555
|
+
assert _run(code, ["CH011"]) == []
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def test_ch011_ignores_frozen_pydantic_model_v1_style_config():
|
|
559
|
+
code = (
|
|
560
|
+
"import pydantic\n"
|
|
561
|
+
"from functools import lru_cache\n"
|
|
562
|
+
"class Image(pydantic.BaseModel):\n"
|
|
563
|
+
" url: str\n"
|
|
564
|
+
" class Config:\n"
|
|
565
|
+
" frozen = True\n"
|
|
566
|
+
" @lru_cache\n"
|
|
567
|
+
" def format(self):\n"
|
|
568
|
+
" return self.url.upper()\n"
|
|
569
|
+
)
|
|
570
|
+
assert _run(code, ["CH011"]) == []
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def test_ch011_still_flags_mutable_dataclass():
|
|
574
|
+
# Guard against the frozen-class check over-suppressing: a plain (not
|
|
575
|
+
# frozen) dataclass is not hashable by value, so this must still flag.
|
|
576
|
+
code = (
|
|
577
|
+
"from dataclasses import dataclass\n"
|
|
578
|
+
"from functools import lru_cache\n"
|
|
579
|
+
"@dataclass\n"
|
|
580
|
+
"class Point:\n"
|
|
581
|
+
" x: int\n"
|
|
582
|
+
" @lru_cache\n"
|
|
583
|
+
" def scaled(self, factor):\n"
|
|
584
|
+
" return self.x * factor\n"
|
|
585
|
+
)
|
|
586
|
+
findings = _run(code, ["CH011"])
|
|
587
|
+
assert len(findings) == 1
|
|
588
|
+
assert findings[0].code == "CH011"
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def test_ch011_still_flags_pydantic_model_without_frozen_config():
|
|
592
|
+
code = (
|
|
593
|
+
"import pydantic\n"
|
|
594
|
+
"from functools import lru_cache\n"
|
|
595
|
+
"class Image(pydantic.BaseModel):\n"
|
|
596
|
+
" url: str\n"
|
|
597
|
+
" @lru_cache\n"
|
|
598
|
+
" def format(self):\n"
|
|
599
|
+
" return self.url.upper()\n"
|
|
600
|
+
)
|
|
601
|
+
findings = _run(code, ["CH011"])
|
|
602
|
+
assert len(findings) == 1
|
|
603
|
+
assert findings[0].code == "CH011"
|
|
604
|
+
|
|
605
|
+
|
|
520
606
|
# --- CH012 floating-process ----------------------------------------------------------
|
|
521
607
|
|
|
522
608
|
|
|
@@ -957,3 +1043,111 @@ def test_ch020_flags_base_exception_with_raise_only_in_nested_handler():
|
|
|
957
1043
|
)
|
|
958
1044
|
assert len(_run(code, ["CH020"])) == 1
|
|
959
1045
|
|
|
1046
|
+
|
|
1047
|
+
# --- CH021 removed-stdlib-module ------------------------------------------------------
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def test_ch021_flags_distutils_import():
|
|
1051
|
+
findings = _run("import distutils\n", ["CH021"])
|
|
1052
|
+
assert len(findings) == 1
|
|
1053
|
+
assert findings[0].code == "CH021"
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def test_ch021_flags_distutils_submodule_import():
|
|
1057
|
+
findings = _run("import distutils.core\n", ["CH021"])
|
|
1058
|
+
assert len(findings) == 1
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def test_ch021_flags_distutils_from_import():
|
|
1062
|
+
code = "from distutils.core import setup\n"
|
|
1063
|
+
findings = _run(code, ["CH021"])
|
|
1064
|
+
assert len(findings) == 1
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def test_ch021_flags_pep594_dead_battery_modules():
|
|
1068
|
+
for mod in ["cgi", "imghdr", "telnetlib", "nntplib", "asynchat", "asyncore", "imp"]:
|
|
1069
|
+
findings = _run(f"import {mod}\n", ["CH021"])
|
|
1070
|
+
assert len(findings) == 1, f"expected {mod} to be flagged"
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def test_ch021_ignores_unrelated_modules():
|
|
1074
|
+
code = "import os\nimport sys\nfrom collections import OrderedDict\n"
|
|
1075
|
+
assert _run(code, ["CH021"]) == []
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def test_ch021_ignores_similarly_named_local_module():
|
|
1079
|
+
# A module named e.g. `imp` in the removed set shouldn't false-positive
|
|
1080
|
+
# on an unrelated attribute/name access - only actual import statements.
|
|
1081
|
+
code = "imp = 5\nprint(imp)\n"
|
|
1082
|
+
assert _run(code, ["CH021"]) == []
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def test_ch021_ignores_relative_import_of_same_named_local_module():
|
|
1086
|
+
# Real false positive found in vllm: `from .chunk import
|
|
1087
|
+
# chunk_gated_delta_rule` imports a local sibling module named
|
|
1088
|
+
# chunk.py, not the removed stdlib `chunk` module - `node.module` is
|
|
1089
|
+
# "chunk" either way, so only `node.level == 0` (absolute) tells them
|
|
1090
|
+
# apart.
|
|
1091
|
+
code = "from .chunk import chunk_gated_delta_rule\n"
|
|
1092
|
+
assert _run(code, ["CH021"]) == []
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def test_ch021_ignores_import_guarded_by_import_error_handler():
|
|
1096
|
+
# Real false positive found in agno: `try: import imghdr except
|
|
1097
|
+
# ImportError: import filetype` explicitly anticipates and falls back
|
|
1098
|
+
# from the removal - already handled, not a bug waiting to happen.
|
|
1099
|
+
code = "try:\n import imghdr\nexcept ImportError:\n import filetype\n"
|
|
1100
|
+
assert _run(code, ["CH021"]) == []
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def test_ch021_ignores_from_import_guarded_by_broad_except():
|
|
1104
|
+
code = "try:\n from distutils.core import setup\nexcept Exception:\n setup = None\n"
|
|
1105
|
+
assert _run(code, ["CH021"]) == []
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def test_ch021_still_flags_import_in_try_with_unrelated_handler():
|
|
1109
|
+
# A try/except that doesn't actually catch ImportError provides no
|
|
1110
|
+
# real protection, so this must still be flagged.
|
|
1111
|
+
code = "try:\n import distutils\nexcept ValueError:\n pass\n"
|
|
1112
|
+
findings = _run(code, ["CH021"])
|
|
1113
|
+
assert len(findings) == 1
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def test_ch021_still_flags_import_outside_any_try():
|
|
1117
|
+
code = "import distutils\ntry:\n risky()\nexcept ImportError:\n pass\n"
|
|
1118
|
+
findings = _run(code, ["CH021"])
|
|
1119
|
+
assert len(findings) == 1
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
# --- CH022 removed-asyncio-coroutine-decorator -----------------------------------------
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def test_ch022_flags_asyncio_coroutine_decorator():
|
|
1126
|
+
code = "import asyncio\n@asyncio.coroutine\ndef f():\n yield from g()\n"
|
|
1127
|
+
findings = _run(code, ["CH022"])
|
|
1128
|
+
assert len(findings) == 1
|
|
1129
|
+
assert findings[0].code == "CH022"
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def test_ch022_flags_bare_coroutine_when_imported_from_asyncio():
|
|
1133
|
+
code = "from asyncio import coroutine\n@coroutine\ndef f():\n yield from g()\n"
|
|
1134
|
+
findings = _run(code, ["CH022"])
|
|
1135
|
+
assert len(findings) == 1
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def test_ch022_ignores_bare_coroutine_not_imported_from_asyncio():
|
|
1139
|
+
# Real name-collision guard, same shape as CH018's for `Task`: a
|
|
1140
|
+
# same-named decorator from somewhere else shouldn't be misidentified.
|
|
1141
|
+
code = "from mylib import coroutine\n@coroutine\ndef f():\n return 1\n"
|
|
1142
|
+
assert _run(code, ["CH022"]) == []
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def test_ch022_ignores_async_def_without_decorator():
|
|
1146
|
+
code = "async def f():\n return 1\n"
|
|
1147
|
+
assert _run(code, ["CH022"]) == []
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def test_ch022_ignores_unrelated_attribute_named_coroutine():
|
|
1151
|
+
code = "import asyncio\n@other.coroutine\ndef f():\n return 1\n"
|
|
1152
|
+
assert _run(code, ["CH022"]) == []
|
|
1153
|
+
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
"""codehound - an AST-based static analyzer that hunts real bugs in Python code.
|
|
2
|
-
|
|
3
|
-
Twenty checks. Seven are each backed by a bug that was actually found and
|
|
4
|
-
fixed in a popular open-source AI framework (agno, crewAI, mem0,
|
|
5
|
-
llama_index, accelerate). The rest (CH007-CH009, CH011-CH020) are
|
|
6
|
-
hardening rules verified against real false positives across a
|
|
7
|
-
~20-framework validation corpus instead - see docs/FINDINGS.md.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
from codehound.checks import ALL_CHECKS, get_checks
|
|
13
|
-
from codehound.core import Check, Finding, scan_file, scan_path
|
|
14
|
-
|
|
15
|
-
__version__ = "1.4.1"
|
|
16
|
-
|
|
17
|
-
__all__ = [
|
|
18
|
-
"ALL_CHECKS",
|
|
19
|
-
"get_checks",
|
|
20
|
-
"Check",
|
|
21
|
-
"Finding",
|
|
22
|
-
"scan_file",
|
|
23
|
-
"scan_path",
|
|
24
|
-
"__version__",
|
|
25
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|