codehound 1.4.2__tar.gz → 1.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {codehound-1.4.2 → codehound-1.6.0}/PKG-INFO +118 -21
  2. {codehound-1.4.2 → codehound-1.6.0}/README.md +117 -20
  3. codehound-1.6.0/src/codehound/__init__.py +26 -0
  4. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/__init__.py +16 -0
  5. codehound-1.6.0/src/codehound/checks/asyncio_coroutine_decorator.py +70 -0
  6. codehound-1.6.0/src/codehound/checks/floating_timer.py +169 -0
  7. codehound-1.6.0/src/codehound/checks/is_literal_comparison.py +80 -0
  8. codehound-1.6.0/src/codehound/checks/mutable_class_attribute.py +108 -0
  9. codehound-1.6.0/src/codehound/checks/removed_stdlib_attribute.py +75 -0
  10. codehound-1.6.0/src/codehound/checks/removed_stdlib_module.py +142 -0
  11. codehound-1.6.0/src/codehound/checks/unittest_deprecated_alias.py +68 -0
  12. codehound-1.6.0/src/codehound/checks/unwaited_subprocess.py +146 -0
  13. {codehound-1.4.2 → codehound-1.6.0}/tests/test_checks.py +431 -0
  14. codehound-1.4.2/src/codehound/__init__.py +0 -25
  15. {codehound-1.4.2 → codehound-1.6.0}/.gitignore +0 -0
  16. {codehound-1.4.2 → codehound-1.6.0}/LICENSE +0 -0
  17. {codehound-1.4.2 → codehound-1.6.0}/pyproject.toml +0 -0
  18. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/async_property.py +0 -0
  19. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
  20. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/bare_except.py +0 -0
  21. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/blocking_async.py +0 -0
  22. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/collections_abc_import.py +0 -0
  23. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/datetime_utcnow.py +0 -0
  24. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/discarded_future.py +0 -0
  25. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/floating_process.py +0 -0
  26. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/floating_task.py +0 -0
  27. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/floating_thread.py +0 -0
  28. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/get_event_loop.py +0 -0
  29. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/loop_closure_capture.py +0 -0
  30. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/lru_cache_on_method.py +0 -0
  31. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/mutable_defaults.py +0 -0
  32. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
  33. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/removed_getargspec.py +0 -0
  34. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/resource_leak.py +0 -0
  35. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
  36. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/unclosed_socket.py +0 -0
  37. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/checks/unprotected_lock.py +0 -0
  38. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/cli.py +0 -0
  39. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/core.py +0 -0
  40. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/sarif.py +0 -0
  41. {codehound-1.4.2 → codehound-1.6.0}/src/codehound/terminal.py +0 -0
  42. {codehound-1.4.2 → codehound-1.6.0}/tests/test_output_formats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codehound
3
- Version: 1.4.2
3
+ Version: 1.6.0
4
4
  Summary: An AST-based static analyzer that hunts real correctness and async-safety bugs in Python code.
5
5
  Project-URL: Homepage, https://github.com/kratos0718/codehound
6
6
  Project-URL: Issues, https://github.com/kratos0718/codehound/issues
@@ -24,7 +24,7 @@ Description-Content-Type: text/markdown
24
24
 
25
25
  <h1 align="center">codehound</h1>
26
26
 
27
- **An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty checks, 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-eight checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
28
28
 
29
29
  [![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 ~3,200 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
75
75
 
76
76
  <details>
77
77
  <summary>From a clone instead (for development)</summary>
@@ -132,7 +132,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
132
132
  ```yaml
133
133
  repos:
134
134
  - repo: https://github.com/kratos0718/codehound
135
- rev: v1.4.2
135
+ rev: v1.6.0
136
136
  hooks:
137
137
  - id: codehound
138
138
  ```
@@ -153,7 +153,7 @@ repos:
153
153
  | **CH008** | `asyncio-run-in-running-loop` | `asyncio.run(...)` called from inside an `async def` — always raises `RuntimeError`, immediately, every time. | hardening rule — zero corpus hits (see below) |
154
154
  | **CH009** | `floating-thread` | A non-daemon `threading.Thread` that's `.start()`ed but never `.join()`ed — the thread analog of CH006. | hardening rule — see below |
155
155
  | **CH010** | `loop-closure-capture` | A `lambda` inside a `for` loop (or comprehension) that's *stored* (appended, assigned, returned) and captures the loop variable by reference — every stored instance ends up sharing the loop's **final** value. | **accelerate** (HuggingFace) — `MegatronEngine.get_module_config`'s `param_sync_func` list, PR #4273 |
156
- | **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. | **optuna** — `_FanovaTree`'s node-lookup methods leaked every tree built for a `get_param_importances()` call; **llama_index** — `VectaraIndex._get_corpus_key` leaked the index *and* broke its own `__del__`-based HTTP session cleanup |
156
+ | **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. | **optuna** — `_FanovaTree`'s node-lookup methods leaked every tree built for a `get_param_importances()` call; **llama_index** — `VectaraIndex._get_corpus_key` leaked the index *and* broke its own `__del__`-based HTTP session cleanup; **litellm** — `Router._cached_get_model_group_info` leaked every `Router` even after its own documented `discard()` cleanup |
157
157
  | **CH012** | `floating-process` | A non-daemon `multiprocessing.Process` that's `.start()`ed but never `.join()`ed — the process analog of CH009. | hardening rule |
158
158
  | **CH013** | `discarded-future` | `ThreadPoolExecutor`/`ProcessPoolExecutor.submit(...)` called as a bare statement — the returned `Future` (and any exception raised inside the submitted work) is silently discarded. | hardening rule — real hits in litellm, accelerate, langchain |
159
159
  | **CH014** | `unprotected-lock-acquire` | `lock.acquire()` outside a `with`, whose matching `.release()` isn't inside a `finally:` — an exception between acquire and release deadlocks every future caller of that lock. | hardening rule — real hits in vllm, accelerate, torchtune |
@@ -163,10 +163,18 @@ repos:
163
163
  | **CH018** | `removed-asyncio-task-methods` | `asyncio.Task.current_task()` / `.all_tasks()` — both removed in Python 3.9; use `asyncio.current_task()` / `asyncio.all_tasks()`. | hardening rule |
164
164
  | **CH019** | `removed-getargspec` | `inspect.getargspec(...)` — removed in Python 3.11 after a decade-plus deprecation; use `inspect.signature(...)`. | hardening rule |
165
165
  | **CH020** | `bare-except` | A bare `except:` (or unused `except BaseException:`) — also catches `KeyboardInterrupt`/`SystemExit`, so Ctrl-C stops working and `sys.exit()` gets silently absorbed. | hardening rule — real hits in agno, llama_index, marimo, litellm |
166
+ | **CH021** | `removed-stdlib-module` | `import distutils` (removed 3.12) or any of the 19 PEP 594 "dead battery" modules (`cgi`, `imghdr`, `telnetlib`, `nntplib`, …, removed 3.13) — `ImportError` the moment the module loads. | hardening rule — real hit in agno (already guarded, see below) |
167
+ | **CH022** | `removed-asyncio-coroutine-decorator` | `@asyncio.coroutine` — removed in Python 3.11 after a generator-based-coroutine bridge that predates `async def`; `AttributeError` the moment the decorator line runs. | hardening rule |
168
+ | **CH023** | `removed-stdlib-attribute` | A specific removed function on a module that still imports fine — `time.clock()` (3.8), `platform.linux_distribution()`/`.dist()` (3.8), `cgi.escape()` (3.8), `base64.encodestring()`/`.decodestring()` (3.9). | hardening rule — real hit in scikit-learn |
169
+ | **CH024** | `unittest-deprecated-alias` | `self.assertEquals(...)`/`self.failUnless(...)` and a dozen other legacy `unittest.TestCase` aliases — removed in Python 3.12. | hardening rule |
170
+ | **CH025** | `is-literal-comparison` | `x is 1000` / `x is not "foo"` — `is` checks identity, not equality; relies on CPython's small-int caching / string interning, neither guaranteed. (pyflakes F632) | hardening rule — real false positive fixed in litellm's own code, see below |
171
+ | **CH026** | `mutable-class-attribute` | `class C: items = []` mutated via `self.items.append(...)` without ever being reassigned per instance — every instance shares and mutates the *same* list. | **vllm**, **llama_index**, **optuna**, **transformers** — see below |
172
+ | **CH027** | `unwaited-subprocess` | `subprocess.Popen(...)` never `.wait()`ed/`.communicate()`d with, and not context-managed — risks a zombie process and a full pipe buffer deadlocking the child. | hardening rule — real hit in dspy (already handled, see below) |
173
+ | **CH028** | `floating-timer` | `threading.Timer(...)` started but never `.cancel()`ed or handed off — nothing can stop the callback from firing later, on stale context. | hardening rule — real hits in marimo, transformers |
166
174
 
167
175
  `codehound list` prints this from the source of truth.
168
176
 
169
- CH007-CH020 don't have found-and-merged bugs behind all of them the way
177
+ CH007-CH028 don't have found-and-merged bugs behind all of them the way
170
178
  CH001-CH006 do - most are hardening rules for well-known Python
171
179
  correctness gotchas rather than something this project personally
172
180
  tracked down first. CH010 and CH011 are the exceptions: both found
@@ -210,20 +218,28 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
210
218
  match same-file names by design, and most real cases of either are
211
219
  plausibly cross-module.
212
220
 
213
- **The optuna and 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.
221
+ **The optuna, llama_index, and litellm finds (CH011):** all three are
222
+ `@lru_cache(maxsize=None)` (or a fixed `maxsize`) decorating an instance
223
+ method - a strong reference to `self` retained for the life of the
224
+ process. In optuna, `_FanovaTree`'s node-lookup methods leak every tree
225
+ built for a `get_param_importances()` call (one per random-forest
226
+ estimator). In llama_index, `VectaraIndex._get_corpus_key` leaks the
227
+ index itself - and since `VectaraIndex.__del__` exists specifically to
228
+ close the index's `requests.Session` on garbage collection, the leak
229
+ silently disables that cleanup too, so an HTTP session leaks along with
230
+ every index. In litellm, `Router._cached_get_model_group_info` leaks
231
+ every `Router` that's ever served a request through it - proved this
232
+ survives even a correctly-called `router.discard()` (Router's own
233
+ documented cleanup method), so it isn't a "you forgot to clean up" bug.
234
+ All three fixed the same way: move the cache from a class-level decorator
235
+ to a per-instance one built in `__init__`, so it's freed with the
236
+ instance instead of outliving it - the exact pattern litellm's own
237
+ `cached_deployment_model_info` sibling method already used, just not yet
238
+ applied to this one. Each has a regression test verified to fail pre-fix
239
+ and pass post-fix.
225
240
  PRs: [optuna/optuna#6859](https://github.com/optuna/optuna/pull/6859),
226
- [run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089).
241
+ [run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089),
242
+ [BerriAI/litellm#41582](https://github.com/BerriAI/litellm/pull/41582).
227
243
 
228
244
  **A third CH011 shape needed a guard instead of a PR:** dspy's `Image` (a
229
245
  pydantic model) caches `format()` the same way, but `Image` is frozen
@@ -247,6 +263,74 @@ as escaped when it's returned as part of a tuple/list or passed as an
247
263
  argument to any call. A full corpus rescan afterward found zero remaining
248
264
  CH016 hits.
249
265
 
266
+ **CH021 did the same thing twice, minutes apart.** The first real-corpus
267
+ scan found a false positive in vllm - `from .chunk import
268
+ chunk_gated_delta_rule`, a relative import of vllm's own local `chunk.py`
269
+ sibling module, not the removed stdlib `chunk`. `ast.ImportFrom.module`
270
+ is `"chunk"` either way; only `node.level` (the leading-dot count) tells
271
+ a relative import apart from an absolute one, and the check wasn't
272
+ checking it. Fixed, rescanned, and found a *second* false positive in
273
+ agno: `try: import imghdr except ImportError: import filetype` - a
274
+ real, deliberate fallback that already anticipates this exact removal,
275
+ not a bug waiting to happen. Added a second guard: skip an import inside
276
+ a `try:` body whose `except` catches `ImportError` (or anything
277
+ broader). A full corpus rescan after both fixes found zero remaining
278
+ CH021 hits.
279
+
280
+ **CH026 found four real, previously-unreported bugs on its first real
281
+ scan.** All four are the exact same shape: a class-level mutable default
282
+ (`items = []`) mutated in place via `self.items.append(...)` (or
283
+ subscript assignment) with no per-instance reassignment anywhere, so
284
+ every instance of the class shares and corrupts the *same* object. In
285
+ vllm's `AXK1ForCausalLM`, `self.packed_modules_mapping["qkv_proj"] =
286
+ [...]` patches a routing table shared by every instance of the model
287
+ class. In llama_index's `ZapierToolSpec`, `self.spec_functions.append(...)`
288
+ means a second tool-spec instance (a different API key, a different
289
+ user) inherits every action name the first instance ever registered. In
290
+ optuna's CLI `_Studies` command, `self._study_list_header.append(...)`
291
+ does the same to a table-header list. In HuggingFace transformers'
292
+ `CodeGenTokenizer`, `self.model_input_names.append("token_type_ids")`
293
+ means constructing one tokenizer with `return_token_type_ids=True`
294
+ silently changes what field every *other* `CodeGenTokenizer` instance in
295
+ the same process expects, regardless of how it was configured - exactly
296
+ the "spooky action at a distance" class of bug this project exists to
297
+ catch. None have PRs yet: vllm and transformers both require AI-assisted
298
+ PRs to carry an explicit disclosure, which this project's own policy
299
+ doesn't do, so those two are documented here rather than filed.
300
+
301
+ **CH028 hit the exact same name-collision problem CH018/CH022 already
302
+ had a guard for, because that guard didn't get reused.** The very first
303
+ corpus scan came back with ~30 hits, almost all in agno - which doesn't
304
+ use `threading.Timer` at all. `from agno.utils.timer import Timer` is
305
+ agno's own unrelated stopwatch class, called as `Timer()` with zero
306
+ arguments (real `threading.Timer` requires `interval` and `function` and
307
+ would raise `TypeError` immediately). Fixed by requiring `from threading
308
+ import Timer` before trusting a bare `Timer(...)` call - the same guard
309
+ already built for CH022's `coroutine` minutes earlier in the same
310
+ session, just not applied here the first time. Also missed CH009's
311
+ `daemon=True` escape entirely (found in weaviate-python-client's
312
+ watchdog timer, `_timeout_timer.daemon = True`, a deliberate
313
+ "outlive the caller" choice) - added both a constructor-kwarg and a
314
+ post-construction-assignment check for it, matching CH009 exactly. Real
315
+ hits remain in marimo (a non-daemon browser-opening timer, never
316
+ cancelled) and transformers (a chained, never-captured checkpoint-retry
317
+ timer).
318
+
319
+ **CH025 and CH027 each found one real bug in the first scan, and one
320
+ real gap in the check.** CH025 (is-literal-comparison) flagged litellm's
321
+ `if "usage" in response_obj is not None:` - but for the wrong reason.
322
+ `ast.Compare` puts every operand and every op from a chained comparison
323
+ in one node; checking "is there a literal anywhere" and "is there an
324
+ `is`/`is not` anywhere" independently, without pairing each op with its
325
+ own adjacent operands, matched the string literal (paired with `in`)
326
+ against the wrong op (`is not`, actually comparing `response_obj` to the
327
+ allowed singleton `None`). Fixed by walking the chain as adjacent
328
+ `(left, op, right)` triples. CH027 (unwaited-subprocess) flagged dspy's
329
+ `process = subprocess.Popen(...)`, followed by `lm.process = process` -
330
+ a real hand-off to a different object, reaped later through a separate
331
+ `terminate_process(lm.process)` call, the same "stored as any object's
332
+ attribute" escape CH009/CH016/CH028 already needed. Added it.
333
+
250
334
  **Two checks we built and did not ship.** `exception-chaining` (`except X
251
335
  as e: raise Y(...)` with no `from e`, discarding the real traceback -
252
336
  overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
@@ -302,12 +386,20 @@ codehound/
302
386
  ├── collections_abc_import.py (CH017)
303
387
  ├── removed_asyncio_task_methods.py (CH018)
304
388
  ├── removed_getargspec.py (CH019)
305
- └── bare_except.py (CH020)
389
+ ├── bare_except.py (CH020)
390
+ ├── removed_stdlib_module.py (CH021)
391
+ ├── asyncio_coroutine_decorator.py (CH022)
392
+ ├── removed_stdlib_attribute.py (CH023)
393
+ ├── unittest_deprecated_alias.py (CH024)
394
+ ├── is_literal_comparison.py (CH025)
395
+ ├── mutable_class_attribute.py (CH026)
396
+ ├── unwaited_subprocess.py (CH027)
397
+ └── floating_timer.py (CH028)
306
398
  ```
307
399
 
308
400
  Each check receives a parsed `ast` tree plus the precomputed parent map and returns `Finding`s. Adding a rule is one file + one registry line + a test. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for a full walkthrough of the engine, the parent map, and the design decisions.
309
401
 
310
- **False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
402
+ **False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
311
403
 
312
404
  ---
313
405
 
@@ -337,6 +429,11 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
337
429
  - [x] 20 checks — memory leaks (`lru_cache` on methods), floating processes,
338
430
  discarded futures, unprotected locks, async properties, unclosed
339
431
  sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
432
+ - [x] 22 checks — removed stdlib modules (`distutils`, PEP 594 "dead
433
+ batteries"), removed `@asyncio.coroutine` decorator — CH021-CH022
434
+ - [x] 28 checks — removed stdlib functions, deprecated unittest aliases,
435
+ `is`-literal comparisons, mutable class attributes, unwaited
436
+ subprocesses, floating timers — CH023-CH028
340
437
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
341
438
  - [ ] Sync HTTP clients constructed inside async request handlers
342
439
  - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">codehound</h1>
6
6
 
7
- **An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty checks, 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-eight checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
8
8
 
9
9
  [![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 ~3,200 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
55
55
 
56
56
  <details>
57
57
  <summary>From a clone instead (for development)</summary>
@@ -112,7 +112,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
112
112
  ```yaml
113
113
  repos:
114
114
  - repo: https://github.com/kratos0718/codehound
115
- rev: v1.4.2
115
+ rev: v1.6.0
116
116
  hooks:
117
117
  - id: codehound
118
118
  ```
@@ -133,7 +133,7 @@ repos:
133
133
  | **CH008** | `asyncio-run-in-running-loop` | `asyncio.run(...)` called from inside an `async def` — always raises `RuntimeError`, immediately, every time. | hardening rule — zero corpus hits (see below) |
134
134
  | **CH009** | `floating-thread` | A non-daemon `threading.Thread` that's `.start()`ed but never `.join()`ed — the thread analog of CH006. | hardening rule — see below |
135
135
  | **CH010** | `loop-closure-capture` | A `lambda` inside a `for` loop (or comprehension) that's *stored* (appended, assigned, returned) and captures the loop variable by reference — every stored instance ends up sharing the loop's **final** value. | **accelerate** (HuggingFace) — `MegatronEngine.get_module_config`'s `param_sync_func` list, PR #4273 |
136
- | **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. | **optuna** — `_FanovaTree`'s node-lookup methods leaked every tree built for a `get_param_importances()` call; **llama_index** — `VectaraIndex._get_corpus_key` leaked the index *and* broke its own `__del__`-based HTTP session cleanup |
136
+ | **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. | **optuna** — `_FanovaTree`'s node-lookup methods leaked every tree built for a `get_param_importances()` call; **llama_index** — `VectaraIndex._get_corpus_key` leaked the index *and* broke its own `__del__`-based HTTP session cleanup; **litellm** — `Router._cached_get_model_group_info` leaked every `Router` even after its own documented `discard()` cleanup |
137
137
  | **CH012** | `floating-process` | A non-daemon `multiprocessing.Process` that's `.start()`ed but never `.join()`ed — the process analog of CH009. | hardening rule |
138
138
  | **CH013** | `discarded-future` | `ThreadPoolExecutor`/`ProcessPoolExecutor.submit(...)` called as a bare statement — the returned `Future` (and any exception raised inside the submitted work) is silently discarded. | hardening rule — real hits in litellm, accelerate, langchain |
139
139
  | **CH014** | `unprotected-lock-acquire` | `lock.acquire()` outside a `with`, whose matching `.release()` isn't inside a `finally:` — an exception between acquire and release deadlocks every future caller of that lock. | hardening rule — real hits in vllm, accelerate, torchtune |
@@ -143,10 +143,18 @@ repos:
143
143
  | **CH018** | `removed-asyncio-task-methods` | `asyncio.Task.current_task()` / `.all_tasks()` — both removed in Python 3.9; use `asyncio.current_task()` / `asyncio.all_tasks()`. | hardening rule |
144
144
  | **CH019** | `removed-getargspec` | `inspect.getargspec(...)` — removed in Python 3.11 after a decade-plus deprecation; use `inspect.signature(...)`. | hardening rule |
145
145
  | **CH020** | `bare-except` | A bare `except:` (or unused `except BaseException:`) — also catches `KeyboardInterrupt`/`SystemExit`, so Ctrl-C stops working and `sys.exit()` gets silently absorbed. | hardening rule — real hits in agno, llama_index, marimo, litellm |
146
+ | **CH021** | `removed-stdlib-module` | `import distutils` (removed 3.12) or any of the 19 PEP 594 "dead battery" modules (`cgi`, `imghdr`, `telnetlib`, `nntplib`, …, removed 3.13) — `ImportError` the moment the module loads. | hardening rule — real hit in agno (already guarded, see below) |
147
+ | **CH022** | `removed-asyncio-coroutine-decorator` | `@asyncio.coroutine` — removed in Python 3.11 after a generator-based-coroutine bridge that predates `async def`; `AttributeError` the moment the decorator line runs. | hardening rule |
148
+ | **CH023** | `removed-stdlib-attribute` | A specific removed function on a module that still imports fine — `time.clock()` (3.8), `platform.linux_distribution()`/`.dist()` (3.8), `cgi.escape()` (3.8), `base64.encodestring()`/`.decodestring()` (3.9). | hardening rule — real hit in scikit-learn |
149
+ | **CH024** | `unittest-deprecated-alias` | `self.assertEquals(...)`/`self.failUnless(...)` and a dozen other legacy `unittest.TestCase` aliases — removed in Python 3.12. | hardening rule |
150
+ | **CH025** | `is-literal-comparison` | `x is 1000` / `x is not "foo"` — `is` checks identity, not equality; relies on CPython's small-int caching / string interning, neither guaranteed. (pyflakes F632) | hardening rule — real false positive fixed in litellm's own code, see below |
151
+ | **CH026** | `mutable-class-attribute` | `class C: items = []` mutated via `self.items.append(...)` without ever being reassigned per instance — every instance shares and mutates the *same* list. | **vllm**, **llama_index**, **optuna**, **transformers** — see below |
152
+ | **CH027** | `unwaited-subprocess` | `subprocess.Popen(...)` never `.wait()`ed/`.communicate()`d with, and not context-managed — risks a zombie process and a full pipe buffer deadlocking the child. | hardening rule — real hit in dspy (already handled, see below) |
153
+ | **CH028** | `floating-timer` | `threading.Timer(...)` started but never `.cancel()`ed or handed off — nothing can stop the callback from firing later, on stale context. | hardening rule — real hits in marimo, transformers |
146
154
 
147
155
  `codehound list` prints this from the source of truth.
148
156
 
149
- CH007-CH020 don't have found-and-merged bugs behind all of them the way
157
+ CH007-CH028 don't have found-and-merged bugs behind all of them the way
150
158
  CH001-CH006 do - most are hardening rules for well-known Python
151
159
  correctness gotchas rather than something this project personally
152
160
  tracked down first. CH010 and CH011 are the exceptions: both found
@@ -190,20 +198,28 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
190
198
  match same-file names by design, and most real cases of either are
191
199
  plausibly cross-module.
192
200
 
193
- **The optuna and 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.
201
+ **The optuna, llama_index, and litellm finds (CH011):** all three are
202
+ `@lru_cache(maxsize=None)` (or a fixed `maxsize`) decorating an instance
203
+ method - a strong reference to `self` retained for the life of the
204
+ process. In optuna, `_FanovaTree`'s node-lookup methods leak every tree
205
+ built for a `get_param_importances()` call (one per random-forest
206
+ estimator). In llama_index, `VectaraIndex._get_corpus_key` leaks the
207
+ index itself - and since `VectaraIndex.__del__` exists specifically to
208
+ close the index's `requests.Session` on garbage collection, the leak
209
+ silently disables that cleanup too, so an HTTP session leaks along with
210
+ every index. In litellm, `Router._cached_get_model_group_info` leaks
211
+ every `Router` that's ever served a request through it - proved this
212
+ survives even a correctly-called `router.discard()` (Router's own
213
+ documented cleanup method), so it isn't a "you forgot to clean up" bug.
214
+ All three fixed the same way: move the cache from a class-level decorator
215
+ to a per-instance one built in `__init__`, so it's freed with the
216
+ instance instead of outliving it - the exact pattern litellm's own
217
+ `cached_deployment_model_info` sibling method already used, just not yet
218
+ applied to this one. Each has a regression test verified to fail pre-fix
219
+ and pass post-fix.
205
220
  PRs: [optuna/optuna#6859](https://github.com/optuna/optuna/pull/6859),
206
- [run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089).
221
+ [run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089),
222
+ [BerriAI/litellm#41582](https://github.com/BerriAI/litellm/pull/41582).
207
223
 
208
224
  **A third CH011 shape needed a guard instead of a PR:** dspy's `Image` (a
209
225
  pydantic model) caches `format()` the same way, but `Image` is frozen
@@ -227,6 +243,74 @@ as escaped when it's returned as part of a tuple/list or passed as an
227
243
  argument to any call. A full corpus rescan afterward found zero remaining
228
244
  CH016 hits.
229
245
 
246
+ **CH021 did the same thing twice, minutes apart.** The first real-corpus
247
+ scan found a false positive in vllm - `from .chunk import
248
+ chunk_gated_delta_rule`, a relative import of vllm's own local `chunk.py`
249
+ sibling module, not the removed stdlib `chunk`. `ast.ImportFrom.module`
250
+ is `"chunk"` either way; only `node.level` (the leading-dot count) tells
251
+ a relative import apart from an absolute one, and the check wasn't
252
+ checking it. Fixed, rescanned, and found a *second* false positive in
253
+ agno: `try: import imghdr except ImportError: import filetype` - a
254
+ real, deliberate fallback that already anticipates this exact removal,
255
+ not a bug waiting to happen. Added a second guard: skip an import inside
256
+ a `try:` body whose `except` catches `ImportError` (or anything
257
+ broader). A full corpus rescan after both fixes found zero remaining
258
+ CH021 hits.
259
+
260
+ **CH026 found four real, previously-unreported bugs on its first real
261
+ scan.** All four are the exact same shape: a class-level mutable default
262
+ (`items = []`) mutated in place via `self.items.append(...)` (or
263
+ subscript assignment) with no per-instance reassignment anywhere, so
264
+ every instance of the class shares and corrupts the *same* object. In
265
+ vllm's `AXK1ForCausalLM`, `self.packed_modules_mapping["qkv_proj"] =
266
+ [...]` patches a routing table shared by every instance of the model
267
+ class. In llama_index's `ZapierToolSpec`, `self.spec_functions.append(...)`
268
+ means a second tool-spec instance (a different API key, a different
269
+ user) inherits every action name the first instance ever registered. In
270
+ optuna's CLI `_Studies` command, `self._study_list_header.append(...)`
271
+ does the same to a table-header list. In HuggingFace transformers'
272
+ `CodeGenTokenizer`, `self.model_input_names.append("token_type_ids")`
273
+ means constructing one tokenizer with `return_token_type_ids=True`
274
+ silently changes what field every *other* `CodeGenTokenizer` instance in
275
+ the same process expects, regardless of how it was configured - exactly
276
+ the "spooky action at a distance" class of bug this project exists to
277
+ catch. None have PRs yet: vllm and transformers both require AI-assisted
278
+ PRs to carry an explicit disclosure, which this project's own policy
279
+ doesn't do, so those two are documented here rather than filed.
280
+
281
+ **CH028 hit the exact same name-collision problem CH018/CH022 already
282
+ had a guard for, because that guard didn't get reused.** The very first
283
+ corpus scan came back with ~30 hits, almost all in agno - which doesn't
284
+ use `threading.Timer` at all. `from agno.utils.timer import Timer` is
285
+ agno's own unrelated stopwatch class, called as `Timer()` with zero
286
+ arguments (real `threading.Timer` requires `interval` and `function` and
287
+ would raise `TypeError` immediately). Fixed by requiring `from threading
288
+ import Timer` before trusting a bare `Timer(...)` call - the same guard
289
+ already built for CH022's `coroutine` minutes earlier in the same
290
+ session, just not applied here the first time. Also missed CH009's
291
+ `daemon=True` escape entirely (found in weaviate-python-client's
292
+ watchdog timer, `_timeout_timer.daemon = True`, a deliberate
293
+ "outlive the caller" choice) - added both a constructor-kwarg and a
294
+ post-construction-assignment check for it, matching CH009 exactly. Real
295
+ hits remain in marimo (a non-daemon browser-opening timer, never
296
+ cancelled) and transformers (a chained, never-captured checkpoint-retry
297
+ timer).
298
+
299
+ **CH025 and CH027 each found one real bug in the first scan, and one
300
+ real gap in the check.** CH025 (is-literal-comparison) flagged litellm's
301
+ `if "usage" in response_obj is not None:` - but for the wrong reason.
302
+ `ast.Compare` puts every operand and every op from a chained comparison
303
+ in one node; checking "is there a literal anywhere" and "is there an
304
+ `is`/`is not` anywhere" independently, without pairing each op with its
305
+ own adjacent operands, matched the string literal (paired with `in`)
306
+ against the wrong op (`is not`, actually comparing `response_obj` to the
307
+ allowed singleton `None`). Fixed by walking the chain as adjacent
308
+ `(left, op, right)` triples. CH027 (unwaited-subprocess) flagged dspy's
309
+ `process = subprocess.Popen(...)`, followed by `lm.process = process` -
310
+ a real hand-off to a different object, reaped later through a separate
311
+ `terminate_process(lm.process)` call, the same "stored as any object's
312
+ attribute" escape CH009/CH016/CH028 already needed. Added it.
313
+
230
314
  **Two checks we built and did not ship.** `exception-chaining` (`except X
231
315
  as e: raise Y(...)` with no `from e`, discarding the real traceback -
232
316
  overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
@@ -282,12 +366,20 @@ codehound/
282
366
  ├── collections_abc_import.py (CH017)
283
367
  ├── removed_asyncio_task_methods.py (CH018)
284
368
  ├── removed_getargspec.py (CH019)
285
- └── bare_except.py (CH020)
369
+ ├── bare_except.py (CH020)
370
+ ├── removed_stdlib_module.py (CH021)
371
+ ├── asyncio_coroutine_decorator.py (CH022)
372
+ ├── removed_stdlib_attribute.py (CH023)
373
+ ├── unittest_deprecated_alias.py (CH024)
374
+ ├── is_literal_comparison.py (CH025)
375
+ ├── mutable_class_attribute.py (CH026)
376
+ ├── unwaited_subprocess.py (CH027)
377
+ └── floating_timer.py (CH028)
286
378
  ```
287
379
 
288
380
  Each check receives a parsed `ast` tree plus the precomputed parent map and returns `Finding`s. Adding a rule is one file + one registry line + a test. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for a full walkthrough of the engine, the parent map, and the design decisions.
289
381
 
290
- **False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
382
+ **False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
291
383
 
292
384
  ---
293
385
 
@@ -317,6 +409,11 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
317
409
  - [x] 20 checks — memory leaks (`lru_cache` on methods), floating processes,
318
410
  discarded futures, unprotected locks, async properties, unclosed
319
411
  sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
412
+ - [x] 22 checks — removed stdlib modules (`distutils`, PEP 594 "dead
413
+ batteries"), removed `@asyncio.coroutine` decorator — CH021-CH022
414
+ - [x] 28 checks — removed stdlib functions, deprecated unittest aliases,
415
+ `is`-literal comparisons, mutable class attributes, unwaited
416
+ subprocesses, floating timers — CH023-CH028
320
417
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
321
418
  - [ ] Sync HTTP clients constructed inside async request handlers
322
419
  - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
@@ -0,0 +1,26 @@
1
+ """codehound - an AST-based static analyzer that hunts real bugs in Python code.
2
+
3
+ Twenty-eight checks. Eight are each backed by a bug that was actually
4
+ found and fixed (or opened as a PR) in a popular open-source AI framework
5
+ (agno, crewAI, mem0, llama_index, accelerate, optuna, litellm). The rest
6
+ (CH007-CH009, CH012-CH028) are hardening rules verified against real
7
+ false positives across a ~29-framework validation corpus instead - see
8
+ docs/FINDINGS.md.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from codehound.checks import ALL_CHECKS, get_checks
14
+ from codehound.core import Check, Finding, scan_file, scan_path
15
+
16
+ __version__ = "1.6.0"
17
+
18
+ __all__ = [
19
+ "ALL_CHECKS",
20
+ "get_checks",
21
+ "Check",
22
+ "Finding",
23
+ "scan_file",
24
+ "scan_path",
25
+ "__version__",
26
+ ]
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from codehound.checks.async_property import AsyncProperty
6
+ from codehound.checks.asyncio_coroutine_decorator import AsyncioCoroutineDecorator
6
7
  from codehound.checks.asyncio_run_in_loop import AsyncioRunInRunningLoop
7
8
  from codehound.checks.bare_except import BareExcept
8
9
  from codehound.checks.blocking_async import BlockingCallInAsync
@@ -12,16 +13,23 @@ from codehound.checks.discarded_future import DiscardedFuture
12
13
  from codehound.checks.floating_process import FloatingProcess
13
14
  from codehound.checks.floating_task import FloatingTask
14
15
  from codehound.checks.floating_thread import FloatingThread
16
+ from codehound.checks.floating_timer import FloatingTimer
15
17
  from codehound.checks.get_event_loop import DeprecatedGetEventLoop
18
+ from codehound.checks.is_literal_comparison import IsLiteralComparison
16
19
  from codehound.checks.loop_closure_capture import LoopClosureCapture
17
20
  from codehound.checks.lru_cache_on_method import LruCacheOnMethod
21
+ from codehound.checks.mutable_class_attribute import MutableClassAttribute
18
22
  from codehound.checks.mutable_defaults import MutableDefaultArgument
19
23
  from codehound.checks.removed_asyncio_task_methods import RemovedAsyncioTaskMethods
20
24
  from codehound.checks.removed_getargspec import RemovedGetargspec
25
+ from codehound.checks.removed_stdlib_attribute import RemovedStdlibAttribute
26
+ from codehound.checks.removed_stdlib_module import RemovedStdlibModule
21
27
  from codehound.checks.resource_leak import UnclosedFileHandle
22
28
  from codehound.checks.unawaited_coroutine import UnawaitedCoroutineCall
23
29
  from codehound.checks.unclosed_socket import UnclosedSocket
30
+ from codehound.checks.unittest_deprecated_alias import UnittestDeprecatedAlias
24
31
  from codehound.checks.unprotected_lock import UnprotectedLockAcquire
32
+ from codehound.checks.unwaited_subprocess import UnwaitedSubprocess
25
33
  from codehound.core import Check
26
34
 
27
35
  ALL_CHECKS: list[type[Check]] = [
@@ -45,6 +53,14 @@ ALL_CHECKS: list[type[Check]] = [
45
53
  RemovedAsyncioTaskMethods,
46
54
  RemovedGetargspec,
47
55
  BareExcept,
56
+ RemovedStdlibModule,
57
+ AsyncioCoroutineDecorator,
58
+ RemovedStdlibAttribute,
59
+ UnittestDeprecatedAlias,
60
+ IsLiteralComparison,
61
+ MutableClassAttribute,
62
+ UnwaitedSubprocess,
63
+ FloatingTimer,
48
64
  ]
49
65
 
50
66
 
@@ -0,0 +1,70 @@
1
+ """CH022 - ``@asyncio.coroutine`` was removed in Python 3.11.
2
+
3
+ The generator-based coroutine decorator predates `async def` (added in
4
+ 3.5) and was kept around for years as a bridge for old code, deprecated
5
+ since 3.8 with an explicit `DeprecationWarning`, then removed outright in
6
+ 3.11 - `AttributeError: module 'asyncio' has no attribute 'coroutine'`
7
+ the moment the decorator line runs, not something that surfaces later.
8
+ `async def` is the direct replacement; there's no decorator to swap in.
9
+
10
+ A bare `@coroutine` is only trusted to mean `asyncio.coroutine` if the
11
+ file actually imported it via `from asyncio import coroutine` - the same
12
+ name-collision guard CH018 uses for `Task`, since `coroutine` alone is
13
+ common enough to plausibly be someone's own decorator.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+
20
+ from codehound.core import Check, Finding
21
+
22
+
23
+ def _imports_coroutine_from_asyncio(tree: ast.AST) -> bool:
24
+ for node in ast.walk(tree):
25
+ if isinstance(node, ast.ImportFrom) and node.module == "asyncio":
26
+ if any(alias.name == "coroutine" for alias in node.names):
27
+ return True
28
+ return False
29
+
30
+
31
+ def _is_asyncio_coroutine_attr(node: ast.expr) -> bool:
32
+ return (
33
+ isinstance(node, ast.Attribute)
34
+ and node.attr == "coroutine"
35
+ and isinstance(node.value, ast.Name)
36
+ and node.value.id == "asyncio"
37
+ )
38
+
39
+
40
+ class AsyncioCoroutineDecorator(Check):
41
+ code = "CH022"
42
+ name = "removed-asyncio-coroutine-decorator"
43
+ description = "@asyncio.coroutine was removed in Python 3.11; use `async def` instead."
44
+
45
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
46
+ findings: list[Finding] = []
47
+ trust_bare_name = _imports_coroutine_from_asyncio(tree)
48
+ for node in ast.walk(tree):
49
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
50
+ continue
51
+ for dec in node.decorator_list:
52
+ is_match = _is_asyncio_coroutine_attr(dec) or (
53
+ trust_bare_name and isinstance(dec, ast.Name) and dec.id == "coroutine"
54
+ )
55
+ if not is_match:
56
+ continue
57
+ findings.append(
58
+ Finding(
59
+ path=path,
60
+ line=dec.lineno,
61
+ col=dec.col_offset,
62
+ code=self.code,
63
+ message=(
64
+ f"`@{ast.unparse(dec)}` on `{node.name}` - removed in Python 3.11; "
65
+ f"define `{node.name}` with `async def` instead."
66
+ ),
67
+ )
68
+ )
69
+ break
70
+ return findings