codehound 1.4.2__tar.gz → 1.6.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.2 → codehound-1.6.0}/PKG-INFO +118 -21
- {codehound-1.4.2 → codehound-1.6.0}/README.md +117 -20
- codehound-1.6.0/src/codehound/__init__.py +26 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/__init__.py +16 -0
- codehound-1.6.0/src/codehound/checks/asyncio_coroutine_decorator.py +70 -0
- codehound-1.6.0/src/codehound/checks/floating_timer.py +169 -0
- codehound-1.6.0/src/codehound/checks/is_literal_comparison.py +80 -0
- codehound-1.6.0/src/codehound/checks/mutable_class_attribute.py +108 -0
- codehound-1.6.0/src/codehound/checks/removed_stdlib_attribute.py +75 -0
- codehound-1.6.0/src/codehound/checks/removed_stdlib_module.py +142 -0
- codehound-1.6.0/src/codehound/checks/unittest_deprecated_alias.py +68 -0
- codehound-1.6.0/src/codehound/checks/unwaited_subprocess.py +146 -0
- {codehound-1.4.2 → codehound-1.6.0}/tests/test_checks.py +431 -0
- codehound-1.4.2/src/codehound/__init__.py +0 -25
- {codehound-1.4.2 → codehound-1.6.0}/.gitignore +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/LICENSE +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/pyproject.toml +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/async_property.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/bare_except.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/blocking_async.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/collections_abc_import.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/datetime_utcnow.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/discarded_future.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/floating_process.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/floating_task.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/floating_thread.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/get_event_loop.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/loop_closure_capture.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/lru_cache_on_method.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/mutable_defaults.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/removed_getargspec.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/resource_leak.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/unclosed_socket.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/unprotected_lock.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/cli.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/core.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/sarif.py +0 -0
- {codehound-1.4.2 → codehound-1.6.0}/src/codehound/terminal.py +0 -0
- {codehound-1.4.2 → codehound-1.6.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.6.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-eight 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 ~
|
|
74
|
+
Zero dependencies — it's ~3,200 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.6.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,18 @@ 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 |
|
|
168
|
+
| **CH023** | `removed-stdlib-attribute` | A specific removed function on a module that still imports fine — `time.clock()` (3.8), `platform.linux_distribution()`/`.dist()` (3.8), `cgi.escape()` (3.8), `base64.encodestring()`/`.decodestring()` (3.9). | hardening rule — real hit in scikit-learn |
|
|
169
|
+
| **CH024** | `unittest-deprecated-alias` | `self.assertEquals(...)`/`self.failUnless(...)` and a dozen other legacy `unittest.TestCase` aliases — removed in Python 3.12. | hardening rule |
|
|
170
|
+
| **CH025** | `is-literal-comparison` | `x is 1000` / `x is not "foo"` — `is` checks identity, not equality; relies on CPython's small-int caching / string interning, neither guaranteed. (pyflakes F632) | hardening rule — real false positive fixed in litellm's own code, see below |
|
|
171
|
+
| **CH026** | `mutable-class-attribute` | `class C: items = []` mutated via `self.items.append(...)` without ever being reassigned per instance — every instance shares and mutates the *same* list. | **vllm**, **llama_index**, **optuna**, **transformers** — see below |
|
|
172
|
+
| **CH027** | `unwaited-subprocess` | `subprocess.Popen(...)` never `.wait()`ed/`.communicate()`d with, and not context-managed — risks a zombie process and a full pipe buffer deadlocking the child. | hardening rule — real hit in dspy (already handled, see below) |
|
|
173
|
+
| **CH028** | `floating-timer` | `threading.Timer(...)` started but never `.cancel()`ed or handed off — nothing can stop the callback from firing later, on stale context. | hardening rule — real hits in marimo, transformers |
|
|
166
174
|
|
|
167
175
|
`codehound list` prints this from the source of truth.
|
|
168
176
|
|
|
169
|
-
CH007-
|
|
177
|
+
CH007-CH028 don't have found-and-merged bugs behind all of them the way
|
|
170
178
|
CH001-CH006 do - most are hardening rules for well-known Python
|
|
171
179
|
correctness gotchas rather than something this project personally
|
|
172
180
|
tracked down first. CH010 and CH011 are the exceptions: both found
|
|
@@ -210,20 +218,28 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
|
|
|
210
218
|
match same-file names by design, and most real cases of either are
|
|
211
219
|
plausibly cross-module.
|
|
212
220
|
|
|
213
|
-
**The optuna and
|
|
214
|
-
None)`
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
`
|
|
224
|
-
|
|
221
|
+
**The optuna, llama_index, and litellm finds (CH011):** all three are
|
|
222
|
+
`@lru_cache(maxsize=None)` (or a fixed `maxsize`) decorating an instance
|
|
223
|
+
method - a strong reference to `self` retained for the life of the
|
|
224
|
+
process. In optuna, `_FanovaTree`'s node-lookup methods leak every tree
|
|
225
|
+
built for a `get_param_importances()` call (one per random-forest
|
|
226
|
+
estimator). In llama_index, `VectaraIndex._get_corpus_key` leaks the
|
|
227
|
+
index itself - and since `VectaraIndex.__del__` exists specifically to
|
|
228
|
+
close the index's `requests.Session` on garbage collection, the leak
|
|
229
|
+
silently disables that cleanup too, so an HTTP session leaks along with
|
|
230
|
+
every index. In litellm, `Router._cached_get_model_group_info` leaks
|
|
231
|
+
every `Router` that's ever served a request through it - proved this
|
|
232
|
+
survives even a correctly-called `router.discard()` (Router's own
|
|
233
|
+
documented cleanup method), so it isn't a "you forgot to clean up" bug.
|
|
234
|
+
All three fixed the same way: move the cache from a class-level decorator
|
|
235
|
+
to a per-instance one built in `__init__`, so it's freed with the
|
|
236
|
+
instance instead of outliving it - the exact pattern litellm's own
|
|
237
|
+
`cached_deployment_model_info` sibling method already used, just not yet
|
|
238
|
+
applied to this one. Each has a regression test verified to fail pre-fix
|
|
239
|
+
and pass post-fix.
|
|
225
240
|
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)
|
|
241
|
+
[run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089),
|
|
242
|
+
[BerriAI/litellm#41582](https://github.com/BerriAI/litellm/pull/41582).
|
|
227
243
|
|
|
228
244
|
**A third CH011 shape needed a guard instead of a PR:** dspy's `Image` (a
|
|
229
245
|
pydantic model) caches `format()` the same way, but `Image` is frozen
|
|
@@ -247,6 +263,74 @@ as escaped when it's returned as part of a tuple/list or passed as an
|
|
|
247
263
|
argument to any call. A full corpus rescan afterward found zero remaining
|
|
248
264
|
CH016 hits.
|
|
249
265
|
|
|
266
|
+
**CH021 did the same thing twice, minutes apart.** The first real-corpus
|
|
267
|
+
scan found a false positive in vllm - `from .chunk import
|
|
268
|
+
chunk_gated_delta_rule`, a relative import of vllm's own local `chunk.py`
|
|
269
|
+
sibling module, not the removed stdlib `chunk`. `ast.ImportFrom.module`
|
|
270
|
+
is `"chunk"` either way; only `node.level` (the leading-dot count) tells
|
|
271
|
+
a relative import apart from an absolute one, and the check wasn't
|
|
272
|
+
checking it. Fixed, rescanned, and found a *second* false positive in
|
|
273
|
+
agno: `try: import imghdr except ImportError: import filetype` - a
|
|
274
|
+
real, deliberate fallback that already anticipates this exact removal,
|
|
275
|
+
not a bug waiting to happen. Added a second guard: skip an import inside
|
|
276
|
+
a `try:` body whose `except` catches `ImportError` (or anything
|
|
277
|
+
broader). A full corpus rescan after both fixes found zero remaining
|
|
278
|
+
CH021 hits.
|
|
279
|
+
|
|
280
|
+
**CH026 found four real, previously-unreported bugs on its first real
|
|
281
|
+
scan.** All four are the exact same shape: a class-level mutable default
|
|
282
|
+
(`items = []`) mutated in place via `self.items.append(...)` (or
|
|
283
|
+
subscript assignment) with no per-instance reassignment anywhere, so
|
|
284
|
+
every instance of the class shares and corrupts the *same* object. In
|
|
285
|
+
vllm's `AXK1ForCausalLM`, `self.packed_modules_mapping["qkv_proj"] =
|
|
286
|
+
[...]` patches a routing table shared by every instance of the model
|
|
287
|
+
class. In llama_index's `ZapierToolSpec`, `self.spec_functions.append(...)`
|
|
288
|
+
means a second tool-spec instance (a different API key, a different
|
|
289
|
+
user) inherits every action name the first instance ever registered. In
|
|
290
|
+
optuna's CLI `_Studies` command, `self._study_list_header.append(...)`
|
|
291
|
+
does the same to a table-header list. In HuggingFace transformers'
|
|
292
|
+
`CodeGenTokenizer`, `self.model_input_names.append("token_type_ids")`
|
|
293
|
+
means constructing one tokenizer with `return_token_type_ids=True`
|
|
294
|
+
silently changes what field every *other* `CodeGenTokenizer` instance in
|
|
295
|
+
the same process expects, regardless of how it was configured - exactly
|
|
296
|
+
the "spooky action at a distance" class of bug this project exists to
|
|
297
|
+
catch. None have PRs yet: vllm and transformers both require AI-assisted
|
|
298
|
+
PRs to carry an explicit disclosure, which this project's own policy
|
|
299
|
+
doesn't do, so those two are documented here rather than filed.
|
|
300
|
+
|
|
301
|
+
**CH028 hit the exact same name-collision problem CH018/CH022 already
|
|
302
|
+
had a guard for, because that guard didn't get reused.** The very first
|
|
303
|
+
corpus scan came back with ~30 hits, almost all in agno - which doesn't
|
|
304
|
+
use `threading.Timer` at all. `from agno.utils.timer import Timer` is
|
|
305
|
+
agno's own unrelated stopwatch class, called as `Timer()` with zero
|
|
306
|
+
arguments (real `threading.Timer` requires `interval` and `function` and
|
|
307
|
+
would raise `TypeError` immediately). Fixed by requiring `from threading
|
|
308
|
+
import Timer` before trusting a bare `Timer(...)` call - the same guard
|
|
309
|
+
already built for CH022's `coroutine` minutes earlier in the same
|
|
310
|
+
session, just not applied here the first time. Also missed CH009's
|
|
311
|
+
`daemon=True` escape entirely (found in weaviate-python-client's
|
|
312
|
+
watchdog timer, `_timeout_timer.daemon = True`, a deliberate
|
|
313
|
+
"outlive the caller" choice) - added both a constructor-kwarg and a
|
|
314
|
+
post-construction-assignment check for it, matching CH009 exactly. Real
|
|
315
|
+
hits remain in marimo (a non-daemon browser-opening timer, never
|
|
316
|
+
cancelled) and transformers (a chained, never-captured checkpoint-retry
|
|
317
|
+
timer).
|
|
318
|
+
|
|
319
|
+
**CH025 and CH027 each found one real bug in the first scan, and one
|
|
320
|
+
real gap in the check.** CH025 (is-literal-comparison) flagged litellm's
|
|
321
|
+
`if "usage" in response_obj is not None:` - but for the wrong reason.
|
|
322
|
+
`ast.Compare` puts every operand and every op from a chained comparison
|
|
323
|
+
in one node; checking "is there a literal anywhere" and "is there an
|
|
324
|
+
`is`/`is not` anywhere" independently, without pairing each op with its
|
|
325
|
+
own adjacent operands, matched the string literal (paired with `in`)
|
|
326
|
+
against the wrong op (`is not`, actually comparing `response_obj` to the
|
|
327
|
+
allowed singleton `None`). Fixed by walking the chain as adjacent
|
|
328
|
+
`(left, op, right)` triples. CH027 (unwaited-subprocess) flagged dspy's
|
|
329
|
+
`process = subprocess.Popen(...)`, followed by `lm.process = process` -
|
|
330
|
+
a real hand-off to a different object, reaped later through a separate
|
|
331
|
+
`terminate_process(lm.process)` call, the same "stored as any object's
|
|
332
|
+
attribute" escape CH009/CH016/CH028 already needed. Added it.
|
|
333
|
+
|
|
250
334
|
**Two checks we built and did not ship.** `exception-chaining` (`except X
|
|
251
335
|
as e: raise Y(...)` with no `from e`, discarding the real traceback -
|
|
252
336
|
overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
|
|
@@ -302,12 +386,20 @@ codehound/
|
|
|
302
386
|
├── collections_abc_import.py (CH017)
|
|
303
387
|
├── removed_asyncio_task_methods.py (CH018)
|
|
304
388
|
├── removed_getargspec.py (CH019)
|
|
305
|
-
|
|
389
|
+
├── bare_except.py (CH020)
|
|
390
|
+
├── removed_stdlib_module.py (CH021)
|
|
391
|
+
├── asyncio_coroutine_decorator.py (CH022)
|
|
392
|
+
├── removed_stdlib_attribute.py (CH023)
|
|
393
|
+
├── unittest_deprecated_alias.py (CH024)
|
|
394
|
+
├── is_literal_comparison.py (CH025)
|
|
395
|
+
├── mutable_class_attribute.py (CH026)
|
|
396
|
+
├── unwaited_subprocess.py (CH027)
|
|
397
|
+
└── floating_timer.py (CH028)
|
|
306
398
|
```
|
|
307
399
|
|
|
308
400
|
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.
|
|
309
401
|
|
|
310
|
-
**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."
|
|
402
|
+
**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. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. 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."
|
|
311
403
|
|
|
312
404
|
---
|
|
313
405
|
|
|
@@ -337,6 +429,11 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
337
429
|
- [x] 20 checks — memory leaks (`lru_cache` on methods), floating processes,
|
|
338
430
|
discarded futures, unprotected locks, async properties, unclosed
|
|
339
431
|
sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
|
|
432
|
+
- [x] 22 checks — removed stdlib modules (`distutils`, PEP 594 "dead
|
|
433
|
+
batteries"), removed `@asyncio.coroutine` decorator — CH021-CH022
|
|
434
|
+
- [x] 28 checks — removed stdlib functions, deprecated unittest aliases,
|
|
435
|
+
`is`-literal comparisons, mutable class attributes, unwaited
|
|
436
|
+
subprocesses, floating timers — CH023-CH028
|
|
340
437
|
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
341
438
|
- [ ] Sync HTTP clients constructed inside async request handlers
|
|
342
439
|
- [ ] `--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-eight 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 ~
|
|
54
|
+
Zero dependencies — it's ~3,200 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.6.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,18 @@ 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 |
|
|
148
|
+
| **CH023** | `removed-stdlib-attribute` | A specific removed function on a module that still imports fine — `time.clock()` (3.8), `platform.linux_distribution()`/`.dist()` (3.8), `cgi.escape()` (3.8), `base64.encodestring()`/`.decodestring()` (3.9). | hardening rule — real hit in scikit-learn |
|
|
149
|
+
| **CH024** | `unittest-deprecated-alias` | `self.assertEquals(...)`/`self.failUnless(...)` and a dozen other legacy `unittest.TestCase` aliases — removed in Python 3.12. | hardening rule |
|
|
150
|
+
| **CH025** | `is-literal-comparison` | `x is 1000` / `x is not "foo"` — `is` checks identity, not equality; relies on CPython's small-int caching / string interning, neither guaranteed. (pyflakes F632) | hardening rule — real false positive fixed in litellm's own code, see below |
|
|
151
|
+
| **CH026** | `mutable-class-attribute` | `class C: items = []` mutated via `self.items.append(...)` without ever being reassigned per instance — every instance shares and mutates the *same* list. | **vllm**, **llama_index**, **optuna**, **transformers** — see below |
|
|
152
|
+
| **CH027** | `unwaited-subprocess` | `subprocess.Popen(...)` never `.wait()`ed/`.communicate()`d with, and not context-managed — risks a zombie process and a full pipe buffer deadlocking the child. | hardening rule — real hit in dspy (already handled, see below) |
|
|
153
|
+
| **CH028** | `floating-timer` | `threading.Timer(...)` started but never `.cancel()`ed or handed off — nothing can stop the callback from firing later, on stale context. | hardening rule — real hits in marimo, transformers |
|
|
146
154
|
|
|
147
155
|
`codehound list` prints this from the source of truth.
|
|
148
156
|
|
|
149
|
-
CH007-
|
|
157
|
+
CH007-CH028 don't have found-and-merged bugs behind all of them the way
|
|
150
158
|
CH001-CH006 do - most are hardening rules for well-known Python
|
|
151
159
|
correctness gotchas rather than something this project personally
|
|
152
160
|
tracked down first. CH010 and CH011 are the exceptions: both found
|
|
@@ -190,20 +198,28 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
|
|
|
190
198
|
match same-file names by design, and most real cases of either are
|
|
191
199
|
plausibly cross-module.
|
|
192
200
|
|
|
193
|
-
**The optuna and
|
|
194
|
-
None)`
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
`
|
|
204
|
-
|
|
201
|
+
**The optuna, llama_index, and litellm finds (CH011):** all three are
|
|
202
|
+
`@lru_cache(maxsize=None)` (or a fixed `maxsize`) decorating an instance
|
|
203
|
+
method - a strong reference to `self` retained for the life of the
|
|
204
|
+
process. In optuna, `_FanovaTree`'s node-lookup methods leak every tree
|
|
205
|
+
built for a `get_param_importances()` call (one per random-forest
|
|
206
|
+
estimator). In llama_index, `VectaraIndex._get_corpus_key` leaks the
|
|
207
|
+
index itself - and since `VectaraIndex.__del__` exists specifically to
|
|
208
|
+
close the index's `requests.Session` on garbage collection, the leak
|
|
209
|
+
silently disables that cleanup too, so an HTTP session leaks along with
|
|
210
|
+
every index. In litellm, `Router._cached_get_model_group_info` leaks
|
|
211
|
+
every `Router` that's ever served a request through it - proved this
|
|
212
|
+
survives even a correctly-called `router.discard()` (Router's own
|
|
213
|
+
documented cleanup method), so it isn't a "you forgot to clean up" bug.
|
|
214
|
+
All three fixed the same way: move the cache from a class-level decorator
|
|
215
|
+
to a per-instance one built in `__init__`, so it's freed with the
|
|
216
|
+
instance instead of outliving it - the exact pattern litellm's own
|
|
217
|
+
`cached_deployment_model_info` sibling method already used, just not yet
|
|
218
|
+
applied to this one. Each has a regression test verified to fail pre-fix
|
|
219
|
+
and pass post-fix.
|
|
205
220
|
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)
|
|
221
|
+
[run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089),
|
|
222
|
+
[BerriAI/litellm#41582](https://github.com/BerriAI/litellm/pull/41582).
|
|
207
223
|
|
|
208
224
|
**A third CH011 shape needed a guard instead of a PR:** dspy's `Image` (a
|
|
209
225
|
pydantic model) caches `format()` the same way, but `Image` is frozen
|
|
@@ -227,6 +243,74 @@ as escaped when it's returned as part of a tuple/list or passed as an
|
|
|
227
243
|
argument to any call. A full corpus rescan afterward found zero remaining
|
|
228
244
|
CH016 hits.
|
|
229
245
|
|
|
246
|
+
**CH021 did the same thing twice, minutes apart.** The first real-corpus
|
|
247
|
+
scan found a false positive in vllm - `from .chunk import
|
|
248
|
+
chunk_gated_delta_rule`, a relative import of vllm's own local `chunk.py`
|
|
249
|
+
sibling module, not the removed stdlib `chunk`. `ast.ImportFrom.module`
|
|
250
|
+
is `"chunk"` either way; only `node.level` (the leading-dot count) tells
|
|
251
|
+
a relative import apart from an absolute one, and the check wasn't
|
|
252
|
+
checking it. Fixed, rescanned, and found a *second* false positive in
|
|
253
|
+
agno: `try: import imghdr except ImportError: import filetype` - a
|
|
254
|
+
real, deliberate fallback that already anticipates this exact removal,
|
|
255
|
+
not a bug waiting to happen. Added a second guard: skip an import inside
|
|
256
|
+
a `try:` body whose `except` catches `ImportError` (or anything
|
|
257
|
+
broader). A full corpus rescan after both fixes found zero remaining
|
|
258
|
+
CH021 hits.
|
|
259
|
+
|
|
260
|
+
**CH026 found four real, previously-unreported bugs on its first real
|
|
261
|
+
scan.** All four are the exact same shape: a class-level mutable default
|
|
262
|
+
(`items = []`) mutated in place via `self.items.append(...)` (or
|
|
263
|
+
subscript assignment) with no per-instance reassignment anywhere, so
|
|
264
|
+
every instance of the class shares and corrupts the *same* object. In
|
|
265
|
+
vllm's `AXK1ForCausalLM`, `self.packed_modules_mapping["qkv_proj"] =
|
|
266
|
+
[...]` patches a routing table shared by every instance of the model
|
|
267
|
+
class. In llama_index's `ZapierToolSpec`, `self.spec_functions.append(...)`
|
|
268
|
+
means a second tool-spec instance (a different API key, a different
|
|
269
|
+
user) inherits every action name the first instance ever registered. In
|
|
270
|
+
optuna's CLI `_Studies` command, `self._study_list_header.append(...)`
|
|
271
|
+
does the same to a table-header list. In HuggingFace transformers'
|
|
272
|
+
`CodeGenTokenizer`, `self.model_input_names.append("token_type_ids")`
|
|
273
|
+
means constructing one tokenizer with `return_token_type_ids=True`
|
|
274
|
+
silently changes what field every *other* `CodeGenTokenizer` instance in
|
|
275
|
+
the same process expects, regardless of how it was configured - exactly
|
|
276
|
+
the "spooky action at a distance" class of bug this project exists to
|
|
277
|
+
catch. None have PRs yet: vllm and transformers both require AI-assisted
|
|
278
|
+
PRs to carry an explicit disclosure, which this project's own policy
|
|
279
|
+
doesn't do, so those two are documented here rather than filed.
|
|
280
|
+
|
|
281
|
+
**CH028 hit the exact same name-collision problem CH018/CH022 already
|
|
282
|
+
had a guard for, because that guard didn't get reused.** The very first
|
|
283
|
+
corpus scan came back with ~30 hits, almost all in agno - which doesn't
|
|
284
|
+
use `threading.Timer` at all. `from agno.utils.timer import Timer` is
|
|
285
|
+
agno's own unrelated stopwatch class, called as `Timer()` with zero
|
|
286
|
+
arguments (real `threading.Timer` requires `interval` and `function` and
|
|
287
|
+
would raise `TypeError` immediately). Fixed by requiring `from threading
|
|
288
|
+
import Timer` before trusting a bare `Timer(...)` call - the same guard
|
|
289
|
+
already built for CH022's `coroutine` minutes earlier in the same
|
|
290
|
+
session, just not applied here the first time. Also missed CH009's
|
|
291
|
+
`daemon=True` escape entirely (found in weaviate-python-client's
|
|
292
|
+
watchdog timer, `_timeout_timer.daemon = True`, a deliberate
|
|
293
|
+
"outlive the caller" choice) - added both a constructor-kwarg and a
|
|
294
|
+
post-construction-assignment check for it, matching CH009 exactly. Real
|
|
295
|
+
hits remain in marimo (a non-daemon browser-opening timer, never
|
|
296
|
+
cancelled) and transformers (a chained, never-captured checkpoint-retry
|
|
297
|
+
timer).
|
|
298
|
+
|
|
299
|
+
**CH025 and CH027 each found one real bug in the first scan, and one
|
|
300
|
+
real gap in the check.** CH025 (is-literal-comparison) flagged litellm's
|
|
301
|
+
`if "usage" in response_obj is not None:` - but for the wrong reason.
|
|
302
|
+
`ast.Compare` puts every operand and every op from a chained comparison
|
|
303
|
+
in one node; checking "is there a literal anywhere" and "is there an
|
|
304
|
+
`is`/`is not` anywhere" independently, without pairing each op with its
|
|
305
|
+
own adjacent operands, matched the string literal (paired with `in`)
|
|
306
|
+
against the wrong op (`is not`, actually comparing `response_obj` to the
|
|
307
|
+
allowed singleton `None`). Fixed by walking the chain as adjacent
|
|
308
|
+
`(left, op, right)` triples. CH027 (unwaited-subprocess) flagged dspy's
|
|
309
|
+
`process = subprocess.Popen(...)`, followed by `lm.process = process` -
|
|
310
|
+
a real hand-off to a different object, reaped later through a separate
|
|
311
|
+
`terminate_process(lm.process)` call, the same "stored as any object's
|
|
312
|
+
attribute" escape CH009/CH016/CH028 already needed. Added it.
|
|
313
|
+
|
|
230
314
|
**Two checks we built and did not ship.** `exception-chaining` (`except X
|
|
231
315
|
as e: raise Y(...)` with no `from e`, discarding the real traceback -
|
|
232
316
|
overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
|
|
@@ -282,12 +366,20 @@ codehound/
|
|
|
282
366
|
├── collections_abc_import.py (CH017)
|
|
283
367
|
├── removed_asyncio_task_methods.py (CH018)
|
|
284
368
|
├── removed_getargspec.py (CH019)
|
|
285
|
-
|
|
369
|
+
├── bare_except.py (CH020)
|
|
370
|
+
├── removed_stdlib_module.py (CH021)
|
|
371
|
+
├── asyncio_coroutine_decorator.py (CH022)
|
|
372
|
+
├── removed_stdlib_attribute.py (CH023)
|
|
373
|
+
├── unittest_deprecated_alias.py (CH024)
|
|
374
|
+
├── is_literal_comparison.py (CH025)
|
|
375
|
+
├── mutable_class_attribute.py (CH026)
|
|
376
|
+
├── unwaited_subprocess.py (CH027)
|
|
377
|
+
└── floating_timer.py (CH028)
|
|
286
378
|
```
|
|
287
379
|
|
|
288
380
|
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.
|
|
289
381
|
|
|
290
|
-
**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."
|
|
382
|
+
**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. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. 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."
|
|
291
383
|
|
|
292
384
|
---
|
|
293
385
|
|
|
@@ -317,6 +409,11 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
317
409
|
- [x] 20 checks — memory leaks (`lru_cache` on methods), floating processes,
|
|
318
410
|
discarded futures, unprotected locks, async properties, unclosed
|
|
319
411
|
sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
|
|
412
|
+
- [x] 22 checks — removed stdlib modules (`distutils`, PEP 594 "dead
|
|
413
|
+
batteries"), removed `@asyncio.coroutine` decorator — CH021-CH022
|
|
414
|
+
- [x] 28 checks — removed stdlib functions, deprecated unittest aliases,
|
|
415
|
+
`is`-literal comparisons, mutable class attributes, unwaited
|
|
416
|
+
subprocesses, floating timers — CH023-CH028
|
|
320
417
|
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
321
418
|
- [ ] Sync HTTP clients constructed inside async request handlers
|
|
322
419
|
- [ ] `--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-eight checks. Eight are each backed by a bug that was actually
|
|
4
|
+
found 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-CH028) 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.6.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
|
|
@@ -12,16 +13,23 @@ from codehound.checks.discarded_future import DiscardedFuture
|
|
|
12
13
|
from codehound.checks.floating_process import FloatingProcess
|
|
13
14
|
from codehound.checks.floating_task import FloatingTask
|
|
14
15
|
from codehound.checks.floating_thread import FloatingThread
|
|
16
|
+
from codehound.checks.floating_timer import FloatingTimer
|
|
15
17
|
from codehound.checks.get_event_loop import DeprecatedGetEventLoop
|
|
18
|
+
from codehound.checks.is_literal_comparison import IsLiteralComparison
|
|
16
19
|
from codehound.checks.loop_closure_capture import LoopClosureCapture
|
|
17
20
|
from codehound.checks.lru_cache_on_method import LruCacheOnMethod
|
|
21
|
+
from codehound.checks.mutable_class_attribute import MutableClassAttribute
|
|
18
22
|
from codehound.checks.mutable_defaults import MutableDefaultArgument
|
|
19
23
|
from codehound.checks.removed_asyncio_task_methods import RemovedAsyncioTaskMethods
|
|
20
24
|
from codehound.checks.removed_getargspec import RemovedGetargspec
|
|
25
|
+
from codehound.checks.removed_stdlib_attribute import RemovedStdlibAttribute
|
|
26
|
+
from codehound.checks.removed_stdlib_module import RemovedStdlibModule
|
|
21
27
|
from codehound.checks.resource_leak import UnclosedFileHandle
|
|
22
28
|
from codehound.checks.unawaited_coroutine import UnawaitedCoroutineCall
|
|
23
29
|
from codehound.checks.unclosed_socket import UnclosedSocket
|
|
30
|
+
from codehound.checks.unittest_deprecated_alias import UnittestDeprecatedAlias
|
|
24
31
|
from codehound.checks.unprotected_lock import UnprotectedLockAcquire
|
|
32
|
+
from codehound.checks.unwaited_subprocess import UnwaitedSubprocess
|
|
25
33
|
from codehound.core import Check
|
|
26
34
|
|
|
27
35
|
ALL_CHECKS: list[type[Check]] = [
|
|
@@ -45,6 +53,14 @@ ALL_CHECKS: list[type[Check]] = [
|
|
|
45
53
|
RemovedAsyncioTaskMethods,
|
|
46
54
|
RemovedGetargspec,
|
|
47
55
|
BareExcept,
|
|
56
|
+
RemovedStdlibModule,
|
|
57
|
+
AsyncioCoroutineDecorator,
|
|
58
|
+
RemovedStdlibAttribute,
|
|
59
|
+
UnittestDeprecatedAlias,
|
|
60
|
+
IsLiteralComparison,
|
|
61
|
+
MutableClassAttribute,
|
|
62
|
+
UnwaitedSubprocess,
|
|
63
|
+
FloatingTimer,
|
|
48
64
|
]
|
|
49
65
|
|
|
50
66
|
|
|
@@ -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
|