codehound 1.4.2__tar.gz → 1.5.0__tar.gz

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