codehound 1.9.0__tar.gz → 1.10.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 (55) hide show
  1. {codehound-1.9.0 → codehound-1.10.0}/PKG-INFO +12 -5
  2. {codehound-1.9.0 → codehound-1.10.0}/README.md +11 -4
  3. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/__init__.py +3 -3
  4. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/__init__.py +6 -0
  5. codehound-1.10.0/src/codehound/checks/empty_except_tuple.py +57 -0
  6. codehound-1.10.0/src/codehound/checks/environ_reassignment.py +69 -0
  7. codehound-1.10.0/src/codehound/checks/raise_literal.py +62 -0
  8. {codehound-1.9.0 → codehound-1.10.0}/tests/test_checks.py +110 -0
  9. {codehound-1.9.0 → codehound-1.10.0}/.gitignore +0 -0
  10. {codehound-1.9.0 → codehound-1.10.0}/LICENSE +0 -0
  11. {codehound-1.9.0 → codehound-1.10.0}/pyproject.toml +0 -0
  12. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/async_property.py +0 -0
  13. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/asyncio_coroutine_decorator.py +0 -0
  14. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
  15. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/bare_except.py +0 -0
  16. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/blocking_async.py +0 -0
  17. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/collections_abc_import.py +0 -0
  18. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/datetime_utcnow.py +0 -0
  19. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/discarded_future.py +0 -0
  20. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/finally_swallows_exception.py +0 -0
  21. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/floating_process.py +0 -0
  22. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/floating_task.py +0 -0
  23. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/floating_thread.py +0 -0
  24. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/floating_timer.py +0 -0
  25. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/get_event_loop.py +0 -0
  26. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/is_literal_comparison.py +0 -0
  27. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/loop_closure_capture.py +0 -0
  28. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/lru_cache_on_async_function.py +0 -0
  29. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/lru_cache_on_method.py +0 -0
  30. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/mutable_class_attribute.py +0 -0
  31. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/mutable_defaults.py +0 -0
  32. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/nondeterministic_default.py +0 -0
  33. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
  34. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/removed_getargspec.py +0 -0
  35. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/removed_stdlib_attribute.py +0 -0
  36. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/removed_stdlib_module.py +0 -0
  37. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/resource_leak.py +0 -0
  38. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/strip_multichar.py +0 -0
  39. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
  40. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/unclosed_pool.py +0 -0
  41. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/unclosed_socket.py +0 -0
  42. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/unittest_deprecated_alias.py +0 -0
  43. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/unprotected_lock.py +0 -0
  44. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/checks/unwaited_subprocess.py +0 -0
  45. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/cli.py +0 -0
  46. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/config.py +0 -0
  47. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/core.py +0 -0
  48. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/fixes.py +0 -0
  49. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/sarif.py +0 -0
  50. {codehound-1.9.0 → codehound-1.10.0}/src/codehound/terminal.py +0 -0
  51. {codehound-1.9.0 → codehound-1.10.0}/tests/test_config.py +0 -0
  52. {codehound-1.9.0 → codehound-1.10.0}/tests/test_fixes.py +0 -0
  53. {codehound-1.9.0 → codehound-1.10.0}/tests/test_noqa.py +0 -0
  54. {codehound-1.9.0 → codehound-1.10.0}/tests/test_output_formats.py +0 -0
  55. {codehound-1.9.0 → codehound-1.10.0}/tests/test_parallel_scan.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codehound
3
- Version: 1.9.0
3
+ Version: 1.10.0
4
4
  Summary: An AST-based static analyzer that hunts real correctness and async-safety bugs in Python code.
5
5
  Project-URL: Homepage, https://github.com/kratos0718/codehound
6
6
  Project-URL: Issues, https://github.com/kratos0718/codehound/issues
@@ -25,7 +25,7 @@ Description-Content-Type: text/markdown
25
25
 
26
26
  <h1 align="center">codehound</h1>
27
27
 
28
- **An AST-based static analyzer that hunts *real* bugs in large Python codebases — thirty-three checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
28
+ **An AST-based static analyzer that hunts *real* bugs in large Python codebases — thirty-six 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.**
29
29
 
30
30
  [![CI](https://github.com/kratos0718/codehound/actions/workflows/ci.yml/badge.svg)](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
31
31
  [![PyPI](https://img.shields.io/pypi/v/codehound.svg)](https://pypi.org/project/codehound/)
@@ -70,11 +70,12 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
70
70
 
71
71
  Being upfront about overlap: `codehound` is not the only tool that catches some of these patterns, and pretending otherwise wouldn't survive five minutes of someone actually checking. [Ruff](https://docs.astral.sh/ruff/)'s `RUF006` already catches a discarded `asyncio.create_task()` (CH006), `flake8-async`'s `ASYNC300` predates it. Ruff's `RUF012` already catches mutable class-level defaults (CH026), `F632` catches `is`-literal comparisons (CH025), `B006`/`UP005`/`E722` cover mutable-default-arguments/deprecated-unittest-aliases/bare-except (CH002/CH024/CH020). Pylint's `W1518` (`method-cache-max-size-none`) is close to a name-for-name match for CH011's `lru_cache`-on-instance-method leak. flake8-bugbear's `B012` also overlaps with CH029 (`return`/`break`/`continue` in `finally:`), though CH029 is narrower — bugbear also flags a bare `continue` inside a `finally:` loop body, which this project hasn't verified has the same swallowed-exception risk in every case. If you already run ruff and pylint, several of `codehound`'s checks will feel familiar.
72
72
 
73
- Three checks are explicit adaptations of a flake8-bugbear idea, kept deliberately narrower than the source rule after checking where the broader version's false-positive risk actually lands:
73
+ Six checks are explicit adaptations of a flake8-bugbear idea. Three are kept deliberately narrower than the source rule after checking where the broader version's false-positive risk actually lands; three others (`B016`, `B029`, `B003`) are ported close to as-is because the underlying bug has no narrower/broader version to weigh — it's a fixed fact about the language, true in every version of Python 3, with no configuration or heuristic to get wrong:
74
74
 
75
75
  - **CH010** extends to cover a nested `def` capturing a loop variable, the same shape bugbear's `B023` covers alongside its lambda case — the same storage-based precision guard (only fires if the function is actually stored past the iteration) applies to both.
76
76
  - **CH032** takes bugbear's `B008` idea — "a call as a default argument is suspicious" — and narrows it to a curated list of ten functions (`time.time`, `datetime.now`, `random.random`, `uuid.uuid4`, …) whose result is *never* sensibly the same across calls. B008 as written also flags `def f(x=some_factory()):`, which is frequently a deliberate compute-once memoization; that ambiguity is exactly why this project didn't just port the broader rule.
77
77
  - **CH033** takes `B005`'s "`.strip()` with a multi-character argument is misleading" and adds one precision pass B005 doesn't: skip the argument entirely when it contains no letter or digit. Scanning ~30 real frameworks turned up over a hundred multi-character `.strip()` calls, and the overwhelming majority — `.strip('\r\n')`, `.strip('[]')`, `.strip('\'"')`, box-drawing tree glyphs — were deliberate, correct uses of the character-*set* semantics, not the substring mistake the rule exists to catch. Only the argument that reads as a word or token (`data:`, `/v1`, `THREAD#`) is the real bug; a bag of punctuation isn't. Full before/after counts in [`docs/FINDINGS.md`](docs/FINDINGS.md).
78
+ - **CH034** (`B016`) — `raise <literal>` always raises a different, unrelated `TypeError` instead of the exception the author wrote. **CH035** (`B029`) — `except ():` can never match anything; the handler is dead code. **CH036** (`B003`) — `os.environ = {...}` rebinds the name without calling `putenv`/`unsetenv`, so the real process environment silently doesn't change. All three verified directly in a REPL before writing any AST code (`docs/FINDINGS.md` has the exact repro for each), and all three have zero false-positive risk: there's no legitimate Python where raising a literal succeeds, an empty exception tuple catches something, or reassigning `os.environ` actually syncs the environment.
78
79
 
79
80
  What actually seems to be missing elsewhere, as far as I've been able to find:
80
81
 
@@ -94,7 +95,7 @@ And a difference in kind, not just coverage: every check here is checked against
94
95
  pip install codehound
95
96
  ```
96
97
 
97
- Zero dependencies — it's ~4,300 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
98
+ Zero dependencies — it's ~4,450 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
98
99
 
99
100
  <details>
100
101
  <summary>From a clone instead (for development)</summary>
@@ -187,7 +188,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
187
188
  ```yaml
188
189
  repos:
189
190
  - repo: https://github.com/kratos0718/codehound
190
- rev: v1.9.0
191
+ rev: v1.10.0
191
192
  hooks:
192
193
  - id: codehound
193
194
  ```
@@ -231,6 +232,9 @@ repos:
231
232
  | **CH031** | `unclosed-pool` | `multiprocessing.Pool()` never `.close()`d/`.terminate()`d — worker processes leak for the life of the parent. | hardening rule |
232
233
  | **CH032** | `nondeterministic-default-argument` | A default argument computed from `time.time()`/`datetime.now()`/`random.random()`/`uuid.uuid4()` etc. — evaluated once at definition time, so every call using the default gets the *same* value forever. (inspired by flake8-bugbear B008, narrowed to a curated function list — see below) | hardening rule — real hit in litellm (`BudgetManager.create_budget`'s `created_at=time.time()` default) |
233
234
  | **CH033** | `strip-multichar-argument` | `.strip()`/`.lstrip()`/`.rstrip()` called with a multi-character string that contains a letter or digit — `str.strip(chars)` removes any of those *characters*, not the substring, from each end. (inspired by flake8-bugbear B005, narrowed to skip punctuation-only character sets — see below) | hardening rule — real hit in huggingface_hub (SSE parsing: `line.lstrip("data:").rstrip("/n")`, the second almost certainly meant `"\n"`) |
235
+ | **CH034** | `raise-literal` | `raise "some error"` / `raise None` / `raise (1, 2)` — raising anything that isn't an exception instance always fails with a *different*, unrelated `TypeError` at the exact moment something has already gone wrong. (flake8-bugbear B016) | hardening rule — real hit in llama_index (an `ImportError` fallback that raises a bare string instead) |
236
+ | **CH035** | `empty-except-tuple` | `except ():` — an empty tuple matches nothing, so the handler can never run; every exception still propagates past it. (flake8-bugbear B029) | hardening rule — zero corpus hits (see below) |
237
+ | **CH036** | `environ-reassignment` | `os.environ = {...}` rebinds the name but doesn't call `putenv`/`unsetenv` — the real process environment (what child processes and C-level `getenv` see) stays unchanged. (flake8-bugbear B003) | hardening rule — real hit in HuggingFace `datasets` (a multiprocessing restore that silently doesn't reach spawned worker processes) |
234
238
 
235
239
  `codehound list` prints this from the source of truth.
236
240
 
@@ -531,6 +535,9 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
531
535
  - [x] 33 checks — nested-`def` loop-closure capture alongside lambdas
532
536
  (CH010, same shape as bugbear's B023), nondeterministic default
533
537
  arguments, multi-character `.strip()` arguments — CH032-CH033
538
+ - [x] 36 checks — raising a literal instead of an exception, an empty
539
+ `except ()` tuple that can never match, direct `os.environ`
540
+ reassignment that doesn't sync the real environment — CH034-CH036
534
541
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
535
542
  - [ ] 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
536
543
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">codehound</h1>
6
6
 
7
- **An AST-based static analyzer that hunts *real* bugs in large Python codebases — thirty-three 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.**
7
+ **An AST-based static analyzer that hunts *real* bugs in large Python codebases — thirty-six checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
8
8
 
9
9
  [![CI](https://github.com/kratos0718/codehound/actions/workflows/ci.yml/badge.svg)](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
10
10
  [![PyPI](https://img.shields.io/pypi/v/codehound.svg)](https://pypi.org/project/codehound/)
@@ -49,11 +49,12 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
49
49
 
50
50
  Being upfront about overlap: `codehound` is not the only tool that catches some of these patterns, and pretending otherwise wouldn't survive five minutes of someone actually checking. [Ruff](https://docs.astral.sh/ruff/)'s `RUF006` already catches a discarded `asyncio.create_task()` (CH006), `flake8-async`'s `ASYNC300` predates it. Ruff's `RUF012` already catches mutable class-level defaults (CH026), `F632` catches `is`-literal comparisons (CH025), `B006`/`UP005`/`E722` cover mutable-default-arguments/deprecated-unittest-aliases/bare-except (CH002/CH024/CH020). Pylint's `W1518` (`method-cache-max-size-none`) is close to a name-for-name match for CH011's `lru_cache`-on-instance-method leak. flake8-bugbear's `B012` also overlaps with CH029 (`return`/`break`/`continue` in `finally:`), though CH029 is narrower — bugbear also flags a bare `continue` inside a `finally:` loop body, which this project hasn't verified has the same swallowed-exception risk in every case. If you already run ruff and pylint, several of `codehound`'s checks will feel familiar.
51
51
 
52
- Three checks are explicit adaptations of a flake8-bugbear idea, kept deliberately narrower than the source rule after checking where the broader version's false-positive risk actually lands:
52
+ Six checks are explicit adaptations of a flake8-bugbear idea. Three are kept deliberately narrower than the source rule after checking where the broader version's false-positive risk actually lands; three others (`B016`, `B029`, `B003`) are ported close to as-is because the underlying bug has no narrower/broader version to weigh — it's a fixed fact about the language, true in every version of Python 3, with no configuration or heuristic to get wrong:
53
53
 
54
54
  - **CH010** extends to cover a nested `def` capturing a loop variable, the same shape bugbear's `B023` covers alongside its lambda case — the same storage-based precision guard (only fires if the function is actually stored past the iteration) applies to both.
55
55
  - **CH032** takes bugbear's `B008` idea — "a call as a default argument is suspicious" — and narrows it to a curated list of ten functions (`time.time`, `datetime.now`, `random.random`, `uuid.uuid4`, …) whose result is *never* sensibly the same across calls. B008 as written also flags `def f(x=some_factory()):`, which is frequently a deliberate compute-once memoization; that ambiguity is exactly why this project didn't just port the broader rule.
56
56
  - **CH033** takes `B005`'s "`.strip()` with a multi-character argument is misleading" and adds one precision pass B005 doesn't: skip the argument entirely when it contains no letter or digit. Scanning ~30 real frameworks turned up over a hundred multi-character `.strip()` calls, and the overwhelming majority — `.strip('\r\n')`, `.strip('[]')`, `.strip('\'"')`, box-drawing tree glyphs — were deliberate, correct uses of the character-*set* semantics, not the substring mistake the rule exists to catch. Only the argument that reads as a word or token (`data:`, `/v1`, `THREAD#`) is the real bug; a bag of punctuation isn't. Full before/after counts in [`docs/FINDINGS.md`](docs/FINDINGS.md).
57
+ - **CH034** (`B016`) — `raise <literal>` always raises a different, unrelated `TypeError` instead of the exception the author wrote. **CH035** (`B029`) — `except ():` can never match anything; the handler is dead code. **CH036** (`B003`) — `os.environ = {...}` rebinds the name without calling `putenv`/`unsetenv`, so the real process environment silently doesn't change. All three verified directly in a REPL before writing any AST code (`docs/FINDINGS.md` has the exact repro for each), and all three have zero false-positive risk: there's no legitimate Python where raising a literal succeeds, an empty exception tuple catches something, or reassigning `os.environ` actually syncs the environment.
57
58
 
58
59
  What actually seems to be missing elsewhere, as far as I've been able to find:
59
60
 
@@ -73,7 +74,7 @@ And a difference in kind, not just coverage: every check here is checked against
73
74
  pip install codehound
74
75
  ```
75
76
 
76
- Zero dependencies — it's ~4,300 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
77
+ Zero dependencies — it's ~4,450 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
77
78
 
78
79
  <details>
79
80
  <summary>From a clone instead (for development)</summary>
@@ -166,7 +167,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
166
167
  ```yaml
167
168
  repos:
168
169
  - repo: https://github.com/kratos0718/codehound
169
- rev: v1.9.0
170
+ rev: v1.10.0
170
171
  hooks:
171
172
  - id: codehound
172
173
  ```
@@ -210,6 +211,9 @@ repos:
210
211
  | **CH031** | `unclosed-pool` | `multiprocessing.Pool()` never `.close()`d/`.terminate()`d — worker processes leak for the life of the parent. | hardening rule |
211
212
  | **CH032** | `nondeterministic-default-argument` | A default argument computed from `time.time()`/`datetime.now()`/`random.random()`/`uuid.uuid4()` etc. — evaluated once at definition time, so every call using the default gets the *same* value forever. (inspired by flake8-bugbear B008, narrowed to a curated function list — see below) | hardening rule — real hit in litellm (`BudgetManager.create_budget`'s `created_at=time.time()` default) |
212
213
  | **CH033** | `strip-multichar-argument` | `.strip()`/`.lstrip()`/`.rstrip()` called with a multi-character string that contains a letter or digit — `str.strip(chars)` removes any of those *characters*, not the substring, from each end. (inspired by flake8-bugbear B005, narrowed to skip punctuation-only character sets — see below) | hardening rule — real hit in huggingface_hub (SSE parsing: `line.lstrip("data:").rstrip("/n")`, the second almost certainly meant `"\n"`) |
214
+ | **CH034** | `raise-literal` | `raise "some error"` / `raise None` / `raise (1, 2)` — raising anything that isn't an exception instance always fails with a *different*, unrelated `TypeError` at the exact moment something has already gone wrong. (flake8-bugbear B016) | hardening rule — real hit in llama_index (an `ImportError` fallback that raises a bare string instead) |
215
+ | **CH035** | `empty-except-tuple` | `except ():` — an empty tuple matches nothing, so the handler can never run; every exception still propagates past it. (flake8-bugbear B029) | hardening rule — zero corpus hits (see below) |
216
+ | **CH036** | `environ-reassignment` | `os.environ = {...}` rebinds the name but doesn't call `putenv`/`unsetenv` — the real process environment (what child processes and C-level `getenv` see) stays unchanged. (flake8-bugbear B003) | hardening rule — real hit in HuggingFace `datasets` (a multiprocessing restore that silently doesn't reach spawned worker processes) |
213
217
 
214
218
  `codehound list` prints this from the source of truth.
215
219
 
@@ -510,6 +514,9 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
510
514
  - [x] 33 checks — nested-`def` loop-closure capture alongside lambdas
511
515
  (CH010, same shape as bugbear's B023), nondeterministic default
512
516
  arguments, multi-character `.strip()` arguments — CH032-CH033
517
+ - [x] 36 checks — raising a literal instead of an exception, an empty
518
+ `except ()` tuple that can never match, direct `os.environ`
519
+ reassignment that doesn't sync the real environment — CH034-CH036
513
520
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
514
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
515
522
 
@@ -1,9 +1,9 @@
1
1
  """codehound - an AST-based static analyzer that hunts real bugs in Python code.
2
2
 
3
- Thirty-three checks. Eight are each backed by a bug that was actually
3
+ Thirty-six checks. Eight are each backed by a bug that was actually
4
4
  found and fixed (or opened as a PR) in a popular open-source AI framework
5
5
  (agno, crewAI, mem0, llama_index, accelerate, optuna, litellm). The rest
6
- (CH007-CH009, CH012-CH033) are hardening rules verified against real
6
+ (CH007-CH009, CH012-CH036) are hardening rules verified against real
7
7
  false positives across a ~29-framework validation corpus instead - see
8
8
  docs/FINDINGS.md.
9
9
 
@@ -19,7 +19,7 @@ from __future__ import annotations
19
19
  from codehound.checks import ALL_CHECKS, get_checks
20
20
  from codehound.core import Check, Finding, scan_file, scan_files, scan_path
21
21
 
22
- __version__ = "1.9.0"
22
+ __version__ = "1.10.0"
23
23
 
24
24
  __all__ = [
25
25
  "ALL_CHECKS",
@@ -10,6 +10,8 @@ from codehound.checks.blocking_async import BlockingCallInAsync
10
10
  from codehound.checks.collections_abc_import import CollectionsAbcImport
11
11
  from codehound.checks.datetime_utcnow import DeprecatedDatetimeUtcnow
12
12
  from codehound.checks.discarded_future import DiscardedFuture
13
+ from codehound.checks.empty_except_tuple import EmptyExceptTuple
14
+ from codehound.checks.environ_reassignment import EnvironReassignment
13
15
  from codehound.checks.finally_swallows_exception import FinallySwallowsException
14
16
  from codehound.checks.floating_process import FloatingProcess
15
17
  from codehound.checks.floating_task import FloatingTask
@@ -23,6 +25,7 @@ from codehound.checks.lru_cache_on_method import LruCacheOnMethod
23
25
  from codehound.checks.mutable_class_attribute import MutableClassAttribute
24
26
  from codehound.checks.mutable_defaults import MutableDefaultArgument
25
27
  from codehound.checks.nondeterministic_default import NondeterministicDefault
28
+ from codehound.checks.raise_literal import RaiseLiteral
26
29
  from codehound.checks.removed_asyncio_task_methods import RemovedAsyncioTaskMethods
27
30
  from codehound.checks.removed_getargspec import RemovedGetargspec
28
31
  from codehound.checks.removed_stdlib_attribute import RemovedStdlibAttribute
@@ -71,6 +74,9 @@ ALL_CHECKS: list[type[Check]] = [
71
74
  UnclosedPool,
72
75
  NondeterministicDefault,
73
76
  StripMultichar,
77
+ RaiseLiteral,
78
+ EmptyExceptTuple,
79
+ EnvironReassignment,
74
80
  ]
75
81
 
76
82
 
@@ -0,0 +1,57 @@
1
+ """CH035 - ``except ():`` catches nothing at all.
2
+
3
+ `isinstance(exc, ())` is always `False` - an empty tuple has nothing
4
+ to match against - so `except ():` is a handler that can *never* run.
5
+ Verified directly:
6
+
7
+ try:
8
+ raise ValueError("boom")
9
+ except ():
10
+ print("caught") # never printed
11
+ # ValueError propagates past the handler, as if it wasn't there
12
+
13
+ This is flake8-bugbear's B029. It's easy to write by accident - a
14
+ `(SomeError,)` tuple that gets refactored down to nothing (a variable
15
+ that used to hold exception types now holds an empty list/tuple,
16
+ or an `except (E1, E2):` where both names got deleted during a merge)
17
+ silently turns into dead code with no error, no warning, and a handler
18
+ that reads as if it's protecting something it no longer protects.
19
+
20
+ Zero configuration, zero false-positive risk: an empty tuple can never
21
+ match any exception, so there's no reading of `except ():` where it's
22
+ doing useful work.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import ast
28
+
29
+ from codehound.core import Check, Finding
30
+
31
+
32
+ class EmptyExceptTuple(Check):
33
+ code = "CH035"
34
+ name = "empty-except-tuple"
35
+ description = "except () catches nothing - an empty exception tuple can never match, so the handler is dead code."
36
+
37
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
38
+ findings: list[Finding] = []
39
+ for node in ast.walk(tree):
40
+ if not isinstance(node, ast.ExceptHandler):
41
+ continue
42
+ if not (isinstance(node.type, ast.Tuple) and len(node.type.elts) == 0):
43
+ continue
44
+ findings.append(
45
+ Finding(
46
+ path=path,
47
+ line=node.lineno,
48
+ col=node.col_offset,
49
+ code=self.code,
50
+ message=(
51
+ "`except ():` can never match any exception - an empty tuple has "
52
+ "nothing to catch, so this handler is dead code and every exception "
53
+ "still propagates past it."
54
+ ),
55
+ )
56
+ )
57
+ return findings
@@ -0,0 +1,69 @@
1
+ """CH036 - assigning directly to ``os.environ`` doesn't sync the real environment.
2
+
3
+ `os.environ` isn't a plain dict - it's a special mapping (`os._Environ`)
4
+ that calls `os.putenv`/`os.unsetenv` under the hood so the process's
5
+ *actual* environment (the C-level `environ`, the thing child processes
6
+ inherit) stays in sync with what Python sees. `os.environ = {...}`
7
+ doesn't call any of that - it just rebinds the module attribute to a
8
+ plain dict, leaving the real environment untouched. Verified directly:
9
+
10
+ os.environ['TOKEN'] = 'secret'
11
+ os.environ = {}
12
+ subprocess.run([...]) # child process still sees TOKEN='secret'
13
+ os.getenv('TOKEN') # None - Python's own view says it's gone
14
+
15
+ Python's own view and the operating system's view now disagree, and
16
+ whichever one a test or a child process happens to read from decides
17
+ whether the "clear" actually took effect. This is flake8-bugbear's
18
+ B003, and it's exactly the kind of gotcha that looks like the obviously
19
+ correct way to reset environment state - `os.environ = old_snapshot`
20
+ inside a test's cleanup/`finally:` block is the single most common
21
+ place this shows up, precisely because it looks like a restore and
22
+ silently isn't one.
23
+
24
+ Only flags a direct assignment to the `os.environ` attribute -
25
+ `os.environ.clear()`, `os.environ.update(...)`, `os.environ.pop(...)`,
26
+ and `del os.environ[...]` all go through the real mapping and are all
27
+ correct, unflagged uses.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import ast
33
+
34
+ from codehound.core import Check, Finding
35
+
36
+
37
+ def _is_os_environ_attribute(node: ast.expr) -> bool:
38
+ return isinstance(node, ast.Attribute) and node.attr == "environ" and isinstance(node.value, ast.Name) and node.value.id == "os"
39
+
40
+
41
+ class EnvironReassignment(Check):
42
+ code = "CH036"
43
+ name = "environ-reassignment"
44
+ description = "os.environ = ... rebinds the name but doesn't sync the real process environment; use .clear()/.update() instead."
45
+
46
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
47
+ findings: list[Finding] = []
48
+ for node in ast.walk(tree):
49
+ if not isinstance(node, ast.Assign):
50
+ continue
51
+ for target in node.targets:
52
+ if not _is_os_environ_attribute(target):
53
+ continue
54
+ findings.append(
55
+ Finding(
56
+ path=path,
57
+ line=node.lineno,
58
+ col=node.col_offset,
59
+ code=self.code,
60
+ message=(
61
+ "assigning directly to `os.environ` rebinds the name but doesn't "
62
+ "call putenv/unsetenv - the real process environment (what child "
63
+ "processes and `os.getenv` from C libraries see) stays unchanged. "
64
+ "Use `os.environ.clear()` and/or `.update(...)` instead."
65
+ ),
66
+ )
67
+ )
68
+ break
69
+ return findings
@@ -0,0 +1,62 @@
1
+ """CH034 - ``raise`` with a literal instead of an exception instance.
2
+
3
+ `raise` accepts a `BaseException` instance or class - nothing else.
4
+ A string, number, or container literal isn't either, so it always
5
+ raises a *different* exception than the one the author wrote. Verified
6
+ directly, every shape:
7
+
8
+ raise "some error" # TypeError: exceptions must derive from BaseException
9
+ raise None # same TypeError
10
+ raise f"boom {x}" # same - still a str at runtime
11
+ raise (1, 2) # same, for a tuple literal
12
+ raise {"error": x} # same, for a dict literal
13
+
14
+ This is flake8-bugbear's B016, and it earns a place here for the same
15
+ reason CH025 (`is`-literal-comparison) does: the code that triggers it
16
+ reads like a deliberate, reasonable choice - `raise "config missing"`
17
+ looks like someone chose a plain string over defining a custom
18
+ exception class - and the failure mode is a second, unrelated
19
+ `TypeError` at the exact moment something has already gone wrong, which
20
+ is a uniquely bad time to discover a typo.
21
+
22
+ Zero configuration, zero false-positive risk: this isn't a style
23
+ opinion or a narrowed heuristic like CH032/CH033 - `raise <literal>`
24
+ has exactly one possible outcome in every version of Python 3, so there
25
+ is no "usually fine, occasionally not" case to guard against.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import ast
31
+
32
+ from codehound.core import Check, Finding
33
+
34
+ _LITERAL_NODE_TYPES = (ast.Constant, ast.JoinedStr, ast.List, ast.Dict, ast.Set, ast.Tuple)
35
+
36
+
37
+ class RaiseLiteral(Check):
38
+ code = "CH034"
39
+ name = "raise-literal"
40
+ description = "raise with a literal (str/int/list/dict/...) instead of an exception instance always raises TypeError."
41
+
42
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
43
+ findings: list[Finding] = []
44
+ for node in ast.walk(tree):
45
+ if not isinstance(node, ast.Raise) or node.exc is None:
46
+ continue
47
+ if not isinstance(node.exc, _LITERAL_NODE_TYPES):
48
+ continue
49
+ findings.append(
50
+ Finding(
51
+ path=path,
52
+ line=node.lineno,
53
+ col=node.col_offset,
54
+ code=self.code,
55
+ message=(
56
+ "raising a literal instead of an exception instance always fails with "
57
+ "`TypeError: exceptions must derive from BaseException` - wrap it in an "
58
+ "exception, e.g. `raise ValueError(...)`."
59
+ ),
60
+ )
61
+ )
62
+ return findings
@@ -1873,3 +1873,113 @@ def test_ch033_flags_argument_with_letters_even_with_punctuation():
1873
1873
  findings = _run(code, ["CH033"])
1874
1874
  assert len(findings) == 1
1875
1875
 
1876
+
1877
+ # --- CH034 raise-literal -----------------------------------------------------------------
1878
+
1879
+
1880
+ def test_ch034_flags_raise_string_literal():
1881
+ code = "raise 'some error'\n"
1882
+ findings = _run(code, ["CH034"])
1883
+ assert len(findings) == 1
1884
+ assert findings[0].code == "CH034"
1885
+
1886
+
1887
+ def test_ch034_flags_raise_fstring():
1888
+ code = "x = 1\nraise f'boom {x}'\n"
1889
+ findings = _run(code, ["CH034"])
1890
+ assert len(findings) == 1
1891
+
1892
+
1893
+ def test_ch034_flags_raise_none():
1894
+ code = "raise None\n"
1895
+ findings = _run(code, ["CH034"])
1896
+ assert len(findings) == 1
1897
+
1898
+
1899
+ def test_ch034_flags_raise_tuple_literal():
1900
+ code = "raise (1, 2)\n"
1901
+ findings = _run(code, ["CH034"])
1902
+ assert len(findings) == 1
1903
+
1904
+
1905
+ def test_ch034_flags_raise_dict_literal():
1906
+ code = "raise {'error': 'bad'}\n"
1907
+ findings = _run(code, ["CH034"])
1908
+ assert len(findings) == 1
1909
+
1910
+
1911
+ def test_ch034_ignores_raise_exception_instance():
1912
+ code = "raise ValueError('bad input')\n"
1913
+ assert _run(code, ["CH034"]) == []
1914
+
1915
+
1916
+ def test_ch034_ignores_bare_reraise():
1917
+ code = "try:\n pass\nexcept Exception:\n raise\n"
1918
+ assert _run(code, ["CH034"]) == []
1919
+
1920
+
1921
+ def test_ch034_ignores_raise_from_variable():
1922
+ code = "def f(exc):\n raise exc\n"
1923
+ assert _run(code, ["CH034"]) == []
1924
+
1925
+
1926
+ # --- CH035 empty-except-tuple -------------------------------------------------------------
1927
+
1928
+
1929
+ def test_ch035_flags_empty_except_tuple():
1930
+ code = "try:\n pass\nexcept ():\n pass\n"
1931
+ findings = _run(code, ["CH035"])
1932
+ assert len(findings) == 1
1933
+ assert findings[0].code == "CH035"
1934
+
1935
+
1936
+ def test_ch035_ignores_single_exception_type():
1937
+ code = "try:\n pass\nexcept ValueError:\n pass\n"
1938
+ assert _run(code, ["CH035"]) == []
1939
+
1940
+
1941
+ def test_ch035_ignores_nonempty_exception_tuple():
1942
+ code = "try:\n pass\nexcept (ValueError, TypeError):\n pass\n"
1943
+ assert _run(code, ["CH035"]) == []
1944
+
1945
+
1946
+ def test_ch035_ignores_bare_except():
1947
+ code = "try:\n pass\nexcept:\n pass\n"
1948
+ assert _run(code, ["CH035"]) == []
1949
+
1950
+
1951
+ # --- CH036 environ-reassignment -----------------------------------------------------------
1952
+
1953
+
1954
+ def test_ch036_flags_direct_environ_reassignment():
1955
+ code = "import os\nos.environ = {}\n"
1956
+ findings = _run(code, ["CH036"])
1957
+ assert len(findings) == 1
1958
+ assert findings[0].code == "CH036"
1959
+
1960
+
1961
+ def test_ch036_flags_environ_reassignment_from_snapshot():
1962
+ code = "import os\nsaved = os.environ.copy()\nos.environ = saved\n"
1963
+ findings = _run(code, ["CH036"])
1964
+ assert len(findings) == 1
1965
+
1966
+
1967
+ def test_ch036_ignores_environ_item_assignment():
1968
+ code = "import os\nos.environ['KEY'] = 'value'\n"
1969
+ assert _run(code, ["CH036"]) == []
1970
+
1971
+
1972
+ def test_ch036_ignores_environ_clear():
1973
+ code = "import os\nos.environ.clear()\n"
1974
+ assert _run(code, ["CH036"]) == []
1975
+
1976
+
1977
+ def test_ch036_ignores_environ_update():
1978
+ code = "import os\nos.environ.update({'KEY': 'value'})\n"
1979
+ assert _run(code, ["CH036"]) == []
1980
+
1981
+
1982
+ def test_ch036_ignores_unrelated_attribute_named_environ():
1983
+ code = "config.environ = {}\n"
1984
+ assert _run(code, ["CH036"]) == []
1985
+
File without changes
File without changes
File without changes
File without changes