codehound
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.
Most linters flag style. codehound flags the subtle correctness and async-safety bugs that slip past code review and only bite in production — event-loop stalls, shared mutable state, leaked file descriptors, fire-and-forget tasks that get garbage-collected mid-run.
Most of the checks below aren't theoretical. I wrote them after finding — and fixing, via a merged pull request — that exact bug in a real, popular framework (agno 25k⭐, crewAI 30k⭐, mem0, llama_index, accelerate).
See it in action
Pointing codehound at agno (a 25k⭐ AI agent framework) surfaced real, previously-unreported bugs:
$ codehound scan agno/libs/agno/agno --select CH001,CH006
agno/integrations/discord/client.py:90:26: CH001 `requests.get()` blocks the event loop
inside async function `on_message`; use the async equivalent.
agno/tracing/exporter.py:112:16: CH006 `asyncio.create_task(...)` result is discarded;
keep a reference (the loop only holds a weak ref, so the task may be GC'd mid-run).
Found 2 issue(s) (CH001: 1, CH006: 1)
Both of these became merged/open fixes upstream. The first froze the Discord bot's event loop on every video/document attachment; the second could silently drop telemetry when its export task was garbage-collected mid-run. codehound found them in seconds — see docs/FINDINGS.md for the full provenance of every rule.
Why this exists
I was contributing bug fixes to large AI frameworks and noticed the same handful of mistakes recurring across codebases. Instead of hunting them by hand, I encoded each one as an AST rule. codehound is the result: point it at a repo and it finds the bugs I'd otherwise have to read 100k lines to spot.
It found the bugs behind these merged fixes — and is built to find the next one.
Install
pip install codehound
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.
From a clone instead (for development)
pip install -e .
# or run straight from source, no install needed
PYTHONPATH=src python -m codehound.cli scan path/to/project
Usage
# scan a project (skips tests/, docs/, examples/, vendored code by default)
codehound scan path/to/project
# scan multiple files/directories in one invocation (what pre-commit does)
codehound scan file1.py file2.py src/
# only run specific checks
codehound scan path/to/project --select CH001,CH006
# machine-readable output for CI dashboards
codehound scan path/to/project --format json
codehound scan path/to/project --format csv
# GitHub Code Scanning (Security tab) can ingest this directly
codehound scan path/to/project --format sarif > results.sarif
# list every available check
codehound list
codehound scan exits non-zero when it finds issues, so it drops straight into CI:
- run: codehound scan src # fails the build on a regression
GitHub Action
- uses: kratos0718/codehound@v1
with:
path: src
# select: CH001,CH006 # optional, defaults to all checks
# fail-on-findings: "false" # optional, report without failing the build
# upload-sarif: "false" # optional, skip the Code Scanning upload
Uploads findings to the repo's Security → Code Scanning tab via SARIF, in addition to failing the step (unless fail-on-findings: "false").
pre-commit
repos:
- repo: https://github.com/kratos0718/codehound
rev: v1.5.0
hooks:
- id: codehound
The checks
| Code | Name | What it catches | Found in the wild |
|---|---|---|---|
| CH001 | blocking-call-in-async |
A synchronous blocking call (time.sleep, requests.*, subprocess.*) inside an async def — it freezes the entire event loop, stalling every other coroutine. |
agno Couchbase vector store (time.sleep in an async collection-overwrite path) |
| CH002 | mutable-default-argument |
def f(x=[]) — the default is created once and shared across every call, silently leaking state. (flake8-bugbear B006) |
agno toolkits; mem0 proxy & embedder configs |
| CH003 | deprecated-datetime-utcnow |
datetime.utcnow() / utcfromtimestamp() — deprecated since 3.12, returns a naive datetime that lies about its timezone. |
crewAI memory subsystem (9 sites, 4 files) |
| CH004 | deprecated-get-event-loop |
asyncio.get_event_loop() outside a running loop — deprecated since 3.10. |
crewAI structured-tool / Snowflake search tool |
| CH005 | unclosed-file-handle |
f = open(...) with no with and no matching .close() — leaks descriptors until RLIMIT_NOFILE is exhausted. |
agno OpenAITools.transcribe_audio |
| CH006 | floating-task |
asyncio.create_task(...) whose result is discarded — the loop keeps only a weak reference, so the task can be GC'd before it finishes. (Ruff RUF006) |
hardening rule — the most under-caught async bug |
| CH007 | unawaited-coroutine-call |
foo() where foo is async def, called as a bare statement — no await, no scheduling. The coroutine object is created and dropped; the body never runs at all. |
hardening rule — see below |
| 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) |
| 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 |
| 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 |
| 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 |
| CH012 | floating-process |
A non-daemon multiprocessing.Process that's .start()ed but never .join()ed — the process analog of CH009. |
hardening rule |
| 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 |
| 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 |
| CH015 | async-property |
@property/@cached_property wrapping an async def — accessing the attribute hands back an un-awaited coroutine object, not the value. |
hardening rule |
| CH016 | unclosed-socket |
socket.socket(...) stored without a context manager or matching .close() — the socket analog of CH005; leaks the file descriptor. |
hardening rule |
| CH017 | collections-abc-import |
from collections import Mapping (or Sequence, Iterable, …) — the ABCs were removed from collections itself in Python 3.10; they live in collections.abc. |
hardening rule |
| 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 |
| CH019 | removed-getargspec |
inspect.getargspec(...) — removed in Python 3.11 after a decade-plus deprecation; use inspect.signature(...). |
hardening rule |
| 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 |
| 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) |
| 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 |
codehound list prints this from the source of truth.
CH007-CH022 don't have found-and-merged bugs behind all of them the way
CH001-CH006 do - most are hardening rules for well-known Python
correctness gotchas rather than something this project personally
tracked down first. CH010 and CH011 are the exceptions: both found
genuine bugs on their own, in HuggingFace's accelerate, optuna, and
llama_index - see below. Building CH007-CH010 surfaced real false
positives, each one fixed before shipping:
- CH007 (agno): a bare
self.foo()call matched against an unrelated same-namedasync def fooon a different class (agno's own sync/async "twin method" convention, e.g.ZepTools/ZepAsyncTools), and a plain callable parameter shadowed by an unrelated same-named async function hundreds of lines away in the same file. - CH009 (llama_index): a thread handed off through a different
object's attribute, not
self-chat_response.write_response_to_history_thread = thread, withchat_responseitself returned and the thread joined later once the caller finishes consuming the stream. - CH010 (marimo):
sorted(rows, key=lambda row: row[sort_arg.by])insidefor sort_arg in ...- the lambda references the loop variable, butsorted()calls it immediately, synchronously, before the next iteration movessort_argon. Nothing outlives the iteration. This reshaped the check entirely: it now only fires when a lambda is directly stored (.append(...), assignment,return), not merely passed as a callback argument to something that consumes it on the spot.
The accelerate find (CH010): MegatronEngine.get_module_config
builds one callback per model chunk for distributed-training parameter
sync: [lambda x: self.optimizer.finish_param_sync(model_index, x) for model_index in range(len(self.module))]. Every lambda captures
model_index by reference; by the time any of them actually runs, the
comprehension has finished and model_index holds its final value for
all of them - whichever chunk's callback fires, it reports the
last chunk's index. Fixed with the standard default-argument capture
(model_index=model_index) and a regression test that fails on the
pre-fix code (all three callbacks report index 2) and passes on the fix.
PR: huggingface/accelerate#4273.
Scanning ~20 major Python AI/ML frameworks with the fixed CH007/CH008/CH009 turned up zero further real instances beyond the ones above - itself a result, not a null: CH008's bug fails immediately and unconditionally, so it's very unlikely to survive basic testing; CH007 and CH009 both only match same-file names by design, and most real cases of either are plausibly cross-module.
The optuna, llama_index, and litellm finds (CH011): all three are
@lru_cache(maxsize=None) (or a fixed maxsize) decorating an instance
method - a strong reference to self retained for the life of the
process. In optuna, _FanovaTree's node-lookup methods leak every tree
built for a get_param_importances() call (one per random-forest
estimator). In llama_index, VectaraIndex._get_corpus_key leaks the
index itself - and since VectaraIndex.__del__ exists specifically to
close the index's requests.Session on garbage collection, the leak
silently disables that cleanup too, so an HTTP session leaks along with
every index. In litellm, Router._cached_get_model_group_info leaks
every Router that's ever served a request through it - proved this
survives even a correctly-called router.discard() (Router's own
documented cleanup method), so it isn't a "you forgot to clean up" bug.
All three fixed the same way: move the cache from a class-level decorator
to a per-instance one built in __init__, so it's freed with the
instance instead of outliving it - the exact pattern litellm's own
cached_deployment_model_info sibling method already used, just not yet
applied to this one. Each has a regression test verified to fail pre-fix
and pass post-fix.
PRs: optuna/optuna#6859,
run-llama/llama_index#23089,
BerriAI/litellm#41582.
A third CH011 shape needed a guard instead of a PR: dspy's Image (a
pydantic model) caches format() the same way, but Image is frozen
(model_config = ConfigDict(frozen=True)), which makes it hashable and
equal by field value, not identity. Checked directly rather than
assumed: two separately constructed instances with equal fields hash
equal, and the second one's call is served from the first's cache entry
without ever being inserted itself — bounded, value-based memoization,
not a leak. CH011 now recognizes a frozen @dataclass or a frozen
pydantic model and skips it.
CH016 found and fixed its own false positive the day it shipped. The
first real-corpus scan of unclosed-socket turned up three hits in
vllm's distributed process-group rendezvous code - all three sockets were
actually handed off correctly (returned inside a tuple, collected into a
list that's itself returned, passed as an argument into a function that
takes ownership), just not in a shape CH005 (the check this one was
modeled on) ever needed to recognize, since files aren't handed off this
way nearly as often as rendezvous sockets are. Fixed by treating a name
as escaped when it's returned as part of a tuple/list or passed as an
argument to any call. A full corpus rescan afterward found zero remaining
CH016 hits.
CH021 did the same thing twice, minutes apart. The first real-corpus
scan found a false positive in vllm - from .chunk import chunk_gated_delta_rule, a relative import of vllm's own local chunk.py
sibling module, not the removed stdlib chunk. ast.ImportFrom.module
is "chunk" either way; only node.level (the leading-dot count) tells
a relative import apart from an absolute one, and the check wasn't
checking it. Fixed, rescanned, and found a second false positive in
agno: try: import imghdr except ImportError: import filetype - a
real, deliberate fallback that already anticipates this exact removal,
not a bug waiting to happen. Added a second guard: skip an import inside
a try: body whose except catches ImportError (or anything
broader). A full corpus rescan after both fixes found zero remaining
CH021 hits.
Two checks we built and did not ship. exception-chaining (except X as e: raise Y(...) with no from e, discarding the real traceback -
overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
that says more about how common the pattern is than about anything worth
flagging: 1,911 hits across the same ~20-framework corpus. Shipping a
check that fires that often would make every scan result mostly noise,
undermining the "a finding must be defensible" standard the rest of this
tool holds itself to.
cancelled-error-swallowed (except asyncio.CancelledError: pass -
premise: silently swallowing task cancellation is a bug) went further
than volume alone: the first two real hits checked, in two different
frameworks, were both correct code, not bugs. agno's was existing_task. cancel(); try: await existing_task; except CancelledError: pass - the
textbook-correct way to await a task's own cancellation. letta's was an
explicit, logged recovery path (except (CancelledError, ...) as e: logger .info(...); <continue processing>) with a comment literally saying it was
overriding the cancellation on purpose. Unlike the exception-chaining
volume problem, this one meant the check's core premise was false in a
large fraction of real occurrences - so it was deleted outright rather
than kept at a lower confidence tier. Both are: built, measured, and
deliberately left out - a real decision, not an oversight.
How it works
codehound/
├── core.py # file discovery, AST parsing, the Finding/Check contract,
│ # and a child→parent map so checks can ask "what's my
│ # enclosing function / am I inside a `with`?"
├── cli.py # `scan` / `list`, text|json|csv|sarif output, CI-friendly exit codes
├── sarif.py # SARIF 2.1.0 output for GitHub Code Scanning
├── terminal.py # colored text output (auto-disabled for non-TTY / NO_COLOR)
└── checks/ # one small, independently-tested class per rule
├── blocking_async.py (CH001)
├── mutable_defaults.py (CH002)
├── datetime_utcnow.py (CH003)
├── get_event_loop.py (CH004)
├── resource_leak.py (CH005)
├── floating_task.py (CH006)
├── unawaited_coroutine.py (CH007)
├── asyncio_run_in_loop.py (CH008)
├── floating_thread.py (CH009)
├── loop_closure_capture.py (CH010)
├── lru_cache_on_method.py (CH011)
├── floating_process.py (CH012)
├── discarded_future.py (CH013)
├── unprotected_lock.py (CH014)
├── async_property.py (CH015)
├── unclosed_socket.py (CH016)
├── collections_abc_import.py (CH017)
├── removed_asyncio_task_methods.py (CH018)
├── removed_getargspec.py (CH019)
├── bare_except.py (CH020)
├── removed_stdlib_module.py (CH021)
└── asyncio_coroutine_decorator.py (CH022)
Each check receives a parsed ast tree plus the precomputed parent map and returns Findings. Adding a rule is one file + one registry line + a test. See docs/ARCHITECTURE.md for a full walkthrough of the engine, the parent map, and the design decisions.
False-positive discipline is a feature. CH005 won't flag a handle that's returned (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). The test suite asserts both "bad code is flagged" and "correct code is not."
Tests
pip install -e ".[dev]"
pytest -q
Every check has paired tests: the buggy pattern is flagged, and the idiomatic fix is not.
Roadmap
-
awaiton a non-awaited coroutine (missing-await detection) — CH007 - PyPI release —
pip install codehound -
asyncio.run()inside a running loop — CH008 - Non-daemon thread started without a join — CH009 (the thread analog of CH006)
- Loop-variable closure capture in lambdas — CH010
- Pre-commit hook —
.pre-commit-hooks.yaml - GitHub Action —
action.yml, uploads SARIF to Code Scanning - SARIF output —
--format sarif - Colored terminal output (auto-disabled for non-TTY /
NO_COLOR) - Multi-path
scaninvocation (what the pre-commit hook needs) - 20 checks — memory leaks (
lru_cacheon methods), floating processes, discarded futures, unprotected locks, async properties, unclosed sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bareexcept:— CH011-CH020 - 22 checks — removed stdlib modules (
distutils, PEP 594 "dead batteries"), removed@asyncio.coroutinedecorator — CH021-CH022 - Cross-module resolution for CH007/CH009 (currently same-file only)
- Sync HTTP clients constructed inside async request handlers
-
--fixfor the mechanical rules (CH002, CH003, CH004)
License
MIT © Abhinav Tarigoppula
Metadata
Release files for codehound 1.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| codehound-1.5.0.tar.gz | 43.0 kB | Without provenance | Details for codehound-1.5.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| codehound-1.5.0-py3-none-any.whl | Python 3 | none | any | Details for codehound-1.5.0-py3-none-any.whl |
Total release size: 95.4 kB
Release files / codehound-1.5.0.tar.gz
| Download URL | codehound-1.5.0.tar.gz |
|---|---|
| Size | 43.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3e4909d3a9896c9417f107c914187fd7291a921b71ae9ecc8c786bdaebc5fdf9
|
|
BLAKE2b-256 checksum How to use checksums |
030af0aac9d2f4360f643573fa733d9d1fea80811627712064973a0e8a5c21ef
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.9
|
Release files / codehound-1.5.0-py3-none-any.whl
| Download URL | codehound-1.5.0-py3-none-any.whl |
|---|---|
| Size | 52.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
42af365d749489b2cc6eb47a2f52ca14a4737d0f264256cd0b3b7d489e642503
|
|
BLAKE2b-256 checksum How to use checksums |
c6cd5b9d0e70753069a9a777aa0f6ad6656b2fd13438b54fe60b3b8bba96b60d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.9
|
Security for codehound 1.5.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.