codehound 0.1.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.
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ *.egg
13
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abhinav Tarigoppula
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.5
2
+ Name: codehound
3
+ Version: 0.1.0
4
+ Summary: An AST-based static analyzer that hunts real correctness and async-safety bugs in Python code.
5
+ Project-URL: Homepage, https://github.com/kratos0718/codehound
6
+ Project-URL: Issues, https://github.com/kratos0718/codehound/issues
7
+ Author: Abhinav Tarigoppula
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: ast,asyncio,bug-finder,linter,static-analysis
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Requires-Python: >=3.9
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=7; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # πŸ• codehound
22
+
23
+ **An AST-based static analyzer that hunts *real* bugs in large Python codebases β€” every rule is backed by a bug that was actually found and merged into a major open-source AI framework.**
24
+
25
+ [![CI](https://github.com/kratos0718/codehound/actions/workflows/ci.yml/badge.svg)](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
26
+ ![Python](https://img.shields.io/badge/python-3.9%2B-blue)
27
+ ![License](https://img.shields.io/badge/license-MIT-green)
28
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21851079.svg)](https://doi.org/10.5281/zenodo.21851079)
29
+
30
+ 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.
31
+
32
+ Each of the six checks below isn't theoretical. **I wrote it after finding β€” and fixing, via a merged pull request β€” that exact bug in a real, popular framework** (agno 25k⭐, crewAI 30k⭐, mem0, huggingface_hub).
33
+
34
+ ---
35
+
36
+ ## See it in action
37
+
38
+ Pointing `codehound` at [agno](https://github.com/agno-agi/agno) (a 25k⭐ AI agent framework) surfaced real, previously-unreported bugs:
39
+
40
+ ```console
41
+ $ codehound scan agno/libs/agno/agno --select CH001,CH006
42
+
43
+ agno/integrations/discord/client.py:90:26: CH001 `requests.get()` blocks the event loop
44
+ inside async function `on_message`; use the async equivalent.
45
+ agno/tracing/exporter.py:112:16: CH006 `asyncio.create_task(...)` result is discarded;
46
+ keep a reference (the loop only holds a weak ref, so the task may be GC'd mid-run).
47
+
48
+ Found 2 issue(s) (CH001: 1, CH006: 1)
49
+ ```
50
+
51
+ **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`](docs/FINDINGS.md) for the full provenance of every rule.
52
+
53
+ ---
54
+
55
+ ## Why this exists
56
+
57
+ 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.
58
+
59
+ > It found the bugs behind these merged fixes β€” and is built to find the next one.
60
+
61
+ ---
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ # from a clone (modern pip)
67
+ pip install -e .
68
+
69
+ # or run straight from source, no install needed
70
+ PYTHONPATH=src python -m codehound.cli scan path/to/project
71
+ ```
72
+
73
+ ## Usage
74
+
75
+ ```bash
76
+ # scan a project (skips tests/, docs/, examples/, vendored code by default)
77
+ codehound scan path/to/project
78
+
79
+ # only run specific checks
80
+ codehound scan path/to/project --select CH001,CH006
81
+
82
+ # machine-readable output for CI dashboards
83
+ codehound scan path/to/project --format json
84
+ codehound scan path/to/project --format csv
85
+
86
+ # list every available check
87
+ codehound list
88
+ ```
89
+
90
+ `codehound scan` exits **non-zero when it finds issues**, so it drops straight into CI:
91
+
92
+ ```yaml
93
+ - run: codehound scan src # fails the build on a regression
94
+ ```
95
+
96
+ ---
97
+
98
+ ## The checks
99
+
100
+ | Code | Name | What it catches | Found in the wild |
101
+ |------|------|-----------------|-------------------|
102
+ | **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) |
103
+ | **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 |
104
+ | **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) |
105
+ | **CH004** | `deprecated-get-event-loop` | `asyncio.get_event_loop()` outside a running loop β€” deprecated since 3.10. | crewAI structured-tool / Snowflake search tool |
106
+ | **CH005** | `unclosed-file-handle` | `f = open(...)` with no `with` and no matching `.close()` β€” leaks descriptors until `RLIMIT_NOFILE` is exhausted. | agno `OpenAITools.transcribe_audio` |
107
+ | **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 |
108
+
109
+ `codehound list` prints this from the source of truth.
110
+
111
+ ---
112
+
113
+ ## How it works
114
+
115
+ ```
116
+ codehound/
117
+ β”œβ”€β”€ core.py # file discovery, AST parsing, the Finding/Check contract,
118
+ │ # and a child→parent map so checks can ask "what's my
119
+ β”‚ # enclosing function / am I inside a `with`?"
120
+ β”œβ”€β”€ cli.py # `scan` / `list`, text|json|csv output, CI-friendly exit codes
121
+ └── checks/ # one small, independently-tested class per rule
122
+ β”œβ”€β”€ blocking_async.py (CH001)
123
+ β”œβ”€β”€ mutable_defaults.py (CH002)
124
+ β”œβ”€β”€ datetime_utcnow.py (CH003)
125
+ β”œβ”€β”€ get_event_loop.py (CH004)
126
+ β”œβ”€β”€ resource_leak.py (CH005)
127
+ └── floating_task.py (CH006)
128
+ ```
129
+
130
+ 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.
131
+
132
+ **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`. The test suite asserts both "bad code is flagged" and "correct code is not."
133
+
134
+ ---
135
+
136
+ ## Tests
137
+
138
+ ```bash
139
+ pip install -e ".[dev]"
140
+ pytest -q
141
+ ```
142
+
143
+ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic fix is *not*.
144
+
145
+ ---
146
+
147
+ ## Roadmap
148
+
149
+ - [ ] `await` on a non-awaited coroutine (missing-await detection)
150
+ - [ ] Sync HTTP clients constructed inside async request handlers
151
+ - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
152
+ - [ ] Pre-commit hook + PyPI release
153
+
154
+ ---
155
+
156
+ ## License
157
+
158
+ MIT Β© Abhinav Tarigoppula
@@ -0,0 +1,138 @@
1
+ # πŸ• codehound
2
+
3
+ **An AST-based static analyzer that hunts *real* bugs in large Python codebases β€” every rule is backed by a bug that was actually found and merged into a major open-source AI framework.**
4
+
5
+ [![CI](https://github.com/kratos0718/codehound/actions/workflows/ci.yml/badge.svg)](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
6
+ ![Python](https://img.shields.io/badge/python-3.9%2B-blue)
7
+ ![License](https://img.shields.io/badge/license-MIT-green)
8
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21851079.svg)](https://doi.org/10.5281/zenodo.21851079)
9
+
10
+ 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.
11
+
12
+ Each of the six checks below isn't theoretical. **I wrote it after finding β€” and fixing, via a merged pull request β€” that exact bug in a real, popular framework** (agno 25k⭐, crewAI 30k⭐, mem0, huggingface_hub).
13
+
14
+ ---
15
+
16
+ ## See it in action
17
+
18
+ Pointing `codehound` at [agno](https://github.com/agno-agi/agno) (a 25k⭐ AI agent framework) surfaced real, previously-unreported bugs:
19
+
20
+ ```console
21
+ $ codehound scan agno/libs/agno/agno --select CH001,CH006
22
+
23
+ agno/integrations/discord/client.py:90:26: CH001 `requests.get()` blocks the event loop
24
+ inside async function `on_message`; use the async equivalent.
25
+ agno/tracing/exporter.py:112:16: CH006 `asyncio.create_task(...)` result is discarded;
26
+ keep a reference (the loop only holds a weak ref, so the task may be GC'd mid-run).
27
+
28
+ Found 2 issue(s) (CH001: 1, CH006: 1)
29
+ ```
30
+
31
+ **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`](docs/FINDINGS.md) for the full provenance of every rule.
32
+
33
+ ---
34
+
35
+ ## Why this exists
36
+
37
+ 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.
38
+
39
+ > It found the bugs behind these merged fixes β€” and is built to find the next one.
40
+
41
+ ---
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ # from a clone (modern pip)
47
+ pip install -e .
48
+
49
+ # or run straight from source, no install needed
50
+ PYTHONPATH=src python -m codehound.cli scan path/to/project
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ ```bash
56
+ # scan a project (skips tests/, docs/, examples/, vendored code by default)
57
+ codehound scan path/to/project
58
+
59
+ # only run specific checks
60
+ codehound scan path/to/project --select CH001,CH006
61
+
62
+ # machine-readable output for CI dashboards
63
+ codehound scan path/to/project --format json
64
+ codehound scan path/to/project --format csv
65
+
66
+ # list every available check
67
+ codehound list
68
+ ```
69
+
70
+ `codehound scan` exits **non-zero when it finds issues**, so it drops straight into CI:
71
+
72
+ ```yaml
73
+ - run: codehound scan src # fails the build on a regression
74
+ ```
75
+
76
+ ---
77
+
78
+ ## The checks
79
+
80
+ | Code | Name | What it catches | Found in the wild |
81
+ |------|------|-----------------|-------------------|
82
+ | **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) |
83
+ | **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 |
84
+ | **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) |
85
+ | **CH004** | `deprecated-get-event-loop` | `asyncio.get_event_loop()` outside a running loop β€” deprecated since 3.10. | crewAI structured-tool / Snowflake search tool |
86
+ | **CH005** | `unclosed-file-handle` | `f = open(...)` with no `with` and no matching `.close()` β€” leaks descriptors until `RLIMIT_NOFILE` is exhausted. | agno `OpenAITools.transcribe_audio` |
87
+ | **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 |
88
+
89
+ `codehound list` prints this from the source of truth.
90
+
91
+ ---
92
+
93
+ ## How it works
94
+
95
+ ```
96
+ codehound/
97
+ β”œβ”€β”€ core.py # file discovery, AST parsing, the Finding/Check contract,
98
+ │ # and a child→parent map so checks can ask "what's my
99
+ β”‚ # enclosing function / am I inside a `with`?"
100
+ β”œβ”€β”€ cli.py # `scan` / `list`, text|json|csv output, CI-friendly exit codes
101
+ └── checks/ # one small, independently-tested class per rule
102
+ β”œβ”€β”€ blocking_async.py (CH001)
103
+ β”œβ”€β”€ mutable_defaults.py (CH002)
104
+ β”œβ”€β”€ datetime_utcnow.py (CH003)
105
+ β”œβ”€β”€ get_event_loop.py (CH004)
106
+ β”œβ”€β”€ resource_leak.py (CH005)
107
+ └── floating_task.py (CH006)
108
+ ```
109
+
110
+ 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.
111
+
112
+ **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`. The test suite asserts both "bad code is flagged" and "correct code is not."
113
+
114
+ ---
115
+
116
+ ## Tests
117
+
118
+ ```bash
119
+ pip install -e ".[dev]"
120
+ pytest -q
121
+ ```
122
+
123
+ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic fix is *not*.
124
+
125
+ ---
126
+
127
+ ## Roadmap
128
+
129
+ - [ ] `await` on a non-awaited coroutine (missing-await detection)
130
+ - [ ] Sync HTTP clients constructed inside async request handlers
131
+ - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
132
+ - [ ] Pre-commit hook + PyPI release
133
+
134
+ ---
135
+
136
+ ## License
137
+
138
+ MIT Β© Abhinav Tarigoppula
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "codehound"
7
+ version = "0.1.0"
8
+ description = "An AST-based static analyzer that hunts real correctness and async-safety bugs in Python code."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Abhinav Tarigoppula" }]
13
+ keywords = ["static-analysis", "linter", "ast", "asyncio", "bug-finder"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Quality Assurance",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/kratos0718/codehound"
25
+ Issues = "https://github.com/kratos0718/codehound/issues"
26
+
27
+ [project.scripts]
28
+ codehound = "codehound.cli:main"
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=7"]
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/codehound"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = [
38
+ "/src",
39
+ "/tests",
40
+ "/README.md",
41
+ "/LICENSE",
42
+ "/pyproject.toml",
43
+ ]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
@@ -0,0 +1,22 @@
1
+ """codehound - an AST-based static analyzer that hunts real bugs in Python code.
2
+
3
+ Six checks, each backed by a bug that was actually found and fixed in a popular
4
+ open-source AI framework (agno, crewAI, mem0, huggingface_hub).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from codehound.checks import ALL_CHECKS, get_checks
10
+ from codehound.core import Check, Finding, scan_file, scan_path
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = [
15
+ "ALL_CHECKS",
16
+ "get_checks",
17
+ "Check",
18
+ "Finding",
19
+ "scan_file",
20
+ "scan_path",
21
+ "__version__",
22
+ ]
@@ -0,0 +1,32 @@
1
+ """Registry of all available checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from codehound.checks.blocking_async import BlockingCallInAsync
6
+ from codehound.checks.datetime_utcnow import DeprecatedDatetimeUtcnow
7
+ from codehound.checks.floating_task import FloatingTask
8
+ from codehound.checks.get_event_loop import DeprecatedGetEventLoop
9
+ from codehound.checks.mutable_defaults import MutableDefaultArgument
10
+ from codehound.checks.resource_leak import UnclosedFileHandle
11
+ from codehound.core import Check
12
+
13
+ ALL_CHECKS: list[type[Check]] = [
14
+ BlockingCallInAsync,
15
+ MutableDefaultArgument,
16
+ DeprecatedDatetimeUtcnow,
17
+ DeprecatedGetEventLoop,
18
+ UnclosedFileHandle,
19
+ FloatingTask,
20
+ ]
21
+
22
+
23
+ def get_checks(selected: list[str] | None = None) -> list[Check]:
24
+ """Instantiate checks, optionally filtered by a list of codes/names."""
25
+ if not selected:
26
+ return [cls() for cls in ALL_CHECKS]
27
+ wanted = {s.upper() for s in selected}
28
+ out: list[Check] = []
29
+ for cls in ALL_CHECKS:
30
+ if cls.code.upper() in wanted or cls.name.upper() in wanted:
31
+ out.append(cls())
32
+ return out
@@ -0,0 +1,87 @@
1
+ """CH001 - Blocking call inside an async function.
2
+
3
+ A synchronous, blocking call (``time.sleep``, ``requests.get`` ...) executed
4
+ directly inside an ``async def`` freezes the *entire* event loop: every other
5
+ coroutine, task and the agent loop itself stalls for the duration of the call.
6
+ The fix is the async equivalent (``await asyncio.sleep``, ``httpx.AsyncClient``,
7
+ ``loop.run_in_executor`` ...).
8
+
9
+ Real-world: this is exactly the bug fixed in agno's Couchbase vector store,
10
+ where ``time.sleep(1)`` sat inside ``_async_create_collection_and_scope``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+
17
+ from codehound.core import Check, Finding, enclosing_function, is_awaited
18
+
19
+ # (module, attribute) pairs that block the calling thread.
20
+ _BLOCKING_CALLS = {
21
+ ("time", "sleep"),
22
+ ("requests", "get"),
23
+ ("requests", "post"),
24
+ ("requests", "put"),
25
+ ("requests", "delete"),
26
+ ("requests", "patch"),
27
+ ("requests", "head"),
28
+ ("requests", "request"),
29
+ ("subprocess", "run"),
30
+ ("subprocess", "call"),
31
+ ("subprocess", "check_call"),
32
+ ("subprocess", "check_output"),
33
+ ("os", "system"),
34
+ ("urllib.request", "urlopen"),
35
+ }
36
+
37
+
38
+ class BlockingCallInAsync(Check):
39
+ code = "CH001"
40
+ name = "blocking-call-in-async"
41
+ description = "Synchronous blocking call inside an async function freezes the event loop."
42
+
43
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
44
+ findings: list[Finding] = []
45
+ for node in ast.walk(tree):
46
+ if not isinstance(node, ast.Call):
47
+ continue
48
+ func = node.func
49
+ if not isinstance(func, ast.Attribute):
50
+ continue
51
+ value = func.value
52
+ # resolve dotted module prefix (e.g. urllib.request)
53
+ if isinstance(value, ast.Name):
54
+ module = value.id
55
+ elif isinstance(value, ast.Attribute):
56
+ parts = []
57
+ cur = value
58
+ while isinstance(cur, ast.Attribute):
59
+ parts.append(cur.attr)
60
+ cur = cur.value
61
+ if isinstance(cur, ast.Name):
62
+ parts.append(cur.id)
63
+ module = ".".join(reversed(parts))
64
+ else:
65
+ continue
66
+ if (module, func.attr) not in _BLOCKING_CALLS:
67
+ continue
68
+ # An awaited call (e.g. `await client.post(...)`) does not block the
69
+ # event loop, even if the receiver name collides with a sync library.
70
+ if is_awaited(node, parents):
71
+ continue
72
+ fn = enclosing_function(node, parents)
73
+ if fn is None or not isinstance(fn, ast.AsyncFunctionDef):
74
+ continue
75
+ findings.append(
76
+ Finding(
77
+ path=path,
78
+ line=node.lineno,
79
+ col=node.col_offset,
80
+ code=self.code,
81
+ message=(
82
+ f"`{module}.{func.attr}()` blocks the event loop inside "
83
+ f"async function `{fn.name}`; use the async equivalent."
84
+ ),
85
+ )
86
+ )
87
+ return findings
@@ -0,0 +1,52 @@
1
+ """CH003 - Deprecated ``datetime.utcnow()`` / ``datetime.utcfromtimestamp()``.
2
+
3
+ These return a *naive* datetime that silently claims to be local time, a
4
+ long-standing footgun. They are deprecated since Python 3.12 and slated for
5
+ removal. Replacement: ``datetime.now(timezone.utc)`` (or, for a naive-UTC
6
+ drop-in, ``datetime.now(timezone.utc).replace(tzinfo=None)``).
7
+
8
+ Real-world: fixed across crewAI's memory subsystem (9 call sites, 4 files).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+
15
+ from codehound.core import Check, Finding
16
+
17
+ _DEPRECATED = {"utcnow", "utcfromtimestamp"}
18
+
19
+
20
+ class DeprecatedDatetimeUtcnow(Check):
21
+ code = "CH003"
22
+ name = "deprecated-datetime-utcnow"
23
+ description = "datetime.utcnow()/utcfromtimestamp() are deprecated and return naive datetimes."
24
+
25
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
26
+ findings: list[Finding] = []
27
+ for node in ast.walk(tree):
28
+ if not isinstance(node, ast.Call):
29
+ continue
30
+ func = node.func
31
+ if not isinstance(func, ast.Attribute) or func.attr not in _DEPRECATED:
32
+ continue
33
+ # value should be `datetime` (Name) or `<something>.datetime` (Attribute)
34
+ value = func.value
35
+ target_ok = (isinstance(value, ast.Name) and value.id == "datetime") or (
36
+ isinstance(value, ast.Attribute) and value.attr == "datetime"
37
+ )
38
+ if not target_ok:
39
+ continue
40
+ findings.append(
41
+ Finding(
42
+ path=path,
43
+ line=node.lineno,
44
+ col=node.col_offset,
45
+ code=self.code,
46
+ message=(
47
+ f"`datetime.{func.attr}()` is deprecated; use "
48
+ f"`datetime.now(timezone.utc)`."
49
+ ),
50
+ )
51
+ )
52
+ return findings
@@ -0,0 +1,58 @@
1
+ """CH006 - Fire-and-forget asyncio task (Ruff RUF006).
2
+
3
+ ``asyncio.create_task(...)`` / ``asyncio.ensure_future(...)`` whose result is
4
+ discarded is a real, hard-to-debug bug: the event loop keeps only a *weak*
5
+ reference to the task, so it can be garbage-collected before it finishes,
6
+ silently cancelling the work mid-flight. The fix is to keep a strong reference
7
+ (store it in a set, await it, or hand it to a TaskGroup).
8
+
9
+ This finds expression-statement calls (result not assigned/awaited/returned) to
10
+ ``asyncio.create_task``, ``asyncio.ensure_future`` or ``<loop>.create_task``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+
17
+ from codehound.core import Check, Finding
18
+
19
+ _CREATORS = {"create_task", "ensure_future"}
20
+
21
+
22
+ class FloatingTask(Check):
23
+ code = "CH006"
24
+ name = "floating-task"
25
+ description = "Result of create_task()/ensure_future() discarded; task may be GC'd before completion."
26
+
27
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
28
+ findings: list[Finding] = []
29
+ for node in ast.walk(tree):
30
+ # Only bare expression statements: `asyncio.create_task(...)` on its own line.
31
+ if not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call):
32
+ continue
33
+ call = node.value
34
+ func = call.func
35
+ if not isinstance(func, ast.Attribute) or func.attr not in _CREATORS:
36
+ continue
37
+ value = func.value
38
+ if not isinstance(value, ast.Name):
39
+ continue
40
+ owner = value.id
41
+ # asyncio.create_task / asyncio.ensure_future, or a loop-ish receiver.
42
+ is_asyncio = owner == "asyncio"
43
+ is_loop = "loop" in owner.lower()
44
+ if not (is_asyncio or is_loop):
45
+ continue
46
+ findings.append(
47
+ Finding(
48
+ path=path,
49
+ line=node.lineno,
50
+ col=node.col_offset,
51
+ code=self.code,
52
+ message=(
53
+ f"`{owner}.{func.attr}(...)` result is discarded; keep a reference "
54
+ f"(the loop only holds a weak ref, so the task may be GC'd mid-run)."
55
+ ),
56
+ )
57
+ )
58
+ return findings
@@ -0,0 +1,47 @@
1
+ """CH004 - Deprecated ``asyncio.get_event_loop()``.
2
+
3
+ Calling ``asyncio.get_event_loop()`` when there is no running loop is deprecated
4
+ since Python 3.10 and emits a ``DeprecationWarning`` from 3.12. Inside a
5
+ coroutine, use ``asyncio.get_running_loop()``; to run a coroutine from sync
6
+ code, use ``asyncio.run()``.
7
+
8
+ Real-world: fixed in crewAI's structured-tool / Snowflake search tool.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+
15
+ from codehound.core import Check, Finding
16
+
17
+
18
+ class DeprecatedGetEventLoop(Check):
19
+ code = "CH004"
20
+ name = "deprecated-get-event-loop"
21
+ description = "asyncio.get_event_loop() is deprecated outside a running loop."
22
+
23
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
24
+ findings: list[Finding] = []
25
+ for node in ast.walk(tree):
26
+ if not isinstance(node, ast.Call):
27
+ continue
28
+ func = node.func
29
+ if (
30
+ isinstance(func, ast.Attribute)
31
+ and func.attr == "get_event_loop"
32
+ and isinstance(func.value, ast.Name)
33
+ and func.value.id == "asyncio"
34
+ ):
35
+ findings.append(
36
+ Finding(
37
+ path=path,
38
+ line=node.lineno,
39
+ col=node.col_offset,
40
+ code=self.code,
41
+ message=(
42
+ "`asyncio.get_event_loop()` is deprecated; use "
43
+ "`asyncio.get_running_loop()` (in async) or `asyncio.run()`."
44
+ ),
45
+ )
46
+ )
47
+ return findings
@@ -0,0 +1,53 @@
1
+ """CH002 - Mutable default argument (Ruff B006 / flake8-bugbear).
2
+
3
+ ``def f(x=[])`` evaluates the default exactly once, at definition time, so the
4
+ *same* list/dict/set is shared across every call. Mutating it leaks state
5
+ between calls - a classic, subtle source of bugs. Fix: default to ``None`` and
6
+ build the container inside the body.
7
+
8
+ Real-world: fixed across agno's toolkits and mem0's proxy/embedder configs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+
15
+ from codehound.core import Check, Finding
16
+
17
+ _MUTABLE_FACTORIES = {"list", "dict", "set", "Counter", "defaultdict", "OrderedDict", "deque"}
18
+
19
+
20
+ def _is_mutable_default(node: ast.expr) -> bool:
21
+ if isinstance(node, (ast.List, ast.Dict, ast.Set)):
22
+ return True
23
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
24
+ return node.func.id in _MUTABLE_FACTORIES
25
+ return False
26
+
27
+
28
+ class MutableDefaultArgument(Check):
29
+ code = "CH002"
30
+ name = "mutable-default-argument"
31
+ description = "Mutable default argument is shared across all calls to the function."
32
+
33
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
34
+ findings: list[Finding] = []
35
+ for node in ast.walk(tree):
36
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
37
+ continue
38
+ defaults = list(node.args.defaults) + [d for d in node.args.kw_defaults if d is not None]
39
+ for default in defaults:
40
+ if _is_mutable_default(default):
41
+ findings.append(
42
+ Finding(
43
+ path=path,
44
+ line=default.lineno,
45
+ col=default.col_offset,
46
+ code=self.code,
47
+ message=(
48
+ f"Mutable default in `{node.name}` is shared across calls; "
49
+ f"use `None` and create the value inside the body."
50
+ ),
51
+ )
52
+ )
53
+ return findings
@@ -0,0 +1,95 @@
1
+ """CH005 - File handle assigned from ``open()`` without a context manager.
2
+
3
+ ``f = open(path)`` outside a ``with`` block leaks the descriptor unless an
4
+ explicit ``f.close()`` runs on every path. Under load this exhausts
5
+ ``RLIMIT_NOFILE``. CPython's GC eventually reclaims it, but only when the object
6
+ dies - which may be never if it is captured in a long-lived attribute.
7
+
8
+ This check flags an ``open(...)`` whose result is assigned to a name, is not
9
+ inside a ``with``, and has no matching ``.close()`` anywhere in the enclosing
10
+ function.
11
+
12
+ Real-world: fixed in agno's ``OpenAITools.transcribe_audio``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+
19
+ from codehound.core import Check, Finding, enclosing_function, inside_with_statement
20
+
21
+
22
+ def _is_open_call(node: ast.expr) -> bool:
23
+ return isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "open"
24
+
25
+
26
+ def _name_targets(target: ast.expr) -> list[str]:
27
+ if isinstance(target, ast.Name):
28
+ return [target.id]
29
+ if isinstance(target, (ast.Tuple, ast.List)):
30
+ out: list[str] = []
31
+ for elt in target.elts:
32
+ out.extend(_name_targets(elt))
33
+ return out
34
+ return []
35
+
36
+
37
+ def _has_close_call(scope: ast.AST, name: str) -> bool:
38
+ for node in ast.walk(scope):
39
+ if isinstance(node, ast.Call):
40
+ f = node.func
41
+ if (
42
+ isinstance(f, ast.Attribute)
43
+ and f.attr == "close"
44
+ and isinstance(f.value, ast.Name)
45
+ and f.value.id == name
46
+ ):
47
+ return True
48
+ return False
49
+
50
+
51
+ class UnclosedFileHandle(Check):
52
+ code = "CH005"
53
+ name = "unclosed-file-handle"
54
+ description = "open() result stored without a context manager or matching close()."
55
+
56
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
57
+ findings: list[Finding] = []
58
+ for node in ast.walk(tree):
59
+ if not isinstance(node, ast.Assign) or not _is_open_call(node.value):
60
+ continue
61
+ if inside_with_statement(node, parents):
62
+ continue
63
+ names: list[str] = []
64
+ for tgt in node.targets:
65
+ names.extend(_name_targets(tgt))
66
+ if not names:
67
+ continue
68
+ fn = enclosing_function(node, parents)
69
+ if fn is None:
70
+ # module/class-level open() is also suspicious, but we focus on
71
+ # function scopes where close-tracking is reliable.
72
+ continue
73
+ # If the function returns the handle, the caller owns closing it.
74
+ returns_handle = any(
75
+ isinstance(n, ast.Return)
76
+ and isinstance(n.value, ast.Name)
77
+ and n.value.id in names
78
+ for n in ast.walk(fn)
79
+ )
80
+ for name in names:
81
+ if returns_handle or _has_close_call(fn, name):
82
+ continue
83
+ findings.append(
84
+ Finding(
85
+ path=path,
86
+ line=node.lineno,
87
+ col=node.col_offset,
88
+ code=self.code,
89
+ message=(
90
+ f"`{name} = open(...)` in `{fn.name}` is never closed; "
91
+ f"use `with open(...) as {name}:`."
92
+ ),
93
+ )
94
+ )
95
+ return findings
@@ -0,0 +1,102 @@
1
+ """Command-line interface: ``codehound scan <path>``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from codehound import __version__
10
+ from codehound.checks import ALL_CHECKS, get_checks
11
+ from codehound.core import DEFAULT_SKIP_DIRS, scan_path
12
+
13
+
14
+ def _cmd_scan(args: argparse.Namespace) -> int:
15
+ selected = [s.strip() for s in args.select.split(",")] if args.select else None
16
+ checks = get_checks(selected)
17
+ if not checks:
18
+ print(f"No checks matched: {args.select}", file=sys.stderr)
19
+ return 2
20
+
21
+ skip = set(DEFAULT_SKIP_DIRS)
22
+ if args.include_tests:
23
+ skip -= {"tests", "test", "testing"}
24
+ findings = scan_path(args.path, checks, skip_dirs=frozenset(skip))
25
+
26
+ if args.format == "json":
27
+ print(json.dumps([f.as_dict() for f in findings], indent=2))
28
+ elif args.format == "csv":
29
+ print("path,line,col,code,message")
30
+ for f in findings:
31
+ msg = f.message.replace('"', "'")
32
+ print(f'{f.path},{f.line},{f.col},{f.code},"{msg}"')
33
+ else: # text
34
+ for f in findings:
35
+ print(f.as_text())
36
+ counts: dict[str, int] = {}
37
+ for f in findings:
38
+ counts[f.code] = counts.get(f.code, 0) + 1
39
+ summary = ", ".join(f"{k}: {v}" for k, v in sorted(counts.items()))
40
+ print(
41
+ f"\nFound {len(findings)} issue(s)"
42
+ + (f" ({summary})" if summary else ""),
43
+ file=sys.stderr,
44
+ )
45
+
46
+ if findings and not args.exit_zero:
47
+ return 1
48
+ return 0
49
+
50
+
51
+ def _cmd_list(_args: argparse.Namespace) -> int:
52
+ for cls in ALL_CHECKS:
53
+ print(f"{cls.code} {cls.name}\n {cls.description}")
54
+ return 0
55
+
56
+
57
+ def build_parser() -> argparse.ArgumentParser:
58
+ parser = argparse.ArgumentParser(
59
+ prog="codehound",
60
+ description="AST-based static analyzer that hunts real bugs in Python code.",
61
+ )
62
+ parser.add_argument("--version", action="version", version=f"codehound {__version__}")
63
+ sub = parser.add_subparsers(dest="command", required=True)
64
+
65
+ scan = sub.add_parser("scan", help="scan a file or directory for issues")
66
+ scan.add_argument("path", help="file or directory to scan")
67
+ scan.add_argument(
68
+ "--select",
69
+ help="comma-separated check codes/names to run (default: all), e.g. CH001,CH006",
70
+ )
71
+ scan.add_argument(
72
+ "--format",
73
+ choices=["text", "json", "csv"],
74
+ default="text",
75
+ help="output format (default: text)",
76
+ )
77
+ scan.add_argument(
78
+ "--include-tests",
79
+ action="store_true",
80
+ help="also scan tests/ directories (skipped by default)",
81
+ )
82
+ scan.add_argument(
83
+ "--exit-zero",
84
+ action="store_true",
85
+ help="always exit 0, even when issues are found",
86
+ )
87
+ scan.set_defaults(func=_cmd_scan)
88
+
89
+ listp = sub.add_parser("list", help="list available checks")
90
+ listp.set_defaults(func=_cmd_list)
91
+
92
+ return parser
93
+
94
+
95
+ def main(argv: list[str] | None = None) -> int:
96
+ parser = build_parser()
97
+ args = parser.parse_args(argv)
98
+ return args.func(args)
99
+
100
+
101
+ if __name__ == "__main__":
102
+ raise SystemExit(main())
@@ -0,0 +1,206 @@
1
+ """Core scanning engine: file discovery, AST parsing, the Finding/Check contract.
2
+
3
+ The design goal is that every check is a small, independently testable class that
4
+ receives a parsed AST plus a precomputed child->parent map (so checks can ask
5
+ "what is the enclosing function?" cheaply) and returns a list of Findings.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import os
12
+ from dataclasses import dataclass
13
+ from typing import Iterator
14
+
15
+
16
+ # Directories we never want to descend into. Third-party and generated code is
17
+ # not ours to fix, and test/example dirs deliberately contain "bad" patterns.
18
+ DEFAULT_SKIP_DIRS = frozenset(
19
+ {
20
+ ".git",
21
+ ".hg",
22
+ ".svn",
23
+ "__pycache__",
24
+ ".mypy_cache",
25
+ ".pytest_cache",
26
+ ".ruff_cache",
27
+ "node_modules",
28
+ "dist",
29
+ "build",
30
+ ".venv",
31
+ "venv",
32
+ ".tox",
33
+ ".nox",
34
+ "vendor",
35
+ "site-packages",
36
+ "tests",
37
+ "test",
38
+ "testing",
39
+ "examples",
40
+ "example",
41
+ "cookbook",
42
+ "docs",
43
+ }
44
+ )
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Finding:
49
+ """A single rule violation at a specific source location."""
50
+
51
+ path: str
52
+ line: int
53
+ col: int
54
+ code: str
55
+ message: str
56
+
57
+ def as_text(self) -> str:
58
+ return f"{self.path}:{self.line}:{self.col}: {self.code} {self.message}"
59
+
60
+ def as_dict(self) -> dict:
61
+ return {
62
+ "path": self.path,
63
+ "line": self.line,
64
+ "col": self.col,
65
+ "code": self.code,
66
+ "message": self.message,
67
+ }
68
+
69
+
70
+ class Check:
71
+ """Base class for a single static-analysis rule.
72
+
73
+ Subclasses set ``code``/``name``/``description`` and implement ``run``.
74
+ """
75
+
76
+ code: str = ""
77
+ name: str = ""
78
+ description: str = ""
79
+
80
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
81
+ raise NotImplementedError
82
+
83
+
84
+ # --- AST helpers shared by checks -------------------------------------------------
85
+
86
+
87
+ def build_parents(tree: ast.AST) -> dict:
88
+ """Map ``id(child) -> parent_node`` for the whole tree.
89
+
90
+ Python's ``ast`` does not record parents, but several checks need to walk
91
+ upward (e.g. "is this call inside a ``with`` statement / an ``async def``?").
92
+ """
93
+ parents: dict = {}
94
+ for parent in ast.walk(tree):
95
+ for child in ast.iter_child_nodes(parent):
96
+ parents[id(child)] = parent
97
+ return parents
98
+
99
+
100
+ def enclosing_function(node: ast.AST, parents: dict):
101
+ """Return the nearest enclosing FunctionDef/AsyncFunctionDef, or None."""
102
+ cur = node
103
+ while cur is not None:
104
+ p = parents.get(id(cur))
105
+ if p is None:
106
+ return None
107
+ if isinstance(p, (ast.FunctionDef, ast.AsyncFunctionDef)):
108
+ return p
109
+ cur = p
110
+ return None
111
+
112
+
113
+ def is_awaited(node: ast.AST, parents: dict) -> bool:
114
+ """True if the call ``node`` is the direct operand of an ``await``.
115
+
116
+ ``await client.get(...)`` does not block the event loop even though the
117
+ receiver/attribute name might look like a synchronous library (e.g. a local
118
+ variable also named ``requests`` that is actually an async HTTP client).
119
+ """
120
+ parent = parents.get(id(node))
121
+ return isinstance(parent, ast.Await)
122
+
123
+
124
+ def inside_with_statement(node: ast.AST, parents: dict) -> bool:
125
+ """True if ``node`` is (transitively) inside a with/async-with, stopping at
126
+ the enclosing function/class/module boundary."""
127
+ cur = node
128
+ while cur is not None:
129
+ p = parents.get(id(cur))
130
+ if p is None:
131
+ return False
132
+ if isinstance(p, (ast.With, ast.AsyncWith)):
133
+ return True
134
+ if isinstance(p, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Module)):
135
+ return False
136
+ cur = p
137
+ return False
138
+
139
+
140
+ def attr_call_parts(node: ast.AST):
141
+ """For a Call like ``a.b()`` return ``("a", "b")``; for ``a.b.c()`` return
142
+ ``("a.b", "c")``. Returns ``(None, None)`` for non attribute-calls."""
143
+ if not isinstance(node, ast.Call):
144
+ return None, None
145
+ func = node.func
146
+ if not isinstance(func, ast.Attribute):
147
+ return None, None
148
+ attr = func.attr
149
+ value = func.value
150
+ if isinstance(value, ast.Name):
151
+ return value.id, attr
152
+ if isinstance(value, ast.Attribute):
153
+ # best-effort dotted prefix, e.g. urllib.request.urlopen
154
+ parts = []
155
+ cur = value
156
+ while isinstance(cur, ast.Attribute):
157
+ parts.append(cur.attr)
158
+ cur = cur.value
159
+ if isinstance(cur, ast.Name):
160
+ parts.append(cur.id)
161
+ return ".".join(reversed(parts)), attr
162
+ return None, attr
163
+
164
+
165
+ # --- File discovery and orchestration ---------------------------------------------
166
+
167
+
168
+ def iter_python_files(root: str, skip_dirs: frozenset = DEFAULT_SKIP_DIRS) -> Iterator[str]:
169
+ if os.path.isfile(root):
170
+ if root.endswith(".py"):
171
+ yield root
172
+ return
173
+ for dirpath, dirnames, filenames in os.walk(root):
174
+ dirnames[:] = [d for d in dirnames if d not in skip_dirs]
175
+ for fname in filenames:
176
+ if fname.endswith(".py"):
177
+ yield os.path.join(dirpath, fname)
178
+
179
+
180
+ def scan_file(path: str, checks: list[Check]) -> list[Finding]:
181
+ try:
182
+ with open(path, encoding="utf-8") as fh:
183
+ source = fh.read()
184
+ except (OSError, UnicodeDecodeError):
185
+ return []
186
+ try:
187
+ tree = ast.parse(source, filename=path)
188
+ except SyntaxError:
189
+ return []
190
+ parents = build_parents(tree)
191
+ findings: list[Finding] = []
192
+ for check in checks:
193
+ findings.extend(check.run(tree, parents, path))
194
+ return findings
195
+
196
+
197
+ def scan_path(
198
+ root: str,
199
+ checks: list[Check],
200
+ skip_dirs: frozenset = DEFAULT_SKIP_DIRS,
201
+ ) -> list[Finding]:
202
+ findings: list[Finding] = []
203
+ for path in iter_python_files(root, skip_dirs):
204
+ findings.extend(scan_file(path, checks))
205
+ findings.sort(key=lambda f: (f.path, f.line, f.col, f.code))
206
+ return findings
@@ -0,0 +1,172 @@
1
+ """Unit tests for every check, using small inline source snippets.
2
+
3
+ Each test asserts both that the bad pattern is flagged and that a corrected
4
+ version is *not* flagged (no false positives).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+
11
+ from codehound.checks import get_checks
12
+ from codehound.core import build_parents
13
+
14
+
15
+ def _run(code: str, select: list[str]) -> list:
16
+ tree = ast.parse(code)
17
+ parents = build_parents(tree)
18
+ findings = []
19
+ for check in get_checks(select):
20
+ findings.extend(check.run(tree, parents, "<test>"))
21
+ return findings
22
+
23
+
24
+ # --- CH001 blocking-call-in-async -------------------------------------------------
25
+
26
+
27
+ def test_ch001_flags_time_sleep_in_async():
28
+ code = (
29
+ "import time\n"
30
+ "async def f():\n"
31
+ " time.sleep(1)\n"
32
+ )
33
+ findings = _run(code, ["CH001"])
34
+ assert len(findings) == 1
35
+ assert findings[0].code == "CH001"
36
+
37
+
38
+ def test_ch001_ignores_sleep_in_sync_function():
39
+ code = (
40
+ "import time\n"
41
+ "def f():\n"
42
+ " time.sleep(1)\n"
43
+ )
44
+ assert _run(code, ["CH001"]) == []
45
+
46
+
47
+ def test_ch001_ignores_awaited_call_on_sync_named_receiver():
48
+ # A local variable named `requests` that is actually an async client; the
49
+ # call is awaited, so it does not block the loop. (Real false positive seen
50
+ # in AutoGPT's MCP client.)
51
+ code = (
52
+ "async def f(self):\n"
53
+ " requests = AsyncClient()\n"
54
+ " response = await requests.post(self.url, json={})\n"
55
+ )
56
+ assert _run(code, ["CH001"]) == []
57
+
58
+
59
+ def test_ch001_still_flags_unawaited_blocking_call_in_async():
60
+ code = (
61
+ "import requests\n"
62
+ "async def f(url):\n"
63
+ " return requests.get(url)\n"
64
+ )
65
+ assert len(_run(code, ["CH001"])) == 1
66
+
67
+
68
+ def test_ch001_ignores_await_asyncio_sleep():
69
+ code = (
70
+ "import asyncio\n"
71
+ "async def f():\n"
72
+ " await asyncio.sleep(1)\n"
73
+ )
74
+ assert _run(code, ["CH001"]) == []
75
+
76
+
77
+ # --- CH002 mutable-default-argument -----------------------------------------------
78
+
79
+
80
+ def test_ch002_flags_list_default():
81
+ code = "def f(x=[]):\n return x\n"
82
+ findings = _run(code, ["CH002"])
83
+ assert len(findings) == 1
84
+
85
+
86
+ def test_ch002_flags_dict_factory_default():
87
+ code = "def f(x=dict()):\n return x\n"
88
+ assert len(_run(code, ["CH002"])) == 1
89
+
90
+
91
+ def test_ch002_ignores_none_default():
92
+ code = "def f(x=None):\n return x or []\n"
93
+ assert _run(code, ["CH002"]) == []
94
+
95
+
96
+ # --- CH003 deprecated-datetime-utcnow ---------------------------------------------
97
+
98
+
99
+ def test_ch003_flags_utcnow():
100
+ code = "from datetime import datetime\nx = datetime.utcnow()\n"
101
+ assert len(_run(code, ["CH003"])) == 1
102
+
103
+
104
+ def test_ch003_ignores_now_with_tz():
105
+ code = "from datetime import datetime, timezone\nx = datetime.now(timezone.utc)\n"
106
+ assert _run(code, ["CH003"]) == []
107
+
108
+
109
+ # --- CH004 deprecated-get-event-loop ----------------------------------------------
110
+
111
+
112
+ def test_ch004_flags_get_event_loop():
113
+ code = "import asyncio\nloop = asyncio.get_event_loop()\n"
114
+ assert len(_run(code, ["CH004"])) == 1
115
+
116
+
117
+ def test_ch004_ignores_get_running_loop():
118
+ code = "import asyncio\nloop = asyncio.get_running_loop()\n"
119
+ assert _run(code, ["CH004"]) == []
120
+
121
+
122
+ # --- CH005 unclosed-file-handle ---------------------------------------------------
123
+
124
+
125
+ def test_ch005_flags_unclosed_open():
126
+ code = "def f(p):\n fh = open(p)\n return fh.read()\n"
127
+ assert len(_run(code, ["CH005"])) == 1
128
+
129
+
130
+ def test_ch005_ignores_with_open():
131
+ code = "def f(p):\n with open(p) as fh:\n return fh.read()\n"
132
+ assert _run(code, ["CH005"]) == []
133
+
134
+
135
+ def test_ch005_ignores_explicit_close():
136
+ code = "def f(p):\n fh = open(p)\n data = fh.read()\n fh.close()\n return data\n"
137
+ assert _run(code, ["CH005"]) == []
138
+
139
+
140
+ def test_ch005_ignores_returned_handle():
141
+ code = "def open_reader(p):\n fh = open(p, 'rb')\n return fh\n"
142
+ assert _run(code, ["CH005"]) == []
143
+
144
+
145
+ # --- CH006 floating-task ----------------------------------------------------------
146
+
147
+
148
+ def test_ch006_flags_discarded_create_task():
149
+ code = (
150
+ "import asyncio\n"
151
+ "async def f(coro):\n"
152
+ " asyncio.create_task(coro)\n"
153
+ )
154
+ assert len(_run(code, ["CH006"])) == 1
155
+
156
+
157
+ def test_ch006_ignores_referenced_task():
158
+ code = (
159
+ "import asyncio\n"
160
+ "async def f(coro):\n"
161
+ " t = asyncio.create_task(coro)\n"
162
+ " await t\n"
163
+ )
164
+ assert _run(code, ["CH006"]) == []
165
+
166
+
167
+ def test_ch006_ignores_taskgroup_create_task():
168
+ code = (
169
+ "async def f(tg, coro):\n"
170
+ " tg.create_task(coro)\n"
171
+ )
172
+ assert _run(code, ["CH006"]) == []