codehound 1.6.0__tar.gz → 1.7.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.6.0 → codehound-1.7.0}/PKG-INFO +49 -8
- {codehound-1.6.0 → codehound-1.7.0}/README.md +48 -7
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/__init__.py +3 -3
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/__init__.py +6 -0
- codehound-1.7.0/src/codehound/checks/finally_swallows_exception.py +146 -0
- codehound-1.7.0/src/codehound/checks/lru_cache_on_async_function.py +74 -0
- codehound-1.7.0/src/codehound/checks/unclosed_pool.py +142 -0
- {codehound-1.6.0 → codehound-1.7.0}/tests/test_checks.py +233 -0
- {codehound-1.6.0 → codehound-1.7.0}/.gitignore +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/LICENSE +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/pyproject.toml +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/async_property.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/asyncio_coroutine_decorator.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/bare_except.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/blocking_async.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/collections_abc_import.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/datetime_utcnow.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/discarded_future.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/floating_process.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/floating_task.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/floating_thread.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/floating_timer.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/get_event_loop.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/is_literal_comparison.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/loop_closure_capture.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/lru_cache_on_method.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/mutable_class_attribute.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/mutable_defaults.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/removed_getargspec.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/removed_stdlib_attribute.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/removed_stdlib_module.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/resource_leak.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/unclosed_socket.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/unittest_deprecated_alias.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/unprotected_lock.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/checks/unwaited_subprocess.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/cli.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/core.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/sarif.py +0 -0
- {codehound-1.6.0 → codehound-1.7.0}/src/codehound/terminal.py +0 -0
- {codehound-1.6.0 → codehound-1.7.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.7.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 —
|
|
27
|
+
**An AST-based static analyzer that hunts *real* bugs in large Python codebases — thirty-one 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/)
|
|
@@ -65,13 +65,27 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
|
|
|
65
65
|
|
|
66
66
|
---
|
|
67
67
|
|
|
68
|
+
## How this compares
|
|
69
|
+
|
|
70
|
+
Being upfront about overlap: `codehound` is not the only tool that catches some of these patterns, and pretending otherwise wouldn't survive five minutes of someone actually checking. [Ruff](https://docs.astral.sh/ruff/)'s `RUF006` already catches a discarded `asyncio.create_task()` (CH006), `flake8-async`'s `ASYNC300` predates it. Ruff's `RUF012` already catches mutable class-level defaults (CH026), `F632` catches `is`-literal comparisons (CH025), `B006`/`UP005`/`E722` cover mutable-default-arguments/deprecated-unittest-aliases/bare-except (CH002/CH024/CH020). Pylint's `W1518` (`method-cache-max-size-none`) is close to a name-for-name match for CH011's `lru_cache`-on-instance-method leak. If you already run ruff and pylint, several of `codehound`'s checks will feel familiar.
|
|
71
|
+
|
|
72
|
+
What actually seems to be missing elsewhere, as far as I've been able to find:
|
|
73
|
+
|
|
74
|
+
- **Floating threading/multiprocessing primitives.** `flake8-async`'s blocking-call rules (`ASYNC2xx`) and Ruff's `RUF006` cover `asyncio` specifically; I couldn't find any linter — Ruff, flake8-async, pylint, bandit — that flags a `threading.Thread`/`multiprocessing.Process`/`threading.Timer`/`multiprocessing.Pool` that's started but never joined, cancelled, or closed (CH009, CH012, CH028, CH031). This isn't a niche pattern; it's the exact same bug as the asyncio case, just one abstraction layer down, and nothing else checks for it.
|
|
75
|
+
- **Unawaited coroutines.** Calling an `async def` function as a bare statement — no `await`, no scheduling — silently drops the whole call (CH007). Ruff has an [open, unresolved issue](https://github.com/astral-sh/ruff/issues/9833) asking for exactly this; as of writing, nothing ships it.
|
|
76
|
+
- **Blocking calls to non-stdlib clients inside async functions.** `flake8-async`'s `ASYNC2xx` family only recognizes a fixed denylist (`requests`, `httpx`, `urllib3`, `subprocess`, `open`, `time.sleep`) — a synchronous call to a vector-DB client, an LLM SDK, or any other third-party library inside an `async def` isn't on anyone's list yet. CH001 is on that same denylist today; extending it to common AI/agent SDKs (the exact libraries the frameworks this tool is validated against actually use) is on the roadmap below.
|
|
77
|
+
|
|
78
|
+
And a difference in kind, not just coverage: every check here is checked against a real corpus, not just reasoned about. [`docs/FINDINGS.md`](docs/FINDINGS.md) has a running ledger of every false positive found while building each check (with the exact framework and line), and two checks that were built, measured, and **rejected outright** when the pattern turned out to be either too common to be a defensible finding (1,911 hits) or premised on something that was actually false (the first two real hits checked turned out to be correct code). I haven't found another static-analysis tool — commercial or open-source — that publishes this kind of "we built it, checked it against real code, and turned it down" ledger. Most tools that market themselves on "catches real bugs" (Greptile, Qodo, CodeRabbit) report an aggregate detection-rate benchmark, not per-rule provenance you can click through to an actual merged fix.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
68
82
|
## Install
|
|
69
83
|
|
|
70
84
|
```bash
|
|
71
85
|
pip install codehound
|
|
72
86
|
```
|
|
73
87
|
|
|
74
|
-
Zero dependencies — it's ~3,
|
|
88
|
+
Zero dependencies — it's ~3,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
89
|
|
|
76
90
|
<details>
|
|
77
91
|
<summary>From a clone instead (for development)</summary>
|
|
@@ -132,7 +146,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
|
|
|
132
146
|
```yaml
|
|
133
147
|
repos:
|
|
134
148
|
- repo: https://github.com/kratos0718/codehound
|
|
135
|
-
rev: v1.
|
|
149
|
+
rev: v1.7.0
|
|
136
150
|
hooks:
|
|
137
151
|
- id: codehound
|
|
138
152
|
```
|
|
@@ -171,10 +185,13 @@ repos:
|
|
|
171
185
|
| **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
186
|
| **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
187
|
| **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 |
|
|
188
|
+
| **CH029** | `finally-swallows-exception` | `return`/`break`/`continue` in a `finally:` block silently discards any exception from the `try:` — the caller never sees it. | hardening rule — real hits in letta |
|
|
189
|
+
| **CH030** | `lru-cache-on-async-function` | `@lru_cache`/`@cache` on `async def` caches the coroutine *object*, not its result — the second call with the same arguments crashes. | hardening rule |
|
|
190
|
+
| **CH031** | `unclosed-pool` | `multiprocessing.Pool()` never `.close()`d/`.terminate()`d — worker processes leak for the life of the parent. | hardening rule |
|
|
174
191
|
|
|
175
192
|
`codehound list` prints this from the source of truth.
|
|
176
193
|
|
|
177
|
-
CH007-
|
|
194
|
+
CH007-CH031 don't have found-and-merged bugs behind all of them the way
|
|
178
195
|
CH001-CH006 do - most are hardening rules for well-known Python
|
|
179
196
|
correctness gotchas rather than something this project personally
|
|
180
197
|
tracked down first. CH010 and CH011 are the exceptions: both found
|
|
@@ -331,6 +348,25 @@ a real hand-off to a different object, reaped later through a separate
|
|
|
331
348
|
`terminate_process(lm.process)` call, the same "stored as any object's
|
|
332
349
|
attribute" escape CH009/CH016/CH028 already needed. Added it.
|
|
333
350
|
|
|
351
|
+
**CH029 found a real bug in letta on day one, and its own precision gap
|
|
352
|
+
right after.** The first corpus scan flagged `except Exception as e:
|
|
353
|
+
result["error"] = str(e)` followed by `finally: return result` in
|
|
354
|
+
letta's job-callback dispatcher - but that except deliberately never
|
|
355
|
+
re-raises (the code comment says so directly: "callback failures should
|
|
356
|
+
not affect job completion"), so nothing is actually pending to swallow
|
|
357
|
+
by the time `finally` runs. Fixed by skipping a `try` whose `except`
|
|
358
|
+
clauses never re-raise anywhere in their own scope - there's nothing
|
|
359
|
+
live left to discard at that point. The same scan also turned up a
|
|
360
|
+
*more serious*, still-real instance in two of letta's LLM streaming
|
|
361
|
+
adapters: `except BaseException: <log, then re-raise a typed error>`
|
|
362
|
+
immediately followed by `finally: if not stream_started: return` -
|
|
363
|
+
except `stream_started` is unconditionally set `True` a few lines
|
|
364
|
+
earlier with no other assignment anywhere in the function, so today
|
|
365
|
+
that specific `return` is dead code, not a currently-live swallow. Read
|
|
366
|
+
carefully rather than assumed, and not filed as a bug report since it
|
|
367
|
+
isn't actually firing right now - but flagged as exactly the kind of
|
|
368
|
+
fragile code a future refactor could silently turn into a real one.
|
|
369
|
+
|
|
334
370
|
**Two checks we built and did not ship.** `exception-chaining` (`except X
|
|
335
371
|
as e: raise Y(...)` with no `from e`, discarding the real traceback -
|
|
336
372
|
overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
|
|
@@ -394,12 +430,15 @@ codehound/
|
|
|
394
430
|
├── is_literal_comparison.py (CH025)
|
|
395
431
|
├── mutable_class_attribute.py (CH026)
|
|
396
432
|
├── unwaited_subprocess.py (CH027)
|
|
397
|
-
|
|
433
|
+
├── floating_timer.py (CH028)
|
|
434
|
+
├── finally_swallows_exception.py (CH029)
|
|
435
|
+
├── lru_cache_on_async_function.py (CH030)
|
|
436
|
+
└── unclosed_pool.py (CH031)
|
|
398
437
|
```
|
|
399
438
|
|
|
400
439
|
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.
|
|
401
440
|
|
|
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."
|
|
441
|
+
**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. CH029 skips a `try` whose `except` clauses never re-raise anywhere in their own scope — a real pattern in letta where the exception is deliberately logged and recorded, never propagated, so a `return` in `finally` isn't discarding anything live. 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."
|
|
403
442
|
|
|
404
443
|
---
|
|
405
444
|
|
|
@@ -434,8 +473,10 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
434
473
|
- [x] 28 checks — removed stdlib functions, deprecated unittest aliases,
|
|
435
474
|
`is`-literal comparisons, mutable class attributes, unwaited
|
|
436
475
|
subprocesses, floating timers — CH023-CH028
|
|
476
|
+
- [x] 31 checks — `finally:` blocks that swallow exceptions, `lru_cache`
|
|
477
|
+
on async functions, unclosed `multiprocessing.Pool` — CH029-CH031
|
|
437
478
|
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
438
|
-
- [ ]
|
|
479
|
+
- [ ] Extend CH001 to a curated denylist of sync AI/agent SDK client calls inside async functions (vector-DB clients, LLM SDKs) — the gap flake8-async's stdlib-only denylist leaves open
|
|
439
480
|
- [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
|
|
440
481
|
|
|
441
482
|
---
|
|
@@ -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 —
|
|
7
|
+
**An AST-based static analyzer that hunts *real* bugs in large Python codebases — thirty-one 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/)
|
|
@@ -45,13 +45,27 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
|
|
|
45
45
|
|
|
46
46
|
---
|
|
47
47
|
|
|
48
|
+
## How this compares
|
|
49
|
+
|
|
50
|
+
Being upfront about overlap: `codehound` is not the only tool that catches some of these patterns, and pretending otherwise wouldn't survive five minutes of someone actually checking. [Ruff](https://docs.astral.sh/ruff/)'s `RUF006` already catches a discarded `asyncio.create_task()` (CH006), `flake8-async`'s `ASYNC300` predates it. Ruff's `RUF012` already catches mutable class-level defaults (CH026), `F632` catches `is`-literal comparisons (CH025), `B006`/`UP005`/`E722` cover mutable-default-arguments/deprecated-unittest-aliases/bare-except (CH002/CH024/CH020). Pylint's `W1518` (`method-cache-max-size-none`) is close to a name-for-name match for CH011's `lru_cache`-on-instance-method leak. If you already run ruff and pylint, several of `codehound`'s checks will feel familiar.
|
|
51
|
+
|
|
52
|
+
What actually seems to be missing elsewhere, as far as I've been able to find:
|
|
53
|
+
|
|
54
|
+
- **Floating threading/multiprocessing primitives.** `flake8-async`'s blocking-call rules (`ASYNC2xx`) and Ruff's `RUF006` cover `asyncio` specifically; I couldn't find any linter — Ruff, flake8-async, pylint, bandit — that flags a `threading.Thread`/`multiprocessing.Process`/`threading.Timer`/`multiprocessing.Pool` that's started but never joined, cancelled, or closed (CH009, CH012, CH028, CH031). This isn't a niche pattern; it's the exact same bug as the asyncio case, just one abstraction layer down, and nothing else checks for it.
|
|
55
|
+
- **Unawaited coroutines.** Calling an `async def` function as a bare statement — no `await`, no scheduling — silently drops the whole call (CH007). Ruff has an [open, unresolved issue](https://github.com/astral-sh/ruff/issues/9833) asking for exactly this; as of writing, nothing ships it.
|
|
56
|
+
- **Blocking calls to non-stdlib clients inside async functions.** `flake8-async`'s `ASYNC2xx` family only recognizes a fixed denylist (`requests`, `httpx`, `urllib3`, `subprocess`, `open`, `time.sleep`) — a synchronous call to a vector-DB client, an LLM SDK, or any other third-party library inside an `async def` isn't on anyone's list yet. CH001 is on that same denylist today; extending it to common AI/agent SDKs (the exact libraries the frameworks this tool is validated against actually use) is on the roadmap below.
|
|
57
|
+
|
|
58
|
+
And a difference in kind, not just coverage: every check here is checked against a real corpus, not just reasoned about. [`docs/FINDINGS.md`](docs/FINDINGS.md) has a running ledger of every false positive found while building each check (with the exact framework and line), and two checks that were built, measured, and **rejected outright** when the pattern turned out to be either too common to be a defensible finding (1,911 hits) or premised on something that was actually false (the first two real hits checked turned out to be correct code). I haven't found another static-analysis tool — commercial or open-source — that publishes this kind of "we built it, checked it against real code, and turned it down" ledger. Most tools that market themselves on "catches real bugs" (Greptile, Qodo, CodeRabbit) report an aggregate detection-rate benchmark, not per-rule provenance you can click through to an actual merged fix.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
48
62
|
## Install
|
|
49
63
|
|
|
50
64
|
```bash
|
|
51
65
|
pip install codehound
|
|
52
66
|
```
|
|
53
67
|
|
|
54
|
-
Zero dependencies — it's ~3,
|
|
68
|
+
Zero dependencies — it's ~3,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
69
|
|
|
56
70
|
<details>
|
|
57
71
|
<summary>From a clone instead (for development)</summary>
|
|
@@ -112,7 +126,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
|
|
|
112
126
|
```yaml
|
|
113
127
|
repos:
|
|
114
128
|
- repo: https://github.com/kratos0718/codehound
|
|
115
|
-
rev: v1.
|
|
129
|
+
rev: v1.7.0
|
|
116
130
|
hooks:
|
|
117
131
|
- id: codehound
|
|
118
132
|
```
|
|
@@ -151,10 +165,13 @@ repos:
|
|
|
151
165
|
| **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
166
|
| **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
167
|
| **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 |
|
|
168
|
+
| **CH029** | `finally-swallows-exception` | `return`/`break`/`continue` in a `finally:` block silently discards any exception from the `try:` — the caller never sees it. | hardening rule — real hits in letta |
|
|
169
|
+
| **CH030** | `lru-cache-on-async-function` | `@lru_cache`/`@cache` on `async def` caches the coroutine *object*, not its result — the second call with the same arguments crashes. | hardening rule |
|
|
170
|
+
| **CH031** | `unclosed-pool` | `multiprocessing.Pool()` never `.close()`d/`.terminate()`d — worker processes leak for the life of the parent. | hardening rule |
|
|
154
171
|
|
|
155
172
|
`codehound list` prints this from the source of truth.
|
|
156
173
|
|
|
157
|
-
CH007-
|
|
174
|
+
CH007-CH031 don't have found-and-merged bugs behind all of them the way
|
|
158
175
|
CH001-CH006 do - most are hardening rules for well-known Python
|
|
159
176
|
correctness gotchas rather than something this project personally
|
|
160
177
|
tracked down first. CH010 and CH011 are the exceptions: both found
|
|
@@ -311,6 +328,25 @@ a real hand-off to a different object, reaped later through a separate
|
|
|
311
328
|
`terminate_process(lm.process)` call, the same "stored as any object's
|
|
312
329
|
attribute" escape CH009/CH016/CH028 already needed. Added it.
|
|
313
330
|
|
|
331
|
+
**CH029 found a real bug in letta on day one, and its own precision gap
|
|
332
|
+
right after.** The first corpus scan flagged `except Exception as e:
|
|
333
|
+
result["error"] = str(e)` followed by `finally: return result` in
|
|
334
|
+
letta's job-callback dispatcher - but that except deliberately never
|
|
335
|
+
re-raises (the code comment says so directly: "callback failures should
|
|
336
|
+
not affect job completion"), so nothing is actually pending to swallow
|
|
337
|
+
by the time `finally` runs. Fixed by skipping a `try` whose `except`
|
|
338
|
+
clauses never re-raise anywhere in their own scope - there's nothing
|
|
339
|
+
live left to discard at that point. The same scan also turned up a
|
|
340
|
+
*more serious*, still-real instance in two of letta's LLM streaming
|
|
341
|
+
adapters: `except BaseException: <log, then re-raise a typed error>`
|
|
342
|
+
immediately followed by `finally: if not stream_started: return` -
|
|
343
|
+
except `stream_started` is unconditionally set `True` a few lines
|
|
344
|
+
earlier with no other assignment anywhere in the function, so today
|
|
345
|
+
that specific `return` is dead code, not a currently-live swallow. Read
|
|
346
|
+
carefully rather than assumed, and not filed as a bug report since it
|
|
347
|
+
isn't actually firing right now - but flagged as exactly the kind of
|
|
348
|
+
fragile code a future refactor could silently turn into a real one.
|
|
349
|
+
|
|
314
350
|
**Two checks we built and did not ship.** `exception-chaining` (`except X
|
|
315
351
|
as e: raise Y(...)` with no `from e`, discarding the real traceback -
|
|
316
352
|
overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
|
|
@@ -374,12 +410,15 @@ codehound/
|
|
|
374
410
|
├── is_literal_comparison.py (CH025)
|
|
375
411
|
├── mutable_class_attribute.py (CH026)
|
|
376
412
|
├── unwaited_subprocess.py (CH027)
|
|
377
|
-
|
|
413
|
+
├── floating_timer.py (CH028)
|
|
414
|
+
├── finally_swallows_exception.py (CH029)
|
|
415
|
+
├── lru_cache_on_async_function.py (CH030)
|
|
416
|
+
└── unclosed_pool.py (CH031)
|
|
378
417
|
```
|
|
379
418
|
|
|
380
419
|
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.
|
|
381
420
|
|
|
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."
|
|
421
|
+
**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. CH029 skips a `try` whose `except` clauses never re-raise anywhere in their own scope — a real pattern in letta where the exception is deliberately logged and recorded, never propagated, so a `return` in `finally` isn't discarding anything live. 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."
|
|
383
422
|
|
|
384
423
|
---
|
|
385
424
|
|
|
@@ -414,8 +453,10 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
414
453
|
- [x] 28 checks — removed stdlib functions, deprecated unittest aliases,
|
|
415
454
|
`is`-literal comparisons, mutable class attributes, unwaited
|
|
416
455
|
subprocesses, floating timers — CH023-CH028
|
|
456
|
+
- [x] 31 checks — `finally:` blocks that swallow exceptions, `lru_cache`
|
|
457
|
+
on async functions, unclosed `multiprocessing.Pool` — CH029-CH031
|
|
417
458
|
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
418
|
-
- [ ]
|
|
459
|
+
- [ ] Extend CH001 to a curated denylist of sync AI/agent SDK client calls inside async functions (vector-DB clients, LLM SDKs) — the gap flake8-async's stdlib-only denylist leaves open
|
|
419
460
|
- [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
|
|
420
461
|
|
|
421
462
|
---
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""codehound - an AST-based static analyzer that hunts real bugs in Python code.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Thirty-one checks. Eight are each backed by a bug that was actually
|
|
4
4
|
found and fixed (or opened as a PR) in a popular open-source AI framework
|
|
5
5
|
(agno, crewAI, mem0, llama_index, accelerate, optuna, litellm). The rest
|
|
6
|
-
(CH007-CH009, CH012-
|
|
6
|
+
(CH007-CH009, CH012-CH031) are hardening rules verified against real
|
|
7
7
|
false positives across a ~29-framework validation corpus instead - see
|
|
8
8
|
docs/FINDINGS.md.
|
|
9
9
|
"""
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
from codehound.checks import ALL_CHECKS, get_checks
|
|
14
14
|
from codehound.core import Check, Finding, scan_file, scan_path
|
|
15
15
|
|
|
16
|
-
__version__ = "1.
|
|
16
|
+
__version__ = "1.7.0"
|
|
17
17
|
|
|
18
18
|
__all__ = [
|
|
19
19
|
"ALL_CHECKS",
|
|
@@ -10,6 +10,7 @@ from codehound.checks.blocking_async import BlockingCallInAsync
|
|
|
10
10
|
from codehound.checks.collections_abc_import import CollectionsAbcImport
|
|
11
11
|
from codehound.checks.datetime_utcnow import DeprecatedDatetimeUtcnow
|
|
12
12
|
from codehound.checks.discarded_future import DiscardedFuture
|
|
13
|
+
from codehound.checks.finally_swallows_exception import FinallySwallowsException
|
|
13
14
|
from codehound.checks.floating_process import FloatingProcess
|
|
14
15
|
from codehound.checks.floating_task import FloatingTask
|
|
15
16
|
from codehound.checks.floating_thread import FloatingThread
|
|
@@ -17,6 +18,7 @@ from codehound.checks.floating_timer import FloatingTimer
|
|
|
17
18
|
from codehound.checks.get_event_loop import DeprecatedGetEventLoop
|
|
18
19
|
from codehound.checks.is_literal_comparison import IsLiteralComparison
|
|
19
20
|
from codehound.checks.loop_closure_capture import LoopClosureCapture
|
|
21
|
+
from codehound.checks.lru_cache_on_async_function import LruCacheOnAsyncFunction
|
|
20
22
|
from codehound.checks.lru_cache_on_method import LruCacheOnMethod
|
|
21
23
|
from codehound.checks.mutable_class_attribute import MutableClassAttribute
|
|
22
24
|
from codehound.checks.mutable_defaults import MutableDefaultArgument
|
|
@@ -26,6 +28,7 @@ from codehound.checks.removed_stdlib_attribute import RemovedStdlibAttribute
|
|
|
26
28
|
from codehound.checks.removed_stdlib_module import RemovedStdlibModule
|
|
27
29
|
from codehound.checks.resource_leak import UnclosedFileHandle
|
|
28
30
|
from codehound.checks.unawaited_coroutine import UnawaitedCoroutineCall
|
|
31
|
+
from codehound.checks.unclosed_pool import UnclosedPool
|
|
29
32
|
from codehound.checks.unclosed_socket import UnclosedSocket
|
|
30
33
|
from codehound.checks.unittest_deprecated_alias import UnittestDeprecatedAlias
|
|
31
34
|
from codehound.checks.unprotected_lock import UnprotectedLockAcquire
|
|
@@ -61,6 +64,9 @@ ALL_CHECKS: list[type[Check]] = [
|
|
|
61
64
|
MutableClassAttribute,
|
|
62
65
|
UnwaitedSubprocess,
|
|
63
66
|
FloatingTimer,
|
|
67
|
+
FinallySwallowsException,
|
|
68
|
+
LruCacheOnAsyncFunction,
|
|
69
|
+
UnclosedPool,
|
|
64
70
|
]
|
|
65
71
|
|
|
66
72
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""CH029 - ``return``/``break``/``continue`` in a ``finally:`` block silently drops any pending exception.
|
|
2
|
+
|
|
3
|
+
If the `try:` body raises, and the matching `finally:` block runs a
|
|
4
|
+
`return`, or a `break`/`continue` that jumps to a loop outside the
|
|
5
|
+
`finally` block itself, the exception is discarded entirely - not
|
|
6
|
+
logged, not re-raised, nothing. Verified directly:
|
|
7
|
+
|
|
8
|
+
def f():
|
|
9
|
+
try:
|
|
10
|
+
raise ValueError("boom")
|
|
11
|
+
finally:
|
|
12
|
+
return 5
|
|
13
|
+
|
|
14
|
+
`f()` returns `5`. The `ValueError` never happens as far as the caller
|
|
15
|
+
can tell. This is documented CPython behavior, not a bug in Python
|
|
16
|
+
itself, but it means an exception can vanish from a `finally:` block
|
|
17
|
+
that looks like ordinary cleanup code, with nothing in the syntax
|
|
18
|
+
calling out that it swallows errors - pylint's own `lost-exception`
|
|
19
|
+
(W0150) exists for exactly this.
|
|
20
|
+
|
|
21
|
+
A `break`/`continue` fully contained within a loop that itself lives
|
|
22
|
+
inside the `finally:` block is a different, local loop and does not
|
|
23
|
+
escape - only a `break`/`continue` whose target loop is *outside* the
|
|
24
|
+
`finally:` block (an ancestor of the whole `try` statement) swallows
|
|
25
|
+
the pending exception. Verified directly for both shapes.
|
|
26
|
+
|
|
27
|
+
Not flagged when the `try` has at least one `except` clause and none of
|
|
28
|
+
them ever re-raise: at that point every exception the `try` body could
|
|
29
|
+
produce has already been fully handled by the time control reaches
|
|
30
|
+
`finally`, so a `return`/`break`/`continue` there is just the
|
|
31
|
+
function's normal exit for the already-handled path, not a swallow.
|
|
32
|
+
Real pattern found in letta: `except Exception as e: result["error"] =
|
|
33
|
+
str(e)` (logged, recorded, deliberately not re-raised, the docstring
|
|
34
|
+
literally says "callback failures should not affect job completion")
|
|
35
|
+
followed by `finally: return result` - nothing is pending to discard.
|
|
36
|
+
A `try` with *no* `except` at all is unaffected by this guard, since
|
|
37
|
+
nothing there could have absorbed anything.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import ast
|
|
43
|
+
|
|
44
|
+
from codehound.core import Check, Finding
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _has_escaping_return(stmts: list[ast.stmt]) -> ast.Return | None:
|
|
48
|
+
def walk(node: ast.AST) -> ast.Return | None:
|
|
49
|
+
if isinstance(node, ast.Return):
|
|
50
|
+
return node
|
|
51
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
52
|
+
return None
|
|
53
|
+
for child in ast.iter_child_nodes(node):
|
|
54
|
+
found = walk(child)
|
|
55
|
+
if found is not None:
|
|
56
|
+
return found
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
for stmt in stmts:
|
|
60
|
+
found = walk(stmt)
|
|
61
|
+
if found is not None:
|
|
62
|
+
return found
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _owning_loop_is_outside_finally(node: ast.AST, try_node: ast.Try, parents: dict) -> bool:
|
|
67
|
+
cur: ast.AST | None = node
|
|
68
|
+
while cur is not None and cur is not try_node:
|
|
69
|
+
if isinstance(cur, (ast.For, ast.AsyncFor, ast.While)):
|
|
70
|
+
return False
|
|
71
|
+
cur = parents.get(id(cur))
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _find_escaping_break_or_continue(
|
|
76
|
+
stmts: list[ast.stmt], try_node: ast.Try, parents: dict
|
|
77
|
+
) -> ast.stmt | None:
|
|
78
|
+
for stmt in stmts:
|
|
79
|
+
for node in ast.walk(stmt):
|
|
80
|
+
if isinstance(node, (ast.Break, ast.Continue)) and _owning_loop_is_outside_finally(
|
|
81
|
+
node, try_node, parents
|
|
82
|
+
):
|
|
83
|
+
return node
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _has_raise_in_own_scope(handler: ast.ExceptHandler) -> bool:
|
|
88
|
+
"""Any `raise` in the handler's own reachable body - not counting a
|
|
89
|
+
nested try/except's own handler, which is a different scope."""
|
|
90
|
+
found = False
|
|
91
|
+
|
|
92
|
+
def walk(node: ast.AST) -> None:
|
|
93
|
+
nonlocal found
|
|
94
|
+
if found:
|
|
95
|
+
return
|
|
96
|
+
for child in ast.iter_child_nodes(node):
|
|
97
|
+
if isinstance(child, ast.Raise):
|
|
98
|
+
found = True
|
|
99
|
+
return
|
|
100
|
+
if isinstance(child, ast.ExceptHandler):
|
|
101
|
+
continue
|
|
102
|
+
walk(child)
|
|
103
|
+
|
|
104
|
+
walk(handler)
|
|
105
|
+
return found
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _fully_absorbs_exceptions(try_node: ast.Try) -> bool:
|
|
109
|
+
if not try_node.handlers:
|
|
110
|
+
return False
|
|
111
|
+
return all(not _has_raise_in_own_scope(h) for h in try_node.handlers)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class FinallySwallowsException(Check):
|
|
115
|
+
code = "CH029"
|
|
116
|
+
name = "finally-swallows-exception"
|
|
117
|
+
description = "return/break/continue in a finally: block silently discards any exception from the try:."
|
|
118
|
+
|
|
119
|
+
def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
|
|
120
|
+
findings: list[Finding] = []
|
|
121
|
+
for node in ast.walk(tree):
|
|
122
|
+
if not isinstance(node, ast.Try) or not node.finalbody:
|
|
123
|
+
continue
|
|
124
|
+
if _fully_absorbs_exceptions(node):
|
|
125
|
+
continue
|
|
126
|
+
culprit: ast.stmt | None = _has_escaping_return(node.finalbody)
|
|
127
|
+
kind = "return"
|
|
128
|
+
if culprit is None:
|
|
129
|
+
culprit = _find_escaping_break_or_continue(node.finalbody, node, parents)
|
|
130
|
+
kind = "break" if isinstance(culprit, ast.Break) else "continue"
|
|
131
|
+
if culprit is None:
|
|
132
|
+
continue
|
|
133
|
+
findings.append(
|
|
134
|
+
Finding(
|
|
135
|
+
path=path,
|
|
136
|
+
line=culprit.lineno,
|
|
137
|
+
col=culprit.col_offset,
|
|
138
|
+
code=self.code,
|
|
139
|
+
message=(
|
|
140
|
+
f"`{kind}` in this `finally:` block silently discards any exception "
|
|
141
|
+
f"raised in the `try:` body - the caller never sees it. Move the "
|
|
142
|
+
f"{kind} out of `finally:`, or re-raise explicitly if that's intended."
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return findings
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""CH030 - ``@lru_cache``/``@cache`` on an ``async def`` caches the coroutine object, not its result.
|
|
2
|
+
|
|
3
|
+
`functools.lru_cache` has no idea what a coroutine is - it just
|
|
4
|
+
memoizes whatever the decorated callable returns for a given set of
|
|
5
|
+
arguments. Calling an `async def` function doesn't run its body at all;
|
|
6
|
+
it just constructs a coroutine object. So `lru_cache` ends up caching
|
|
7
|
+
*that object*, and returns the exact same one on every call with the
|
|
8
|
+
same arguments - including the second, third, and every later one.
|
|
9
|
+
Verified directly:
|
|
10
|
+
|
|
11
|
+
@lru_cache
|
|
12
|
+
async def fetch(x):
|
|
13
|
+
return x * 2
|
|
14
|
+
|
|
15
|
+
async def main():
|
|
16
|
+
await fetch(1)
|
|
17
|
+
await fetch(1) # RuntimeError: cannot reuse already awaited coroutine
|
|
18
|
+
|
|
19
|
+
The second `await` crashes immediately, every time, for any arguments
|
|
20
|
+
that repeat - unlike CH011 (`lru_cache` leaking `self` on a *sync*
|
|
21
|
+
instance method), this is a correctness bug on the very next call, not
|
|
22
|
+
a slow memory leak, and it fires on module-level functions too, not
|
|
23
|
+
just methods.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import ast
|
|
29
|
+
|
|
30
|
+
from codehound.core import Check, Finding
|
|
31
|
+
|
|
32
|
+
_CACHE_DECORATOR_NAMES = {"lru_cache", "cache"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_cache_decorator(node: ast.expr) -> bool:
|
|
36
|
+
target = node.func if isinstance(node, ast.Call) else node
|
|
37
|
+
if isinstance(target, ast.Name):
|
|
38
|
+
return target.id in _CACHE_DECORATOR_NAMES
|
|
39
|
+
if isinstance(target, ast.Attribute):
|
|
40
|
+
return target.attr in _CACHE_DECORATOR_NAMES
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LruCacheOnAsyncFunction(Check):
|
|
45
|
+
code = "CH030"
|
|
46
|
+
name = "lru-cache-on-async-function"
|
|
47
|
+
description = "@lru_cache/@cache on an async def caches the coroutine object, not its awaited result."
|
|
48
|
+
|
|
49
|
+
def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
|
|
50
|
+
findings: list[Finding] = []
|
|
51
|
+
for node in ast.walk(tree):
|
|
52
|
+
if not isinstance(node, ast.AsyncFunctionDef):
|
|
53
|
+
continue
|
|
54
|
+
for dec in node.decorator_list:
|
|
55
|
+
if not _is_cache_decorator(dec):
|
|
56
|
+
continue
|
|
57
|
+
findings.append(
|
|
58
|
+
Finding(
|
|
59
|
+
path=path,
|
|
60
|
+
line=node.lineno,
|
|
61
|
+
col=node.col_offset,
|
|
62
|
+
code=self.code,
|
|
63
|
+
message=(
|
|
64
|
+
f"`{node.name}` is `async def` decorated with a cache that has no "
|
|
65
|
+
f"idea what a coroutine is - it caches the coroutine *object*, not "
|
|
66
|
+
f"its awaited result. The second call with the same arguments "
|
|
67
|
+
f"raises `RuntimeError: cannot reuse already awaited coroutine`. "
|
|
68
|
+
f"Cache the result inside the function body instead, or use a "
|
|
69
|
+
f"cache designed for coroutines (e.g. `asyncache`'s `@cached`)."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
break
|
|
74
|
+
return findings
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""CH031 - ``multiprocessing.Pool()`` never closed or terminated.
|
|
2
|
+
|
|
3
|
+
A `Pool` starts its worker processes at construction time. Until
|
|
4
|
+
`.close()` (graceful, finishes queued work first) or `.terminate()`
|
|
5
|
+
(immediate) is called - and normally `.join()` after `.close()` to
|
|
6
|
+
reap them - those worker processes keep running, each holding its own
|
|
7
|
+
copy of the parent's memory, for as long as the parent process lives.
|
|
8
|
+
Same resource-leak shape as CH027's `subprocess.Popen`, CH009's
|
|
9
|
+
`threading.Thread`, and CH012's `multiprocessing.Process`, just for a
|
|
10
|
+
whole pool of them at once, and Python's own docs recommend the
|
|
11
|
+
context-manager form specifically to avoid this.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
|
|
18
|
+
from codehound.core import Check, Finding, enclosing_function, inside_with_statement
|
|
19
|
+
|
|
20
|
+
_RELEASES = {"close", "terminate"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_pool_call(node: ast.expr) -> bool:
|
|
24
|
+
if not isinstance(node, ast.Call):
|
|
25
|
+
return False
|
|
26
|
+
func = node.func
|
|
27
|
+
if isinstance(func, ast.Attribute):
|
|
28
|
+
return func.attr == "Pool" and isinstance(func.value, ast.Name) and func.value.id in (
|
|
29
|
+
"multiprocessing",
|
|
30
|
+
"mp",
|
|
31
|
+
)
|
|
32
|
+
if isinstance(func, ast.Name):
|
|
33
|
+
return func.id == "Pool"
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _name_targets(target: ast.expr) -> list[str]:
|
|
38
|
+
if isinstance(target, ast.Name):
|
|
39
|
+
return [target.id]
|
|
40
|
+
if isinstance(target, (ast.Tuple, ast.List)):
|
|
41
|
+
out: list[str] = []
|
|
42
|
+
for elt in target.elts:
|
|
43
|
+
out.extend(_name_targets(elt))
|
|
44
|
+
return out
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _return_targets(value: ast.expr | None) -> list[str]:
|
|
49
|
+
if value is None:
|
|
50
|
+
return []
|
|
51
|
+
if isinstance(value, ast.Name):
|
|
52
|
+
return [value.id]
|
|
53
|
+
if isinstance(value, (ast.Tuple, ast.List)):
|
|
54
|
+
out: list[str] = []
|
|
55
|
+
for elt in value.elts:
|
|
56
|
+
out.extend(_return_targets(elt))
|
|
57
|
+
return out
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _has_release_call(scope: ast.AST, name: str) -> bool:
|
|
62
|
+
for node in ast.walk(scope):
|
|
63
|
+
if (
|
|
64
|
+
isinstance(node, ast.Call)
|
|
65
|
+
and isinstance(node.func, ast.Attribute)
|
|
66
|
+
and node.func.attr in _RELEASES
|
|
67
|
+
and isinstance(node.func.value, ast.Name)
|
|
68
|
+
and node.func.value.id == name
|
|
69
|
+
):
|
|
70
|
+
return True
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_passed_as_argument(scope: ast.AST, name: str) -> bool:
|
|
75
|
+
for node in ast.walk(scope):
|
|
76
|
+
if not isinstance(node, ast.Call):
|
|
77
|
+
continue
|
|
78
|
+
for arg in node.args:
|
|
79
|
+
if isinstance(arg, ast.Name) and arg.id == name:
|
|
80
|
+
return True
|
|
81
|
+
for kw in node.keywords:
|
|
82
|
+
if isinstance(kw.value, ast.Name) and kw.value.id == name:
|
|
83
|
+
return True
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_stored_as_attribute(scope: ast.AST, name: str) -> bool:
|
|
88
|
+
for node in ast.walk(scope):
|
|
89
|
+
if not isinstance(node, ast.Assign):
|
|
90
|
+
continue
|
|
91
|
+
if isinstance(node.value, ast.Name) and node.value.id == name:
|
|
92
|
+
if any(isinstance(tgt, ast.Attribute) for tgt in node.targets):
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class UnclosedPool(Check):
|
|
98
|
+
code = "CH031"
|
|
99
|
+
name = "unclosed-pool"
|
|
100
|
+
description = "multiprocessing.Pool() never closed or terminated; worker processes leak."
|
|
101
|
+
|
|
102
|
+
def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
|
|
103
|
+
findings: list[Finding] = []
|
|
104
|
+
for node in ast.walk(tree):
|
|
105
|
+
if not isinstance(node, ast.Assign) or not _is_pool_call(node.value):
|
|
106
|
+
continue
|
|
107
|
+
if inside_with_statement(node, parents):
|
|
108
|
+
continue
|
|
109
|
+
names: list[str] = []
|
|
110
|
+
for tgt in node.targets:
|
|
111
|
+
names.extend(_name_targets(tgt))
|
|
112
|
+
if not names:
|
|
113
|
+
continue
|
|
114
|
+
fn = enclosing_function(node, parents)
|
|
115
|
+
if fn is None:
|
|
116
|
+
continue
|
|
117
|
+
returned_names = {
|
|
118
|
+
n for r in ast.walk(fn) if isinstance(r, ast.Return) for n in _return_targets(r.value)
|
|
119
|
+
}
|
|
120
|
+
for name in names:
|
|
121
|
+
if (
|
|
122
|
+
name in returned_names
|
|
123
|
+
or _has_release_call(fn, name)
|
|
124
|
+
or _is_passed_as_argument(fn, name)
|
|
125
|
+
or _is_stored_as_attribute(fn, name)
|
|
126
|
+
):
|
|
127
|
+
continue
|
|
128
|
+
findings.append(
|
|
129
|
+
Finding(
|
|
130
|
+
path=path,
|
|
131
|
+
line=node.lineno,
|
|
132
|
+
col=node.col_offset,
|
|
133
|
+
code=self.code,
|
|
134
|
+
message=(
|
|
135
|
+
f"`{name} = multiprocessing.Pool(...)` in `{fn.name}` is never "
|
|
136
|
+
f"closed or terminated; its worker processes keep running for the "
|
|
137
|
+
f"life of the parent. Call `{name}.close()`/`.join()` (or "
|
|
138
|
+
f"`.terminate()`), or use `with multiprocessing.Pool(...) as {name}:`."
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return findings
|
|
@@ -1474,3 +1474,236 @@ def test_ch028_ignores_daemon_set_post_construction():
|
|
|
1474
1474
|
)
|
|
1475
1475
|
assert _run(code, ["CH028"]) == []
|
|
1476
1476
|
|
|
1477
|
+
|
|
1478
|
+
# --- CH029 finally-swallows-exception --------------------------------------------------
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
def test_ch029_flags_return_in_finally():
|
|
1482
|
+
code = "def f():\n try:\n raise ValueError()\n finally:\n return 5\n"
|
|
1483
|
+
findings = _run(code, ["CH029"])
|
|
1484
|
+
assert len(findings) == 1
|
|
1485
|
+
assert findings[0].code == "CH029"
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def test_ch029_flags_break_escaping_finally():
|
|
1489
|
+
code = (
|
|
1490
|
+
"def f():\n"
|
|
1491
|
+
" for i in range(3):\n"
|
|
1492
|
+
" try:\n"
|
|
1493
|
+
" raise ValueError()\n"
|
|
1494
|
+
" finally:\n"
|
|
1495
|
+
" break\n"
|
|
1496
|
+
)
|
|
1497
|
+
findings = _run(code, ["CH029"])
|
|
1498
|
+
assert len(findings) == 1
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
def test_ch029_flags_continue_escaping_finally():
|
|
1502
|
+
code = (
|
|
1503
|
+
"def f():\n"
|
|
1504
|
+
" for i in range(3):\n"
|
|
1505
|
+
" try:\n"
|
|
1506
|
+
" raise ValueError()\n"
|
|
1507
|
+
" finally:\n"
|
|
1508
|
+
" continue\n"
|
|
1509
|
+
)
|
|
1510
|
+
findings = _run(code, ["CH029"])
|
|
1511
|
+
assert len(findings) == 1
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
def test_ch029_ignores_break_local_to_a_loop_inside_finally():
|
|
1515
|
+
# A break inside a loop that itself lives entirely within the
|
|
1516
|
+
# finally block is local to that loop and does not escape - the
|
|
1517
|
+
# pending exception still propagates correctly.
|
|
1518
|
+
code = (
|
|
1519
|
+
"def f():\n"
|
|
1520
|
+
" try:\n"
|
|
1521
|
+
" raise ValueError()\n"
|
|
1522
|
+
" finally:\n"
|
|
1523
|
+
" for i in range(1):\n"
|
|
1524
|
+
" break\n"
|
|
1525
|
+
)
|
|
1526
|
+
assert _run(code, ["CH029"]) == []
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def test_ch029_ignores_normal_finally_cleanup():
|
|
1530
|
+
code = (
|
|
1531
|
+
"def f():\n"
|
|
1532
|
+
" try:\n"
|
|
1533
|
+
" risky()\n"
|
|
1534
|
+
" finally:\n"
|
|
1535
|
+
" cleanup()\n"
|
|
1536
|
+
)
|
|
1537
|
+
assert _run(code, ["CH029"]) == []
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def test_ch029_ignores_return_in_try_body():
|
|
1541
|
+
code = "def f():\n try:\n return 1\n finally:\n cleanup()\n"
|
|
1542
|
+
assert _run(code, ["CH029"]) == []
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
def test_ch029_ignores_return_in_nested_function_inside_finally():
|
|
1546
|
+
code = (
|
|
1547
|
+
"def f():\n"
|
|
1548
|
+
" try:\n"
|
|
1549
|
+
" raise ValueError()\n"
|
|
1550
|
+
" finally:\n"
|
|
1551
|
+
" def helper():\n"
|
|
1552
|
+
" return 1\n"
|
|
1553
|
+
" helper()\n"
|
|
1554
|
+
)
|
|
1555
|
+
assert _run(code, ["CH029"]) == []
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
def test_ch029_ignores_return_when_except_fully_absorbs_and_never_reraises():
|
|
1559
|
+
# Real pattern found in letta: except Exception logs and records the
|
|
1560
|
+
# error into a result dict, deliberately never re-raises (docstring:
|
|
1561
|
+
# "callback failures should not affect job completion") - by the
|
|
1562
|
+
# time finally runs, nothing is pending to swallow.
|
|
1563
|
+
code = (
|
|
1564
|
+
"def f():\n"
|
|
1565
|
+
" result = {}\n"
|
|
1566
|
+
" try:\n"
|
|
1567
|
+
" risky()\n"
|
|
1568
|
+
" except Exception as e:\n"
|
|
1569
|
+
" result['error'] = str(e)\n"
|
|
1570
|
+
" finally:\n"
|
|
1571
|
+
" return result\n"
|
|
1572
|
+
)
|
|
1573
|
+
assert _run(code, ["CH029"]) == []
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def test_ch029_still_flags_return_when_except_reraises():
|
|
1577
|
+
# Real pattern found in letta: the except handler logs, then
|
|
1578
|
+
# re-raises (or raises a wrapped error) - the finally's return still
|
|
1579
|
+
# discards that live, in-flight exception.
|
|
1580
|
+
code = (
|
|
1581
|
+
"def f():\n"
|
|
1582
|
+
" try:\n"
|
|
1583
|
+
" risky()\n"
|
|
1584
|
+
" except Exception as e:\n"
|
|
1585
|
+
" log(e)\n"
|
|
1586
|
+
" raise\n"
|
|
1587
|
+
" finally:\n"
|
|
1588
|
+
" return None\n"
|
|
1589
|
+
)
|
|
1590
|
+
findings = _run(code, ["CH029"])
|
|
1591
|
+
assert len(findings) == 1
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
def test_ch029_still_flags_return_when_no_except_at_all():
|
|
1595
|
+
code = "def f():\n try:\n raise ValueError()\n finally:\n return 5\n"
|
|
1596
|
+
findings = _run(code, ["CH029"])
|
|
1597
|
+
assert len(findings) == 1
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
def test_ch029_still_flags_when_one_of_several_handlers_reraises():
|
|
1601
|
+
code = (
|
|
1602
|
+
"def f():\n"
|
|
1603
|
+
" try:\n"
|
|
1604
|
+
" risky()\n"
|
|
1605
|
+
" except ValueError:\n"
|
|
1606
|
+
" pass\n"
|
|
1607
|
+
" except TypeError:\n"
|
|
1608
|
+
" raise\n"
|
|
1609
|
+
" finally:\n"
|
|
1610
|
+
" return None\n"
|
|
1611
|
+
)
|
|
1612
|
+
findings = _run(code, ["CH029"])
|
|
1613
|
+
assert len(findings) == 1
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
# --- CH030 lru-cache-on-async-function --------------------------------------------------
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def test_ch030_flags_lru_cache_on_module_level_async_function():
|
|
1620
|
+
code = "from functools import lru_cache\n@lru_cache\nasync def fetch(x):\n return x\n"
|
|
1621
|
+
findings = _run(code, ["CH030"])
|
|
1622
|
+
assert len(findings) == 1
|
|
1623
|
+
assert findings[0].code == "CH030"
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def test_ch030_flags_cache_decorator_call_form_on_async_method():
|
|
1627
|
+
code = (
|
|
1628
|
+
"import functools\n"
|
|
1629
|
+
"class C:\n"
|
|
1630
|
+
" @functools.lru_cache(maxsize=128)\n"
|
|
1631
|
+
" async def fetch(self, x):\n"
|
|
1632
|
+
" return x\n"
|
|
1633
|
+
)
|
|
1634
|
+
findings = _run(code, ["CH030"])
|
|
1635
|
+
assert len(findings) == 1
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def test_ch030_ignores_lru_cache_on_sync_function():
|
|
1639
|
+
code = "from functools import lru_cache\n@lru_cache\ndef compute(x):\n return x\n"
|
|
1640
|
+
assert _run(code, ["CH030"]) == []
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def test_ch030_ignores_uncached_async_function():
|
|
1644
|
+
code = "async def fetch(x):\n return x\n"
|
|
1645
|
+
assert _run(code, ["CH030"]) == []
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
# --- CH031 unclosed-pool -----------------------------------------------------------------
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
def test_ch031_flags_pool_never_closed():
|
|
1652
|
+
code = (
|
|
1653
|
+
"import multiprocessing\n"
|
|
1654
|
+
"def run():\n"
|
|
1655
|
+
" pool = multiprocessing.Pool(4)\n"
|
|
1656
|
+
" pool.map(f, items)\n"
|
|
1657
|
+
)
|
|
1658
|
+
findings = _run(code, ["CH031"])
|
|
1659
|
+
assert len(findings) == 1
|
|
1660
|
+
assert findings[0].code == "CH031"
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
def test_ch031_ignores_pool_with_context_manager():
|
|
1664
|
+
code = (
|
|
1665
|
+
"import multiprocessing\n"
|
|
1666
|
+
"def run():\n"
|
|
1667
|
+
" with multiprocessing.Pool(4) as pool:\n"
|
|
1668
|
+
" pool.map(f, items)\n"
|
|
1669
|
+
)
|
|
1670
|
+
assert _run(code, ["CH031"]) == []
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
def test_ch031_ignores_pool_with_close_call():
|
|
1674
|
+
code = (
|
|
1675
|
+
"import multiprocessing\n"
|
|
1676
|
+
"def run():\n"
|
|
1677
|
+
" pool = multiprocessing.Pool(4)\n"
|
|
1678
|
+
" pool.map(f, items)\n"
|
|
1679
|
+
" pool.close()\n"
|
|
1680
|
+
" pool.join()\n"
|
|
1681
|
+
)
|
|
1682
|
+
assert _run(code, ["CH031"]) == []
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
def test_ch031_ignores_pool_with_terminate_call():
|
|
1686
|
+
code = (
|
|
1687
|
+
"import multiprocessing\n"
|
|
1688
|
+
"def run():\n"
|
|
1689
|
+
" pool = multiprocessing.Pool(4)\n"
|
|
1690
|
+
" pool.map(f, items)\n"
|
|
1691
|
+
" pool.terminate()\n"
|
|
1692
|
+
)
|
|
1693
|
+
assert _run(code, ["CH031"]) == []
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
def test_ch031_ignores_pool_returned_to_caller():
|
|
1697
|
+
code = "import multiprocessing\ndef run():\n pool = multiprocessing.Pool(4)\n return pool\n"
|
|
1698
|
+
assert _run(code, ["CH031"]) == []
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
def test_ch031_ignores_pool_stored_as_attribute():
|
|
1702
|
+
code = (
|
|
1703
|
+
"import multiprocessing\n"
|
|
1704
|
+
"def run(obj):\n"
|
|
1705
|
+
" pool = multiprocessing.Pool(4)\n"
|
|
1706
|
+
" obj.pool = pool\n"
|
|
1707
|
+
)
|
|
1708
|
+
assert _run(code, ["CH031"]) == []
|
|
1709
|
+
|
|
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
|
|
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
|