codehound 1.7.0__tar.gz → 1.8.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 (51) hide show
  1. codehound-1.7.0/README.md → codehound-1.8.0/PKG-INFO +67 -3
  2. codehound-1.7.0/PKG-INFO → codehound-1.8.0/README.md +46 -23
  3. {codehound-1.7.0 → codehound-1.8.0}/pyproject.toml +1 -1
  4. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/__init__.py +9 -2
  5. codehound-1.8.0/src/codehound/cli.py +169 -0
  6. codehound-1.8.0/src/codehound/config.py +85 -0
  7. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/core.py +82 -5
  8. codehound-1.8.0/src/codehound/fixes.py +151 -0
  9. codehound-1.8.0/tests/test_config.py +63 -0
  10. codehound-1.8.0/tests/test_fixes.py +83 -0
  11. codehound-1.8.0/tests/test_noqa.py +97 -0
  12. codehound-1.8.0/tests/test_parallel_scan.py +68 -0
  13. codehound-1.7.0/src/codehound/cli.py +0 -106
  14. {codehound-1.7.0 → codehound-1.8.0}/.gitignore +0 -0
  15. {codehound-1.7.0 → codehound-1.8.0}/LICENSE +0 -0
  16. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/__init__.py +0 -0
  17. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/async_property.py +0 -0
  18. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/asyncio_coroutine_decorator.py +0 -0
  19. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
  20. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/bare_except.py +0 -0
  21. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/blocking_async.py +0 -0
  22. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/collections_abc_import.py +0 -0
  23. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/datetime_utcnow.py +0 -0
  24. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/discarded_future.py +0 -0
  25. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/finally_swallows_exception.py +0 -0
  26. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/floating_process.py +0 -0
  27. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/floating_task.py +0 -0
  28. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/floating_thread.py +0 -0
  29. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/floating_timer.py +0 -0
  30. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/get_event_loop.py +0 -0
  31. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/is_literal_comparison.py +0 -0
  32. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/loop_closure_capture.py +0 -0
  33. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/lru_cache_on_async_function.py +0 -0
  34. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/lru_cache_on_method.py +0 -0
  35. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/mutable_class_attribute.py +0 -0
  36. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/mutable_defaults.py +0 -0
  37. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
  38. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/removed_getargspec.py +0 -0
  39. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/removed_stdlib_attribute.py +0 -0
  40. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/removed_stdlib_module.py +0 -0
  41. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/resource_leak.py +0 -0
  42. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
  43. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/unclosed_pool.py +0 -0
  44. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/unclosed_socket.py +0 -0
  45. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/unittest_deprecated_alias.py +0 -0
  46. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/unprotected_lock.py +0 -0
  47. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/unwaited_subprocess.py +0 -0
  48. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/sarif.py +0 -0
  49. {codehound-1.7.0 → codehound-1.8.0}/src/codehound/terminal.py +0 -0
  50. {codehound-1.7.0 → codehound-1.8.0}/tests/test_checks.py +0 -0
  51. {codehound-1.7.0 → codehound-1.8.0}/tests/test_output_formats.py +0 -0
@@ -1,3 +1,24 @@
1
+ Metadata-Version: 2.5
2
+ Name: codehound
3
+ Version: 1.8.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
+ Requires-Dist: tomli>=2; (python_version < '3.11') and extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
1
22
  <p align="center">
2
23
  <img src="assets/logo.png" alt="codehound" width="220">
3
24
  </p>
@@ -57,6 +78,8 @@ What actually seems to be missing elsewhere, as far as I've been able to find:
57
78
 
58
79
  And a difference in kind, not just coverage: every check here is checked against a real corpus, not just reasoned about. [`docs/FINDINGS.md`](docs/FINDINGS.md) has a running ledger of every false positive found while building each check (with the exact framework and line), and two checks that were built, measured, and **rejected outright** when the pattern turned out to be either too common to be a defensible finding (1,911 hits) or premised on something that was actually false (the first two real hits checked turned out to be correct code). I haven't found another static-analysis tool — commercial or open-source — that publishes this kind of "we built it, checked it against real code, and turned it down" ledger. Most tools that market themselves on "catches real bugs" (Greptile, Qodo, CodeRabbit) report an aggregate detection-rate benchmark, not per-rule provenance you can click through to an actual merged fix.
59
80
 
81
+ **Closing the "toy project" gaps, honestly.** Ruff is a single, fast binary with editor integrations, a plugin-free config file, autofix, and inline suppression - table stakes for a tool people actually adopt, not just admire. `codehound` isn't going to out-perform a Rust tool by staying pure Python, but it now has the parts of that list that don't require rewriting the whole thing: a `[tool.codehound]` block in `pyproject.toml`, `# noqa`/`# noqa: CH001` inline suppression (same syntax flake8/ruff already use, so it doesn't collide with either), `--fix` for the two checks where the rewrite is genuinely unambiguous (CH017 always, CH004 only inside `async def` - guessing wrong on the rest would be worse than not fixing them), and scanning parallelized across a process pool once there's enough files to make that worth it. Measured, not claimed: a full scan of HuggingFace's `transformers` (thousands of files) went from 57 seconds to 12 - verified byte-identical against the sequential result first, not just "seems faster."
82
+
60
83
  ---
61
84
 
62
85
  ## Install
@@ -65,7 +88,7 @@ And a difference in kind, not just coverage: every check here is checked against
65
88
  pip install codehound
66
89
  ```
67
90
 
68
- Zero dependencies — it's ~3,600 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
91
+ Zero dependencies — it's ~4,000 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
69
92
 
70
93
  <details>
71
94
  <summary>From a clone instead (for development)</summary>
@@ -91,6 +114,9 @@ codehound scan file1.py file2.py src/
91
114
  # only run specific checks
92
115
  codehound scan path/to/project --select CH001,CH006
93
116
 
117
+ # also skip extra directories beyond the built-in defaults
118
+ codehound scan path/to/project --exclude migrations,generated
119
+
94
120
  # machine-readable output for CI dashboards
95
121
  codehound scan path/to/project --format json
96
122
  codehound scan path/to/project --format csv
@@ -98,6 +124,9 @@ codehound scan path/to/project --format csv
98
124
  # GitHub Code Scanning (Security tab) can ingest this directly
99
125
  codehound scan path/to/project --format sarif > results.sarif
100
126
 
127
+ # rewrite the fixable findings in place, then report what's left
128
+ codehound scan path/to/project --fix
129
+
101
130
  # list every available check
102
131
  codehound list
103
132
  ```
@@ -108,6 +137,32 @@ codehound list
108
137
  - run: codehound scan src # fails the build on a regression
109
138
  ```
110
139
 
140
+ A finding you've reviewed and want to keep suppresses the same way flake8/ruff findings do - a trailing `# noqa` (everything on that line) or `# noqa: CH001` (just that code):
141
+
142
+ ```python
143
+ time.sleep(1) # noqa: CH001 - deliberate; this branch only runs at startup, before the loop exists
144
+ ```
145
+
146
+ ### Config file
147
+
148
+ Drop defaults into `[tool.codehound]` in `pyproject.toml` so you don't have to repeat flags on every invocation - explicit CLI flags always win over these:
149
+
150
+ ```toml
151
+ [tool.codehound]
152
+ select = ["CH001", "CH006"] # same as --select
153
+ exclude = ["migrations"] # extra directories to skip, merged with the built-in defaults
154
+ paths = ["src"] # what `codehound scan` (no path args) scans
155
+ ```
156
+
157
+ Requires Python 3.11+ to load (uses the standard-library `tomllib`) - on 3.9/3.10 the config file is silently skipped and every flag still works exactly the same via the CLI, since nothing about codehound itself depends on being able to read it.
158
+
159
+ ### `--fix`
160
+
161
+ Only two checks ship an autofix, and deliberately so - every other check either needs a judgment call (is this "leak" actually intentional?) or an import that may or may not already be in scope, and guessing wrong there is worse than just reporting the finding:
162
+
163
+ - **CH017** - `collections.<ABC>` → `collections.abc.<ABC>`, a pure rename, always safe.
164
+ - **CH004** - `asyncio.get_event_loop()` → `asyncio.get_running_loop()`, but *only* inside an `async def`. Outside one, `get_running_loop()` raises where `get_event_loop()` wouldn't, so those calls are left as detection-only.
165
+
111
166
  ### GitHub Action
112
167
 
113
168
  ```yaml
@@ -126,7 +181,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
126
181
  ```yaml
127
182
  repos:
128
183
  - repo: https://github.com/kratos0718/codehound
129
- rev: v1.7.0
184
+ rev: v1.8.0
130
185
  hooks:
131
186
  - id: codehound
132
187
  ```
@@ -455,9 +510,18 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
455
510
  subprocesses, floating timers — CH023-CH028
456
511
  - [x] 31 checks — `finally:` blocks that swallow exceptions, `lru_cache`
457
512
  on async functions, unclosed `multiprocessing.Pool` — CH029-CH031
513
+ - [x] Inline `# noqa` / `# noqa: CH001` suppression
514
+ - [x] `[tool.codehound]` project config in `pyproject.toml` (`select`,
515
+ `exclude`, `paths` — Python 3.11+ to load, every flag still works
516
+ without it on 3.9/3.10)
517
+ - [x] `--fix` — CH017 always, CH004 only inside `async def` (CH002/CH003
518
+ turned out to need judgment calls or import-injection this tool
519
+ won't guess at, so they stay detection-only; see docs/ARCHITECTURE.md)
458
520
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
459
521
  - [ ] Extend CH001 to a curated denylist of sync AI/agent SDK client calls inside async functions (vector-DB clients, LLM SDKs) — the gap flake8-async's stdlib-only denylist leaves open
460
- - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
522
+ - [x] Parallelize scanning across files for large codebases a full
523
+ HuggingFace transformers scan went from 57s to 12s (measured,
524
+ byte-identical output verified against the sequential run)
461
525
 
462
526
  ---
463
527
 
@@ -1,23 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: codehound
3
- Version: 1.7.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
1
  <p align="center">
22
2
  <img src="assets/logo.png" alt="codehound" width="220">
23
3
  </p>
@@ -77,6 +57,8 @@ What actually seems to be missing elsewhere, as far as I've been able to find:
77
57
 
78
58
  And a difference in kind, not just coverage: every check here is checked against a real corpus, not just reasoned about. [`docs/FINDINGS.md`](docs/FINDINGS.md) has a running ledger of every false positive found while building each check (with the exact framework and line), and two checks that were built, measured, and **rejected outright** when the pattern turned out to be either too common to be a defensible finding (1,911 hits) or premised on something that was actually false (the first two real hits checked turned out to be correct code). I haven't found another static-analysis tool — commercial or open-source — that publishes this kind of "we built it, checked it against real code, and turned it down" ledger. Most tools that market themselves on "catches real bugs" (Greptile, Qodo, CodeRabbit) report an aggregate detection-rate benchmark, not per-rule provenance you can click through to an actual merged fix.
79
59
 
60
+ **Closing the "toy project" gaps, honestly.** Ruff is a single, fast binary with editor integrations, a plugin-free config file, autofix, and inline suppression - table stakes for a tool people actually adopt, not just admire. `codehound` isn't going to out-perform a Rust tool by staying pure Python, but it now has the parts of that list that don't require rewriting the whole thing: a `[tool.codehound]` block in `pyproject.toml`, `# noqa`/`# noqa: CH001` inline suppression (same syntax flake8/ruff already use, so it doesn't collide with either), `--fix` for the two checks where the rewrite is genuinely unambiguous (CH017 always, CH004 only inside `async def` - guessing wrong on the rest would be worse than not fixing them), and scanning parallelized across a process pool once there's enough files to make that worth it. Measured, not claimed: a full scan of HuggingFace's `transformers` (thousands of files) went from 57 seconds to 12 - verified byte-identical against the sequential result first, not just "seems faster."
61
+
80
62
  ---
81
63
 
82
64
  ## Install
@@ -85,7 +67,7 @@ And a difference in kind, not just coverage: every check here is checked against
85
67
  pip install codehound
86
68
  ```
87
69
 
88
- Zero dependencies — it's ~3,600 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
70
+ Zero dependencies — it's ~4,000 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
89
71
 
90
72
  <details>
91
73
  <summary>From a clone instead (for development)</summary>
@@ -111,6 +93,9 @@ codehound scan file1.py file2.py src/
111
93
  # only run specific checks
112
94
  codehound scan path/to/project --select CH001,CH006
113
95
 
96
+ # also skip extra directories beyond the built-in defaults
97
+ codehound scan path/to/project --exclude migrations,generated
98
+
114
99
  # machine-readable output for CI dashboards
115
100
  codehound scan path/to/project --format json
116
101
  codehound scan path/to/project --format csv
@@ -118,6 +103,9 @@ codehound scan path/to/project --format csv
118
103
  # GitHub Code Scanning (Security tab) can ingest this directly
119
104
  codehound scan path/to/project --format sarif > results.sarif
120
105
 
106
+ # rewrite the fixable findings in place, then report what's left
107
+ codehound scan path/to/project --fix
108
+
121
109
  # list every available check
122
110
  codehound list
123
111
  ```
@@ -128,6 +116,32 @@ codehound list
128
116
  - run: codehound scan src # fails the build on a regression
129
117
  ```
130
118
 
119
+ A finding you've reviewed and want to keep suppresses the same way flake8/ruff findings do - a trailing `# noqa` (everything on that line) or `# noqa: CH001` (just that code):
120
+
121
+ ```python
122
+ time.sleep(1) # noqa: CH001 - deliberate; this branch only runs at startup, before the loop exists
123
+ ```
124
+
125
+ ### Config file
126
+
127
+ Drop defaults into `[tool.codehound]` in `pyproject.toml` so you don't have to repeat flags on every invocation - explicit CLI flags always win over these:
128
+
129
+ ```toml
130
+ [tool.codehound]
131
+ select = ["CH001", "CH006"] # same as --select
132
+ exclude = ["migrations"] # extra directories to skip, merged with the built-in defaults
133
+ paths = ["src"] # what `codehound scan` (no path args) scans
134
+ ```
135
+
136
+ Requires Python 3.11+ to load (uses the standard-library `tomllib`) - on 3.9/3.10 the config file is silently skipped and every flag still works exactly the same via the CLI, since nothing about codehound itself depends on being able to read it.
137
+
138
+ ### `--fix`
139
+
140
+ Only two checks ship an autofix, and deliberately so - every other check either needs a judgment call (is this "leak" actually intentional?) or an import that may or may not already be in scope, and guessing wrong there is worse than just reporting the finding:
141
+
142
+ - **CH017** - `collections.<ABC>` → `collections.abc.<ABC>`, a pure rename, always safe.
143
+ - **CH004** - `asyncio.get_event_loop()` → `asyncio.get_running_loop()`, but *only* inside an `async def`. Outside one, `get_running_loop()` raises where `get_event_loop()` wouldn't, so those calls are left as detection-only.
144
+
131
145
  ### GitHub Action
132
146
 
133
147
  ```yaml
@@ -146,7 +160,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
146
160
  ```yaml
147
161
  repos:
148
162
  - repo: https://github.com/kratos0718/codehound
149
- rev: v1.7.0
163
+ rev: v1.8.0
150
164
  hooks:
151
165
  - id: codehound
152
166
  ```
@@ -475,9 +489,18 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
475
489
  subprocesses, floating timers — CH023-CH028
476
490
  - [x] 31 checks — `finally:` blocks that swallow exceptions, `lru_cache`
477
491
  on async functions, unclosed `multiprocessing.Pool` — CH029-CH031
492
+ - [x] Inline `# noqa` / `# noqa: CH001` suppression
493
+ - [x] `[tool.codehound]` project config in `pyproject.toml` (`select`,
494
+ `exclude`, `paths` — Python 3.11+ to load, every flag still works
495
+ without it on 3.9/3.10)
496
+ - [x] `--fix` — CH017 always, CH004 only inside `async def` (CH002/CH003
497
+ turned out to need judgment calls or import-injection this tool
498
+ won't guess at, so they stay detection-only; see docs/ARCHITECTURE.md)
478
499
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
479
500
  - [ ] Extend CH001 to a curated denylist of sync AI/agent SDK client calls inside async functions (vector-DB clients, LLM SDKs) — the gap flake8-async's stdlib-only denylist leaves open
480
- - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
501
+ - [x] Parallelize scanning across files for large codebases a full
502
+ HuggingFace transformers scan went from 57s to 12s (measured,
503
+ byte-identical output verified against the sequential run)
481
504
 
482
505
  ---
483
506
 
@@ -28,7 +28,7 @@ Issues = "https://github.com/kratos0718/codehound/issues"
28
28
  codehound = "codehound.cli:main"
29
29
 
30
30
  [project.optional-dependencies]
31
- dev = ["pytest>=7"]
31
+ dev = ["pytest>=7", "tomli>=2; python_version < '3.11'"]
32
32
 
33
33
  [tool.hatch.version]
34
34
  path = "src/codehound/__init__.py"
@@ -6,14 +6,20 @@ found and fixed (or opened as a PR) in a popular open-source AI framework
6
6
  (CH007-CH009, CH012-CH031) are hardening rules verified against real
7
7
  false positives across a ~29-framework validation corpus instead - see
8
8
  docs/FINDINGS.md.
9
+
10
+ Also has the parts of a production-grade linter that don't require
11
+ rewriting the whole thing in Rust: inline `# noqa` suppression,
12
+ `[tool.codehound]` project config, `--fix` for the checks where the
13
+ rewrite is genuinely unambiguous, and scanning parallelized across a
14
+ process pool for large codebases.
9
15
  """
10
16
 
11
17
  from __future__ import annotations
12
18
 
13
19
  from codehound.checks import ALL_CHECKS, get_checks
14
- from codehound.core import Check, Finding, scan_file, scan_path
20
+ from codehound.core import Check, Finding, scan_file, scan_files, scan_path
15
21
 
16
- __version__ = "1.7.0"
22
+ __version__ = "1.8.0"
17
23
 
18
24
  __all__ = [
19
25
  "ALL_CHECKS",
@@ -21,6 +27,7 @@ __all__ = [
21
27
  "Check",
22
28
  "Finding",
23
29
  "scan_file",
30
+ "scan_files",
24
31
  "scan_path",
25
32
  "__version__",
26
33
  ]
@@ -0,0 +1,169 @@
1
+ """Command-line interface: ``codehound scan <path>``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import ast
7
+ import json
8
+ import sys
9
+
10
+ from codehound import __version__
11
+ from codehound.checks import ALL_CHECKS, get_checks
12
+ from codehound.config import load_config
13
+ from codehound.core import DEFAULT_SKIP_DIRS, build_parents, iter_python_files, scan_files
14
+ from codehound.fixes import fix_source
15
+ from codehound.sarif import to_sarif
16
+ from codehound.terminal import format_findings_text, format_summary
17
+
18
+
19
+ def _apply_fixes(paths: list[str], skip_dirs: frozenset) -> tuple[int, int]:
20
+ """Rewrite every fixable finding in place. Returns ``(files_changed, edit_count)``."""
21
+ files_changed = 0
22
+ total_edits = 0
23
+ for root in paths:
24
+ for path in iter_python_files(root, skip_dirs):
25
+ try:
26
+ with open(path, encoding="utf-8") as fh:
27
+ source = fh.read()
28
+ except (OSError, UnicodeDecodeError):
29
+ continue
30
+ try:
31
+ tree = ast.parse(source, filename=path)
32
+ except SyntaxError:
33
+ continue
34
+ parents = build_parents(tree)
35
+ new_source, count = fix_source(source, tree, parents)
36
+ if count:
37
+ with open(path, "w", encoding="utf-8") as fh:
38
+ fh.write(new_source)
39
+ files_changed += 1
40
+ total_edits += count
41
+ return files_changed, total_edits
42
+
43
+
44
+ def _cmd_scan(args: argparse.Namespace) -> int:
45
+ config = load_config()
46
+
47
+ select_arg = args.select or (",".join(config.select) if config.select else None)
48
+ selected = [s.strip() for s in select_arg.split(",")] if select_arg else None
49
+ checks = get_checks(selected)
50
+ if not checks:
51
+ print(f"No checks matched: {select_arg}", file=sys.stderr)
52
+ return 2
53
+
54
+ paths = args.paths or config.paths or ["."]
55
+
56
+ skip = set(DEFAULT_SKIP_DIRS)
57
+ if args.include_tests:
58
+ skip -= {"tests", "test", "testing"}
59
+ skip |= set(config.exclude)
60
+ if args.exclude:
61
+ skip |= {e.strip() for e in args.exclude.split(",") if e.strip()}
62
+
63
+ if args.fix:
64
+ files_changed, edit_count = _apply_fixes(paths, frozenset(skip))
65
+ if edit_count:
66
+ print(f"Fixed {edit_count} issue(s) in {files_changed} file(s).", file=sys.stderr)
67
+
68
+ all_files: list[str] = []
69
+ for path in paths:
70
+ all_files.extend(iter_python_files(path, frozenset(skip)))
71
+ findings = scan_files(all_files, checks)
72
+
73
+ if args.format == "json":
74
+ print(json.dumps([f.as_dict() for f in findings], indent=2))
75
+ elif args.format == "csv":
76
+ print("path,line,col,code,message")
77
+ for f in findings:
78
+ msg = f.message.replace('"', "'")
79
+ print(f'{f.path},{f.line},{f.col},{f.code},"{msg}"')
80
+ elif args.format == "sarif":
81
+ print(json.dumps(to_sarif(findings, ALL_CHECKS), indent=2))
82
+ else: # text
83
+ for line in format_findings_text(findings):
84
+ print(line)
85
+ print(f"\n{format_summary(findings)}", file=sys.stderr)
86
+
87
+ if findings and not args.exit_zero:
88
+ return 1
89
+ return 0
90
+
91
+
92
+ def _cmd_list(_args: argparse.Namespace) -> int:
93
+ for cls in ALL_CHECKS:
94
+ print(f"{cls.code} {cls.name}\n {cls.description}")
95
+ return 0
96
+
97
+
98
+ def build_parser() -> argparse.ArgumentParser:
99
+ parser = argparse.ArgumentParser(
100
+ prog="codehound",
101
+ description="AST-based static analyzer that hunts real bugs in Python code.",
102
+ )
103
+ parser.add_argument("--version", action="version", version=f"codehound {__version__}")
104
+ sub = parser.add_subparsers(dest="command", required=True)
105
+
106
+ scan = sub.add_parser("scan", help="scan one or more files/directories for issues")
107
+ scan.add_argument(
108
+ "paths",
109
+ nargs="*",
110
+ metavar="path",
111
+ help=(
112
+ "file(s) or director(y/ies) to scan (accepts multiple, for pre-commit). "
113
+ "Defaults to `paths` in pyproject.toml's [tool.codehound], then `.`"
114
+ ),
115
+ )
116
+ scan.add_argument(
117
+ "--select",
118
+ help=(
119
+ "comma-separated check codes/names to run (default: `select` in "
120
+ "pyproject.toml's [tool.codehound], else all), e.g. CH001,CH006"
121
+ ),
122
+ )
123
+ scan.add_argument(
124
+ "--exclude",
125
+ help=(
126
+ "comma-separated extra directory names to skip, merged with the "
127
+ "built-in defaults and pyproject.toml's [tool.codehound] `exclude`"
128
+ ),
129
+ )
130
+ scan.add_argument(
131
+ "--format",
132
+ choices=["text", "json", "csv", "sarif"],
133
+ default="text",
134
+ help="output format (default: text; sarif for GitHub Code Scanning)",
135
+ )
136
+ scan.add_argument(
137
+ "--include-tests",
138
+ action="store_true",
139
+ help="also scan tests/ directories (skipped by default)",
140
+ )
141
+ scan.add_argument(
142
+ "--exit-zero",
143
+ action="store_true",
144
+ help="always exit 0, even when issues are found",
145
+ )
146
+ scan.add_argument(
147
+ "--fix",
148
+ action="store_true",
149
+ help=(
150
+ "rewrite fixable findings in place before reporting (currently CH017, "
151
+ "and CH004 only inside async functions - see docs/ARCHITECTURE.md)"
152
+ ),
153
+ )
154
+ scan.set_defaults(func=_cmd_scan)
155
+
156
+ listp = sub.add_parser("list", help="list available checks")
157
+ listp.set_defaults(func=_cmd_list)
158
+
159
+ return parser
160
+
161
+
162
+ def main(argv: list[str] | None = None) -> int:
163
+ parser = build_parser()
164
+ args = parser.parse_args(argv)
165
+ return args.func(args)
166
+
167
+
168
+ if __name__ == "__main__":
169
+ raise SystemExit(main())
@@ -0,0 +1,85 @@
1
+ """Project config: ``[tool.codehound]`` in ``pyproject.toml``.
2
+
3
+ Loaded with the standard-library ``tomllib`` (Python 3.11+), falling back
4
+ to the ``tomli`` backport *only if it happens to already be installed*
5
+ (it's listed in the ``dev`` extras so the test suite can exercise config
6
+ loading on 3.9/3.10 too) - never a required install. If neither is
7
+ available, config loading is silently skipped rather than failing: the
8
+ CLI still works identically with explicit flags either way, so a missing
9
+ TOML parser never blocks a scan, it only means the config-file
10
+ convenience isn't available on that Python. This keeps the
11
+ zero-runtime-dependency guarantee: nothing here is ever a hard
12
+ requirement to run `codehound scan`.
13
+
14
+ Recognized keys, all optional:
15
+
16
+ [tool.codehound]
17
+ select = ["CH001", "CH006"] # same as --select
18
+ exclude = ["migrations"] # extra directory names to skip, merged
19
+ # with the built-in DEFAULT_SKIP_DIRS
20
+ paths = ["src"] # default paths when none given on the CLI
21
+
22
+ Explicit CLI flags always take precedence over the config file - the
23
+ config only fills in what wasn't passed on the command line.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+
30
+ try:
31
+ import tomllib
32
+ except ImportError: # Python < 3.11
33
+ try:
34
+ import tomli as tomllib # type: ignore[import-not-found,no-redef]
35
+ except ImportError:
36
+ tomllib = None # type: ignore[assignment]
37
+
38
+
39
+ class CodehoundConfig:
40
+ def __init__(
41
+ self,
42
+ select: list[str] | None = None,
43
+ exclude: list[str] | None = None,
44
+ paths: list[str] | None = None,
45
+ ) -> None:
46
+ self.select = select
47
+ self.exclude = exclude or []
48
+ self.paths = paths
49
+
50
+
51
+ _EMPTY = CodehoundConfig()
52
+
53
+
54
+ def load_config(start_dir: str = ".") -> CodehoundConfig:
55
+ """Read ``[tool.codehound]`` from ``pyproject.toml`` in ``start_dir``.
56
+
57
+ Returns an empty config (every field ``None``/``[]``) if the file
58
+ doesn't exist, has no ``[tool.codehound]`` table, or ``tomllib`` isn't
59
+ available on this Python version - never raises for any of those.
60
+ """
61
+ if tomllib is None:
62
+ return _EMPTY
63
+
64
+ pyproject_path = os.path.join(start_dir, "pyproject.toml")
65
+ if not os.path.isfile(pyproject_path):
66
+ return _EMPTY
67
+
68
+ try:
69
+ with open(pyproject_path, "rb") as fh:
70
+ data = tomllib.load(fh)
71
+ except (OSError, tomllib.TOMLDecodeError):
72
+ return _EMPTY
73
+
74
+ table = data.get("tool", {}).get("codehound", {})
75
+ if not isinstance(table, dict):
76
+ return _EMPTY
77
+
78
+ select = table.get("select")
79
+ exclude = table.get("exclude")
80
+ paths = table.get("paths")
81
+ return CodehoundConfig(
82
+ select=list(select) if isinstance(select, list) else None,
83
+ exclude=list(exclude) if isinstance(exclude, list) else None,
84
+ paths=list(paths) if isinstance(paths, list) else None,
85
+ )
@@ -9,9 +9,16 @@ from __future__ import annotations
9
9
 
10
10
  import ast
11
11
  import os
12
+ import re
13
+ from concurrent.futures import ProcessPoolExecutor
12
14
  from dataclasses import dataclass
13
15
  from typing import Iterator
14
16
 
17
+ # Below this many files, a process pool's startup cost isn't worth it -
18
+ # sequential scanning wins on small inputs (a handful of files, which is
19
+ # also exactly pre-commit's typical invocation shape).
20
+ _MIN_FILES_FOR_PARALLEL = 16
21
+
15
22
 
16
23
  # Directories we never want to descend into. Third-party and generated code is
17
24
  # not ours to fix, and test/example dirs deliberately contain "bad" patterns.
@@ -181,6 +188,44 @@ def attr_call_parts(node: ast.AST):
181
188
  return None, attr
182
189
 
183
190
 
191
+ # --- Inline suppression (# noqa) --------------------------------------------------
192
+
193
+ _NOQA_RE = re.compile(r"#\s*noqa\b(?::\s*(?P<codes>[A-Za-z0-9_, ]+))?", re.IGNORECASE)
194
+
195
+
196
+ def _parse_noqa_lines(source: str) -> dict[int, set[str] | None]:
197
+ """Map 1-indexed line number -> suppressed codes, or ``None`` for a bare
198
+ ``# noqa`` that suppresses every finding on that line.
199
+
200
+ Same convention flake8/ruff use: a trailing ``# noqa`` (optionally
201
+ ``# noqa: CH001,CH002``) on the offending physical line. This is a
202
+ text-level scan, not an AST one - a real ``# noqa`` inside a string
203
+ literal would false-suppress, but that's the same trade-off every
204
+ other tool using this convention already makes, and matching their
205
+ exact syntax means a codebase can suppress codehound and flake8/ruff
206
+ findings side by side on the same line.
207
+ """
208
+ suppressed: dict[int, set[str] | None] = {}
209
+ for lineno, line in enumerate(source.splitlines(), start=1):
210
+ match = _NOQA_RE.search(line)
211
+ if match is None:
212
+ continue
213
+ codes_text = match.group("codes")
214
+ if codes_text is None:
215
+ suppressed[lineno] = None
216
+ else:
217
+ codes = {c.strip().upper() for c in re.split(r"[,\s]+", codes_text) if c.strip()}
218
+ suppressed[lineno] = codes
219
+ return suppressed
220
+
221
+
222
+ def _is_suppressed(finding: Finding, noqa_lines: dict[int, set[str] | None]) -> bool:
223
+ if finding.line not in noqa_lines:
224
+ return False
225
+ codes = noqa_lines[finding.line]
226
+ return codes is None or finding.code.upper() in codes
227
+
228
+
184
229
  # --- File discovery and orchestration ---------------------------------------------
185
230
 
186
231
 
@@ -210,16 +255,48 @@ def scan_file(path: str, checks: list[Check]) -> list[Finding]:
210
255
  findings: list[Finding] = []
211
256
  for check in checks:
212
257
  findings.extend(check.run(tree, parents, path))
258
+ noqa_lines = _parse_noqa_lines(source)
259
+ if noqa_lines:
260
+ findings = [f for f in findings if not _is_suppressed(f, noqa_lines)]
213
261
  return findings
214
262
 
215
263
 
216
- def scan_path(
217
- root: str,
264
+ def _scan_file_worker(args: tuple[str, list[Check]]) -> list[Finding]:
265
+ path, checks = args
266
+ return scan_file(path, checks)
267
+
268
+
269
+ def scan_files(
270
+ paths: list[str],
218
271
  checks: list[Check],
219
- skip_dirs: frozenset = DEFAULT_SKIP_DIRS,
272
+ workers: int | None = None,
220
273
  ) -> list[Finding]:
274
+ """Scan an already-expanded list of file paths (no directory walking),
275
+ in parallel across a process pool once there are enough files to make
276
+ that worthwhile - one call, one pool, regardless of how many separate
277
+ root paths a CLI invocation was given.
278
+ """
279
+ if not paths:
280
+ return []
281
+ if workers is None:
282
+ workers = os.cpu_count() or 1
283
+
221
284
  findings: list[Finding] = []
222
- for path in iter_python_files(root, skip_dirs):
223
- findings.extend(scan_file(path, checks))
285
+ if workers <= 1 or len(paths) < _MIN_FILES_FOR_PARALLEL:
286
+ for path in paths:
287
+ findings.extend(scan_file(path, checks))
288
+ else:
289
+ with ProcessPoolExecutor(max_workers=workers) as executor:
290
+ for result in executor.map(_scan_file_worker, [(p, checks) for p in paths]):
291
+ findings.extend(result)
224
292
  findings.sort(key=lambda f: (f.path, f.line, f.col, f.code))
225
293
  return findings
294
+
295
+
296
+ def scan_path(
297
+ root: str,
298
+ checks: list[Check],
299
+ skip_dirs: frozenset = DEFAULT_SKIP_DIRS,
300
+ workers: int | None = None,
301
+ ) -> list[Finding]:
302
+ return scan_files(list(iter_python_files(root, skip_dirs)), checks, workers=workers)