codehound 1.8.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.
- {codehound-1.8.0 → codehound-1.10.0}/PKG-INFO +27 -9
- {codehound-1.8.0 → codehound-1.10.0}/README.md +26 -8
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/__init__.py +3 -3
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/__init__.py +10 -0
- codehound-1.10.0/src/codehound/checks/empty_except_tuple.py +57 -0
- codehound-1.10.0/src/codehound/checks/environ_reassignment.py +69 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/loop_closure_capture.py +86 -0
- codehound-1.10.0/src/codehound/checks/nondeterministic_default.py +99 -0
- codehound-1.10.0/src/codehound/checks/raise_literal.py +62 -0
- codehound-1.10.0/src/codehound/checks/strip_multichar.py +88 -0
- {codehound-1.8.0 → codehound-1.10.0}/tests/test_checks.py +276 -0
- {codehound-1.8.0 → codehound-1.10.0}/.gitignore +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/LICENSE +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/pyproject.toml +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/async_property.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/asyncio_coroutine_decorator.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/bare_except.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/blocking_async.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/collections_abc_import.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/datetime_utcnow.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/discarded_future.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/finally_swallows_exception.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/floating_process.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/floating_task.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/floating_thread.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/floating_timer.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/get_event_loop.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/is_literal_comparison.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/lru_cache_on_async_function.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/lru_cache_on_method.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/mutable_class_attribute.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/mutable_defaults.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/removed_getargspec.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/removed_stdlib_attribute.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/removed_stdlib_module.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/resource_leak.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/unclosed_pool.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/unclosed_socket.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/unittest_deprecated_alias.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/unprotected_lock.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/checks/unwaited_subprocess.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/cli.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/config.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/core.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/fixes.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/sarif.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/src/codehound/terminal.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/tests/test_config.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/tests/test_fixes.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/tests/test_noqa.py +0 -0
- {codehound-1.8.0 → codehound-1.10.0}/tests/test_output_formats.py +0 -0
- {codehound-1.8.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.
|
|
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-
|
|
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
|
[](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
|
|
31
31
|
[](https://pypi.org/project/codehound/)
|
|
@@ -68,7 +68,14 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
|
|
|
68
68
|
|
|
69
69
|
## How this compares
|
|
70
70
|
|
|
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. If you already run ruff and pylint, several of `codehound`'s checks will feel familiar.
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
- **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
|
+
- **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.
|
|
72
79
|
|
|
73
80
|
What actually seems to be missing elsewhere, as far as I've been able to find:
|
|
74
81
|
|
|
@@ -88,7 +95,7 @@ And a difference in kind, not just coverage: every check here is checked against
|
|
|
88
95
|
pip install codehound
|
|
89
96
|
```
|
|
90
97
|
|
|
91
|
-
Zero dependencies — it's ~4,
|
|
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.
|
|
92
99
|
|
|
93
100
|
<details>
|
|
94
101
|
<summary>From a clone instead (for development)</summary>
|
|
@@ -181,7 +188,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
|
|
|
181
188
|
```yaml
|
|
182
189
|
repos:
|
|
183
190
|
- repo: https://github.com/kratos0718/codehound
|
|
184
|
-
rev: v1.
|
|
191
|
+
rev: v1.10.0
|
|
185
192
|
hooks:
|
|
186
193
|
- id: codehound
|
|
187
194
|
```
|
|
@@ -201,7 +208,7 @@ repos:
|
|
|
201
208
|
| **CH007** | `unawaited-coroutine-call` | `foo()` where `foo` is `async def`, called as a bare statement — no `await`, no scheduling. The coroutine object is created and dropped; the body **never runs at all**. | hardening rule — see below |
|
|
202
209
|
| **CH008** | `asyncio-run-in-running-loop` | `asyncio.run(...)` called from inside an `async def` — always raises `RuntimeError`, immediately, every time. | hardening rule — zero corpus hits (see below) |
|
|
203
210
|
| **CH009** | `floating-thread` | A non-daemon `threading.Thread` that's `.start()`ed but never `.join()`ed — the thread analog of CH006. | hardening rule — see below |
|
|
204
|
-
| **CH010** | `loop-closure-capture` | A `lambda` inside a `for` loop (or comprehension) that's *stored* (appended, assigned, returned) and captures the loop variable by reference — every stored instance ends up sharing the loop's **final** value. | **accelerate** (HuggingFace) — `MegatronEngine.get_module_config`'s `param_sync_func` list, PR #4273 |
|
|
211
|
+
| **CH010** | `loop-closure-capture` | A `lambda` *or* nested `def` inside a `for` loop (or comprehension) that's *stored* (appended, assigned, returned) and captures the loop variable by reference — every stored instance ends up sharing the loop's **final** value. | **accelerate** (HuggingFace) — `MegatronEngine.get_module_config`'s `param_sync_func` list, PR #4273 |
|
|
205
212
|
| **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. | **optuna** — `_FanovaTree`'s node-lookup methods leaked every tree built for a `get_param_importances()` call; **llama_index** — `VectaraIndex._get_corpus_key` leaked the index *and* broke its own `__del__`-based HTTP session cleanup; **litellm** — `Router._cached_get_model_group_info` leaked every `Router` even after its own documented `discard()` cleanup |
|
|
206
213
|
| **CH012** | `floating-process` | A non-daemon `multiprocessing.Process` that's `.start()`ed but never `.join()`ed — the process analog of CH009. | hardening rule |
|
|
207
214
|
| **CH013** | `discarded-future` | `ThreadPoolExecutor`/`ProcessPoolExecutor.submit(...)` called as a bare statement — the returned `Future` (and any exception raised inside the submitted work) is silently discarded. | hardening rule — real hits in litellm, accelerate, langchain |
|
|
@@ -223,6 +230,11 @@ repos:
|
|
|
223
230
|
| **CH029** | `finally-swallows-exception` | `return`/`break`/`continue` in a `finally:` block silently discards any exception from the `try:` — the caller never sees it. | hardening rule — real hits in letta |
|
|
224
231
|
| **CH030** | `lru-cache-on-async-function` | `@lru_cache`/`@cache` on `async def` caches the coroutine *object*, not its result — the second call with the same arguments crashes. | hardening rule |
|
|
225
232
|
| **CH031** | `unclosed-pool` | `multiprocessing.Pool()` never `.close()`d/`.terminate()`d — worker processes leak for the life of the parent. | hardening rule |
|
|
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) |
|
|
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) |
|
|
226
238
|
|
|
227
239
|
`codehound list` prints this from the source of truth.
|
|
228
240
|
|
|
@@ -473,7 +485,7 @@ codehound/
|
|
|
473
485
|
|
|
474
486
|
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.
|
|
475
487
|
|
|
476
|
-
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. CH029 skips a `try` whose `except` clauses never re-raise anywhere in their own scope — a real pattern in letta where the exception is deliberately logged and recorded, never propagated, so a `return` in `finally` isn't discarding anything live. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
488
|
+
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. CH029 skips a `try` whose `except` clauses never re-raise anywhere in their own scope — a real pattern in letta where the exception is deliberately logged and recorded, never propagated, so a `return` in `finally` isn't discarding anything live. CH033 skips a `.strip()` argument that's every character the same (`.strip('```')`) or made entirely of punctuation/whitespace with no letter or digit (`.strip('\r\n')`, `.strip('[]')`) — real patterns found across nearly every framework scanned, all deliberate uses of the character-*set* semantics rather than the substring mistake the check exists to catch. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
477
489
|
|
|
478
490
|
---
|
|
479
491
|
|
|
@@ -517,11 +529,17 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
517
529
|
- [x] `--fix` — CH017 always, CH004 only inside `async def` (CH002/CH003
|
|
518
530
|
turned out to need judgment calls or import-injection this tool
|
|
519
531
|
won't guess at, so they stay detection-only; see docs/ARCHITECTURE.md)
|
|
520
|
-
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
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
|
|
522
532
|
- [x] Parallelize scanning across files for large codebases — a full
|
|
523
533
|
HuggingFace transformers scan went from 57s to 12s (measured,
|
|
524
534
|
byte-identical output verified against the sequential run)
|
|
535
|
+
- [x] 33 checks — nested-`def` loop-closure capture alongside lambdas
|
|
536
|
+
(CH010, same shape as bugbear's B023), nondeterministic default
|
|
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
|
|
541
|
+
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
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
|
|
525
543
|
|
|
526
544
|
---
|
|
527
545
|
|
|
@@ -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-
|
|
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
|
[](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
|
|
10
10
|
[](https://pypi.org/project/codehound/)
|
|
@@ -47,7 +47,14 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
|
|
|
47
47
|
|
|
48
48
|
## How this compares
|
|
49
49
|
|
|
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. If you already run ruff and pylint, several of `codehound`'s checks will feel familiar.
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
- **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
|
+
- **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.
|
|
51
58
|
|
|
52
59
|
What actually seems to be missing elsewhere, as far as I've been able to find:
|
|
53
60
|
|
|
@@ -67,7 +74,7 @@ And a difference in kind, not just coverage: every check here is checked against
|
|
|
67
74
|
pip install codehound
|
|
68
75
|
```
|
|
69
76
|
|
|
70
|
-
Zero dependencies — it's ~4,
|
|
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.
|
|
71
78
|
|
|
72
79
|
<details>
|
|
73
80
|
<summary>From a clone instead (for development)</summary>
|
|
@@ -160,7 +167,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
|
|
|
160
167
|
```yaml
|
|
161
168
|
repos:
|
|
162
169
|
- repo: https://github.com/kratos0718/codehound
|
|
163
|
-
rev: v1.
|
|
170
|
+
rev: v1.10.0
|
|
164
171
|
hooks:
|
|
165
172
|
- id: codehound
|
|
166
173
|
```
|
|
@@ -180,7 +187,7 @@ repos:
|
|
|
180
187
|
| **CH007** | `unawaited-coroutine-call` | `foo()` where `foo` is `async def`, called as a bare statement — no `await`, no scheduling. The coroutine object is created and dropped; the body **never runs at all**. | hardening rule — see below |
|
|
181
188
|
| **CH008** | `asyncio-run-in-running-loop` | `asyncio.run(...)` called from inside an `async def` — always raises `RuntimeError`, immediately, every time. | hardening rule — zero corpus hits (see below) |
|
|
182
189
|
| **CH009** | `floating-thread` | A non-daemon `threading.Thread` that's `.start()`ed but never `.join()`ed — the thread analog of CH006. | hardening rule — see below |
|
|
183
|
-
| **CH010** | `loop-closure-capture` | A `lambda` inside a `for` loop (or comprehension) that's *stored* (appended, assigned, returned) and captures the loop variable by reference — every stored instance ends up sharing the loop's **final** value. | **accelerate** (HuggingFace) — `MegatronEngine.get_module_config`'s `param_sync_func` list, PR #4273 |
|
|
190
|
+
| **CH010** | `loop-closure-capture` | A `lambda` *or* nested `def` inside a `for` loop (or comprehension) that's *stored* (appended, assigned, returned) and captures the loop variable by reference — every stored instance ends up sharing the loop's **final** value. | **accelerate** (HuggingFace) — `MegatronEngine.get_module_config`'s `param_sync_func` list, PR #4273 |
|
|
184
191
|
| **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. | **optuna** — `_FanovaTree`'s node-lookup methods leaked every tree built for a `get_param_importances()` call; **llama_index** — `VectaraIndex._get_corpus_key` leaked the index *and* broke its own `__del__`-based HTTP session cleanup; **litellm** — `Router._cached_get_model_group_info` leaked every `Router` even after its own documented `discard()` cleanup |
|
|
185
192
|
| **CH012** | `floating-process` | A non-daemon `multiprocessing.Process` that's `.start()`ed but never `.join()`ed — the process analog of CH009. | hardening rule |
|
|
186
193
|
| **CH013** | `discarded-future` | `ThreadPoolExecutor`/`ProcessPoolExecutor.submit(...)` called as a bare statement — the returned `Future` (and any exception raised inside the submitted work) is silently discarded. | hardening rule — real hits in litellm, accelerate, langchain |
|
|
@@ -202,6 +209,11 @@ repos:
|
|
|
202
209
|
| **CH029** | `finally-swallows-exception` | `return`/`break`/`continue` in a `finally:` block silently discards any exception from the `try:` — the caller never sees it. | hardening rule — real hits in letta |
|
|
203
210
|
| **CH030** | `lru-cache-on-async-function` | `@lru_cache`/`@cache` on `async def` caches the coroutine *object*, not its result — the second call with the same arguments crashes. | hardening rule |
|
|
204
211
|
| **CH031** | `unclosed-pool` | `multiprocessing.Pool()` never `.close()`d/`.terminate()`d — worker processes leak for the life of the parent. | hardening rule |
|
|
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) |
|
|
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) |
|
|
205
217
|
|
|
206
218
|
`codehound list` prints this from the source of truth.
|
|
207
219
|
|
|
@@ -452,7 +464,7 @@ codehound/
|
|
|
452
464
|
|
|
453
465
|
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.
|
|
454
466
|
|
|
455
|
-
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. CH029 skips a `try` whose `except` clauses never re-raise anywhere in their own scope — a real pattern in letta where the exception is deliberately logged and recorded, never propagated, so a `return` in `finally` isn't discarding anything live. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
467
|
+
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. CH029 skips a `try` whose `except` clauses never re-raise anywhere in their own scope — a real pattern in letta where the exception is deliberately logged and recorded, never propagated, so a `return` in `finally` isn't discarding anything live. CH033 skips a `.strip()` argument that's every character the same (`.strip('```')`) or made entirely of punctuation/whitespace with no letter or digit (`.strip('\r\n')`, `.strip('[]')`) — real patterns found across nearly every framework scanned, all deliberate uses of the character-*set* semantics rather than the substring mistake the check exists to catch. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
456
468
|
|
|
457
469
|
---
|
|
458
470
|
|
|
@@ -496,11 +508,17 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
496
508
|
- [x] `--fix` — CH017 always, CH004 only inside `async def` (CH002/CH003
|
|
497
509
|
turned out to need judgment calls or import-injection this tool
|
|
498
510
|
won't guess at, so they stay detection-only; see docs/ARCHITECTURE.md)
|
|
499
|
-
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
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
|
|
501
511
|
- [x] Parallelize scanning across files for large codebases — a full
|
|
502
512
|
HuggingFace transformers scan went from 57s to 12s (measured,
|
|
503
513
|
byte-identical output verified against the sequential run)
|
|
514
|
+
- [x] 33 checks — nested-`def` loop-closure capture alongside lambdas
|
|
515
|
+
(CH010, same shape as bugbear's B023), nondeterministic default
|
|
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
|
|
520
|
+
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
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
|
|
504
522
|
|
|
505
523
|
---
|
|
506
524
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""codehound - an AST-based static analyzer that hunts real bugs in Python code.
|
|
2
2
|
|
|
3
|
-
Thirty-
|
|
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-
|
|
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.
|
|
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
|
|
@@ -22,11 +24,14 @@ from codehound.checks.lru_cache_on_async_function import LruCacheOnAsyncFunction
|
|
|
22
24
|
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
|
|
27
|
+
from codehound.checks.nondeterministic_default import NondeterministicDefault
|
|
28
|
+
from codehound.checks.raise_literal import RaiseLiteral
|
|
25
29
|
from codehound.checks.removed_asyncio_task_methods import RemovedAsyncioTaskMethods
|
|
26
30
|
from codehound.checks.removed_getargspec import RemovedGetargspec
|
|
27
31
|
from codehound.checks.removed_stdlib_attribute import RemovedStdlibAttribute
|
|
28
32
|
from codehound.checks.removed_stdlib_module import RemovedStdlibModule
|
|
29
33
|
from codehound.checks.resource_leak import UnclosedFileHandle
|
|
34
|
+
from codehound.checks.strip_multichar import StripMultichar
|
|
30
35
|
from codehound.checks.unawaited_coroutine import UnawaitedCoroutineCall
|
|
31
36
|
from codehound.checks.unclosed_pool import UnclosedPool
|
|
32
37
|
from codehound.checks.unclosed_socket import UnclosedSocket
|
|
@@ -67,6 +72,11 @@ ALL_CHECKS: list[type[Check]] = [
|
|
|
67
72
|
FinallySwallowsException,
|
|
68
73
|
LruCacheOnAsyncFunction,
|
|
69
74
|
UnclosedPool,
|
|
75
|
+
NondeterministicDefault,
|
|
76
|
+
StripMultichar,
|
|
77
|
+
RaiseLiteral,
|
|
78
|
+
EmptyExceptTuple,
|
|
79
|
+
EnvironReassignment,
|
|
70
80
|
]
|
|
71
81
|
|
|
72
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
|
|
@@ -29,6 +29,14 @@ Comprehensions (`` [lambda: i for i in range(3)] ``) are the one
|
|
|
29
29
|
exception: the ``elt``/``key``/``value`` position *is* inherently the
|
|
30
30
|
"produce and store" position, so no extra storage-context check is
|
|
31
31
|
needed there.
|
|
32
|
+
|
|
33
|
+
Same bug, same fix, different syntax: a nested ``def`` inside a ``for``
|
|
34
|
+
loop captures the loop variable exactly the same way a lambda does -
|
|
35
|
+
flake8-bugbear's B023 covers both shapes under one rule, and this check
|
|
36
|
+
now does too. ``def`` is a statement, not an expression, so "is it
|
|
37
|
+
stored" means something different: the function's *name* has to show up
|
|
38
|
+
later in a storage position (appended, assigned, returned), not the
|
|
39
|
+
``def`` itself.
|
|
32
40
|
"""
|
|
33
41
|
|
|
34
42
|
from __future__ import annotations
|
|
@@ -54,6 +62,53 @@ def _references_name_unshadowed(lam: ast.Lambda, name: str) -> bool:
|
|
|
54
62
|
return False
|
|
55
63
|
|
|
56
64
|
|
|
65
|
+
def _references_name_in_body(funcdef: ast.FunctionDef | ast.AsyncFunctionDef, name: str) -> bool:
|
|
66
|
+
"""Like ``_references_name_unshadowed`` but for a ``def``'s own
|
|
67
|
+
parameters and statement body rather than a lambda's params and
|
|
68
|
+
single expression - a same-named parameter shadows the outer loop
|
|
69
|
+
variable exactly like it would for a lambda."""
|
|
70
|
+
args = funcdef.args
|
|
71
|
+
param_names = {a.arg for a in (args.posonlyargs + args.args + args.kwonlyargs)}
|
|
72
|
+
if args.vararg:
|
|
73
|
+
param_names.add(args.vararg.arg)
|
|
74
|
+
if args.kwarg:
|
|
75
|
+
param_names.add(args.kwarg.arg)
|
|
76
|
+
if name in param_names:
|
|
77
|
+
return False
|
|
78
|
+
for stmt in funcdef.body:
|
|
79
|
+
for node in ast.walk(stmt):
|
|
80
|
+
if isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, ast.Load):
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _name_is_stored(name: str, loop_body: list[ast.stmt], parents: dict) -> bool:
|
|
86
|
+
"""True if `name` (a nested def's own name) is later used in a
|
|
87
|
+
storage position anywhere in the loop body: assigned, returned/
|
|
88
|
+
yielded, or passed to `.append()`/`.add()` - same storage shapes
|
|
89
|
+
`_is_stored` recognizes for a lambda, just checked via the Name
|
|
90
|
+
reference's parent instead of the def statement's own parent, since
|
|
91
|
+
a `def` can't be the direct value of an assignment the way a lambda
|
|
92
|
+
expression can."""
|
|
93
|
+
for stmt in loop_body:
|
|
94
|
+
for node in ast.walk(stmt):
|
|
95
|
+
if not (isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, ast.Load)):
|
|
96
|
+
continue
|
|
97
|
+
parent = parents.get(id(node))
|
|
98
|
+
if isinstance(parent, (ast.Assign, ast.AnnAssign)) and parent.value is node:
|
|
99
|
+
return True
|
|
100
|
+
if isinstance(parent, (ast.Return, ast.Yield)) and parent.value is node:
|
|
101
|
+
return True
|
|
102
|
+
if (
|
|
103
|
+
isinstance(parent, ast.Call)
|
|
104
|
+
and isinstance(parent.func, ast.Attribute)
|
|
105
|
+
and parent.func.attr in _STORAGE_METHODS
|
|
106
|
+
and node in parent.args
|
|
107
|
+
):
|
|
108
|
+
return True
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
57
112
|
def _is_stored(lam: ast.Lambda, parents: dict) -> bool:
|
|
58
113
|
"""True only if the lambda is directly stored somewhere that outlives
|
|
59
114
|
this loop iteration - not merely passed as a callback argument to a
|
|
@@ -120,4 +175,35 @@ class LoopClosureCapture(Check):
|
|
|
120
175
|
)
|
|
121
176
|
)
|
|
122
177
|
break
|
|
178
|
+
|
|
179
|
+
# Same bug, `def` instead of `lambda`: a nested function
|
|
180
|
+
# defined directly in a `for` loop's body, capturing the loop
|
|
181
|
+
# variable, whose *name* (not the def itself) is later stored
|
|
182
|
+
# somewhere that outlives this iteration.
|
|
183
|
+
if isinstance(loop, ast.For) and isinstance(loop.target, ast.Name):
|
|
184
|
+
var = loop.target.id
|
|
185
|
+
for stmt in loop.body:
|
|
186
|
+
if not isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
187
|
+
continue
|
|
188
|
+
if id(stmt) in seen:
|
|
189
|
+
continue
|
|
190
|
+
if not _references_name_in_body(stmt, var):
|
|
191
|
+
continue
|
|
192
|
+
if not _name_is_stored(stmt.name, loop.body, parents):
|
|
193
|
+
continue
|
|
194
|
+
seen.add(id(stmt))
|
|
195
|
+
findings.append(
|
|
196
|
+
Finding(
|
|
197
|
+
path=path,
|
|
198
|
+
line=stmt.lineno,
|
|
199
|
+
col=stmt.col_offset,
|
|
200
|
+
code=self.code,
|
|
201
|
+
message=(
|
|
202
|
+
f"`{stmt.name}` captures loop variable `{var}` by reference; if "
|
|
203
|
+
f"called after the loop moves on, every instance sees the same "
|
|
204
|
+
f"final value. Bind it explicitly with a default argument, e.g. "
|
|
205
|
+
f"`def {stmt.name}({var}={var}):`."
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
)
|
|
123
209
|
return findings
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""CH032 - a default argument computed from a call to a non-deterministic function.
|
|
2
|
+
|
|
3
|
+
A default value is evaluated exactly *once*, when the function is
|
|
4
|
+
defined, not on every call - the same fact CH002 is about, but this is
|
|
5
|
+
a distinct shape: a call to something like `time.time()` or
|
|
6
|
+
`datetime.now()` isn't mutable, so CH002's "shared mutable object" check
|
|
7
|
+
doesn't fire, and it's syntactically identical to a perfectly reasonable
|
|
8
|
+
default like `def f(x=DEFAULT_TIMEOUT):`. What makes it a bug is that
|
|
9
|
+
the *value itself* depends on when it's called, and every default
|
|
10
|
+
argument only ever captures the value from function-definition time.
|
|
11
|
+
Verified directly:
|
|
12
|
+
|
|
13
|
+
def f(x=time.time()):
|
|
14
|
+
return x
|
|
15
|
+
|
|
16
|
+
a = f()
|
|
17
|
+
time.sleep(0.05)
|
|
18
|
+
b = f()
|
|
19
|
+
a == b # True - both calls return the exact same timestamp
|
|
20
|
+
|
|
21
|
+
Deliberately narrow, unlike a general "any call as a default is
|
|
22
|
+
suspicious" rule (flake8-bugbear's B008, which also flags things like
|
|
23
|
+
`def f(x=some_factory()):` that may well be an intentional
|
|
24
|
+
compute-once memoization): this only flags a curated set of functions
|
|
25
|
+
where "the same value every call" can never be what the author wanted -
|
|
26
|
+
the current time, a random number, a new UUID. A generic factory call
|
|
27
|
+
as a default might be deliberate; a cached `time.time()` never is.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import ast
|
|
33
|
+
|
|
34
|
+
from codehound.core import Check, Finding
|
|
35
|
+
|
|
36
|
+
# (module, attribute) pairs whose result changes every call and would
|
|
37
|
+
# never sensibly be memoized as a shared default.
|
|
38
|
+
_NONDETERMINISTIC_CALLS: dict[tuple[str, str], str] = {
|
|
39
|
+
("time", "time"): "time.time()",
|
|
40
|
+
("time", "monotonic"): "time.monotonic()",
|
|
41
|
+
("time", "perf_counter"): "time.perf_counter()",
|
|
42
|
+
("datetime", "now"): "datetime.now()",
|
|
43
|
+
("datetime", "utcnow"): "datetime.utcnow()",
|
|
44
|
+
("date", "today"): "date.today()",
|
|
45
|
+
("random", "random"): "random.random()",
|
|
46
|
+
("random", "randint"): "random.randint()",
|
|
47
|
+
("random", "choice"): "random.choice()",
|
|
48
|
+
("uuid", "uuid4"): "uuid.uuid4()",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _nondeterministic_call_name(node: ast.expr) -> str | None:
|
|
53
|
+
if not isinstance(node, ast.Call):
|
|
54
|
+
return None
|
|
55
|
+
func = node.func
|
|
56
|
+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
57
|
+
return _NONDETERMINISTIC_CALLS.get((func.value.id, func.attr))
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _iter_defaults(args: ast.arguments):
|
|
62
|
+
positional = args.posonlyargs + args.args
|
|
63
|
+
for arg, default in zip(reversed(positional), reversed(args.defaults)):
|
|
64
|
+
yield arg, default
|
|
65
|
+
for arg, default in zip(args.kwonlyargs, args.kw_defaults):
|
|
66
|
+
if default is not None:
|
|
67
|
+
yield arg, default
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NondeterministicDefault(Check):
|
|
71
|
+
code = "CH032"
|
|
72
|
+
name = "nondeterministic-default-argument"
|
|
73
|
+
description = "Default argument calls a function (time/random/uuid) whose result changes every call."
|
|
74
|
+
|
|
75
|
+
def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
|
|
76
|
+
findings: list[Finding] = []
|
|
77
|
+
for node in ast.walk(tree):
|
|
78
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
79
|
+
continue
|
|
80
|
+
for arg, default in _iter_defaults(node.args):
|
|
81
|
+
call_text = _nondeterministic_call_name(default)
|
|
82
|
+
if call_text is None:
|
|
83
|
+
continue
|
|
84
|
+
name = getattr(node, "name", "<lambda>")
|
|
85
|
+
findings.append(
|
|
86
|
+
Finding(
|
|
87
|
+
path=path,
|
|
88
|
+
line=default.lineno,
|
|
89
|
+
col=default.col_offset,
|
|
90
|
+
code=self.code,
|
|
91
|
+
message=(
|
|
92
|
+
f"default value for `{arg.arg}` in `{name}` calls `{call_text}` - "
|
|
93
|
+
f"evaluated once, at definition time, not on every call. Every "
|
|
94
|
+
f"invocation using the default gets the exact same value. Use "
|
|
95
|
+
f"`{arg.arg}=None` and compute it inside the function body instead."
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
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
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""CH033 - ``.strip()``/``.lstrip()``/``.rstrip()`` called with a multi-character string.
|
|
2
|
+
|
|
3
|
+
`str.strip(chars)` doesn't remove a substring - `chars` is a *set* of
|
|
4
|
+
characters, stripped one at a time from each end for as long as the
|
|
5
|
+
next character matches something in the set. Verified directly, in the
|
|
6
|
+
shape this actually bites people in:
|
|
7
|
+
|
|
8
|
+
"report.txt".strip(".txt") # 'repor' - not 'report'
|
|
9
|
+
"test.txt".strip(".txt") # 'es' - not 'test'
|
|
10
|
+
|
|
11
|
+
Both look like "strip the `.txt` suffix," and both are wrong: the
|
|
12
|
+
trailing `t` in "report" and the leading `t` in "test" are themselves in
|
|
13
|
+
the character set `{'.', 't', 'x'}`, so they get eaten too. This is
|
|
14
|
+
flake8-bugbear's B005, included here because it's exactly the kind of
|
|
15
|
+
subtle, easy-to-write, hard-to-notice correctness bug this project
|
|
16
|
+
exists to catch - the code runs, returns a string, and looks plausible
|
|
17
|
+
at a glance.
|
|
18
|
+
|
|
19
|
+
Scanning ~30 real frameworks turned up over a hundred multi-character
|
|
20
|
+
`.strip()` calls, and the overwhelming majority were deliberate,
|
|
21
|
+
correct uses of the character-*set* semantics: `.strip('\r\n')` to
|
|
22
|
+
absorb either line-ending convention, `.strip('[]')`/`.strip('()')` to
|
|
23
|
+
peel matching brackets, `.strip('\'"')` to drop either quote style,
|
|
24
|
+
`.lstrip('│ ├└─')` to strip box-drawing tree glyphs.
|
|
25
|
+
None of those are "misread as a substring" mistakes - a reader who
|
|
26
|
+
sees a bag of punctuation or whitespace correctly reads it as a set.
|
|
27
|
+
The real mistakes only showed up when the argument contained a letter
|
|
28
|
+
or digit: `.lstrip('data:')` (the SSE "data: " prefix), `.strip('/v1')`
|
|
29
|
+
(an API version suffix), `.strip('THREAD#')`/`.strip('STEP#')` (DynamoDB
|
|
30
|
+
key prefixes), `.rstrip('/n')` (almost certainly a typo for `'\n'`) -
|
|
31
|
+
those really do read like someone meant a prefix or suffix and reached
|
|
32
|
+
for the wrong method.
|
|
33
|
+
|
|
34
|
+
So this only flags a multi-character argument that contains at least
|
|
35
|
+
one letter or digit, and isn't just the same character repeated
|
|
36
|
+
(`.strip('```')` strips one character, backtick, no matter how many
|
|
37
|
+
times it appears in the argument - no charset/substring ambiguity to
|
|
38
|
+
get wrong either).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import ast
|
|
44
|
+
|
|
45
|
+
from codehound.core import Check, Finding
|
|
46
|
+
|
|
47
|
+
_STRIP_METHODS = {"strip", "lstrip", "rstrip"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class StripMultichar(Check):
|
|
51
|
+
code = "CH033"
|
|
52
|
+
name = "strip-multichar-argument"
|
|
53
|
+
description = "str.strip()/.lstrip()/.rstrip() with a multi-character argument strips a character set, not a substring."
|
|
54
|
+
|
|
55
|
+
def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
|
|
56
|
+
findings: list[Finding] = []
|
|
57
|
+
for node in ast.walk(tree):
|
|
58
|
+
if not (
|
|
59
|
+
isinstance(node, ast.Call)
|
|
60
|
+
and isinstance(node.func, ast.Attribute)
|
|
61
|
+
and node.func.attr in _STRIP_METHODS
|
|
62
|
+
and len(node.args) == 1
|
|
63
|
+
and not node.keywords
|
|
64
|
+
):
|
|
65
|
+
continue
|
|
66
|
+
arg = node.args[0]
|
|
67
|
+
if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str) and len(arg.value) > 1):
|
|
68
|
+
continue
|
|
69
|
+
chars = arg.value
|
|
70
|
+
if len(set(chars)) == 1:
|
|
71
|
+
continue
|
|
72
|
+
if not any(c.isalnum() for c in chars):
|
|
73
|
+
continue
|
|
74
|
+
findings.append(
|
|
75
|
+
Finding(
|
|
76
|
+
path=path,
|
|
77
|
+
line=node.lineno,
|
|
78
|
+
col=node.col_offset,
|
|
79
|
+
code=self.code,
|
|
80
|
+
message=(
|
|
81
|
+
f"`.{node.func.attr}({arg.value!r})` strips any of these characters "
|
|
82
|
+
f"from each end, not the substring `{arg.value!r}` - "
|
|
83
|
+
f"`{arg.value[0]!r}{arg.value[-1]!r}` and every character in between are "
|
|
84
|
+
f"each removed independently for as long as they keep matching."
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return findings
|
|
@@ -468,6 +468,71 @@ def test_ch010_still_flags_lambda_appended_even_when_named_like_a_key_fn():
|
|
|
468
468
|
assert len(findings) == 1
|
|
469
469
|
|
|
470
470
|
|
|
471
|
+
def test_ch010_flags_nested_def_capturing_loop_variable():
|
|
472
|
+
# Same bug as the lambda case, `def` instead - flake8-bugbear's B023
|
|
473
|
+
# covers this shape too.
|
|
474
|
+
code = (
|
|
475
|
+
"callbacks = []\n"
|
|
476
|
+
"for i in range(3):\n"
|
|
477
|
+
" def handler():\n"
|
|
478
|
+
" return i\n"
|
|
479
|
+
" callbacks.append(handler)\n"
|
|
480
|
+
)
|
|
481
|
+
findings = _run(code, ["CH010"])
|
|
482
|
+
assert len(findings) == 1
|
|
483
|
+
assert findings[0].code == "CH010"
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def test_ch010_flags_nested_def_returned():
|
|
487
|
+
code = (
|
|
488
|
+
"def make_handlers():\n"
|
|
489
|
+
" handlers = []\n"
|
|
490
|
+
" for i in range(3):\n"
|
|
491
|
+
" def handler():\n"
|
|
492
|
+
" return i\n"
|
|
493
|
+
" handlers.append(handler)\n"
|
|
494
|
+
" return handlers\n"
|
|
495
|
+
)
|
|
496
|
+
findings = _run(code, ["CH010"])
|
|
497
|
+
assert len(findings) == 1
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def test_ch010_ignores_nested_def_shadowed_by_own_parameter():
|
|
501
|
+
# def's own parameter `i` shadows the outer loop variable - the same
|
|
502
|
+
# guard the lambda case already has for lambda i=i: i.
|
|
503
|
+
code = (
|
|
504
|
+
"callbacks = []\n"
|
|
505
|
+
"for i in range(3):\n"
|
|
506
|
+
" def handler(i):\n"
|
|
507
|
+
" return i\n"
|
|
508
|
+
" callbacks.append(handler)\n"
|
|
509
|
+
)
|
|
510
|
+
assert _run(code, ["CH010"]) == []
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def test_ch010_ignores_nested_def_not_referencing_loop_var():
|
|
514
|
+
code = (
|
|
515
|
+
"callbacks = []\n"
|
|
516
|
+
"for i in range(3):\n"
|
|
517
|
+
" def handler():\n"
|
|
518
|
+
" return 42\n"
|
|
519
|
+
" callbacks.append(handler)\n"
|
|
520
|
+
)
|
|
521
|
+
assert _run(code, ["CH010"]) == []
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def test_ch010_ignores_nested_def_never_stored():
|
|
525
|
+
# Defined and called immediately within the same iteration - nothing
|
|
526
|
+
# outlives the loop, same idea as the immediately-invoked lambda case.
|
|
527
|
+
code = (
|
|
528
|
+
"for i in range(3):\n"
|
|
529
|
+
" def handler():\n"
|
|
530
|
+
" return i\n"
|
|
531
|
+
" print(handler())\n"
|
|
532
|
+
)
|
|
533
|
+
assert _run(code, ["CH010"]) == []
|
|
534
|
+
|
|
535
|
+
|
|
471
536
|
# --- CH011 lru-cache-on-method ------------------------------------------------------
|
|
472
537
|
|
|
473
538
|
|
|
@@ -1707,3 +1772,214 @@ def test_ch031_ignores_pool_stored_as_attribute():
|
|
|
1707
1772
|
)
|
|
1708
1773
|
assert _run(code, ["CH031"]) == []
|
|
1709
1774
|
|
|
1775
|
+
|
|
1776
|
+
# --- CH032 nondeterministic-default-argument --------------------------------------------
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def test_ch032_flags_time_time_default():
|
|
1780
|
+
code = "import time\ndef f(x=time.time()):\n return x\n"
|
|
1781
|
+
findings = _run(code, ["CH032"])
|
|
1782
|
+
assert len(findings) == 1
|
|
1783
|
+
assert findings[0].code == "CH032"
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
def test_ch032_flags_datetime_now_default():
|
|
1787
|
+
code = "from datetime import datetime\ndef f(x=datetime.now()):\n return x\n"
|
|
1788
|
+
findings = _run(code, ["CH032"])
|
|
1789
|
+
assert len(findings) == 1
|
|
1790
|
+
|
|
1791
|
+
|
|
1792
|
+
def test_ch032_flags_random_default_on_lambda():
|
|
1793
|
+
code = "import random\nf = lambda x=random.random(): x\n"
|
|
1794
|
+
findings = _run(code, ["CH032"])
|
|
1795
|
+
assert len(findings) == 1
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def test_ch032_flags_uuid4_keyword_only_default():
|
|
1799
|
+
code = "import uuid\ndef f(*, id=uuid.uuid4()):\n return id\n"
|
|
1800
|
+
findings = _run(code, ["CH032"])
|
|
1801
|
+
assert len(findings) == 1
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def test_ch032_ignores_none_default():
|
|
1805
|
+
code = "def f(x=None):\n return x\n"
|
|
1806
|
+
assert _run(code, ["CH032"]) == []
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
def test_ch032_ignores_literal_default():
|
|
1810
|
+
code = "def f(x=10):\n return x\n"
|
|
1811
|
+
assert _run(code, ["CH032"]) == []
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
def test_ch032_ignores_unrelated_call_default():
|
|
1815
|
+
code = "def f(x=get_default_timeout()):\n return x\n"
|
|
1816
|
+
assert _run(code, ["CH032"]) == []
|
|
1817
|
+
|
|
1818
|
+
|
|
1819
|
+
def test_ch032_ignores_module_constant_default():
|
|
1820
|
+
code = "import time\ndef f(x=time.sleep):\n return x\n"
|
|
1821
|
+
assert _run(code, ["CH032"]) == []
|
|
1822
|
+
|
|
1823
|
+
|
|
1824
|
+
# --- CH033 strip-multichar-argument ------------------------------------------------------
|
|
1825
|
+
|
|
1826
|
+
|
|
1827
|
+
def test_ch033_flags_strip_with_multichar_string():
|
|
1828
|
+
code = "'report.txt'.strip('.txt')\n"
|
|
1829
|
+
findings = _run(code, ["CH033"])
|
|
1830
|
+
assert len(findings) == 1
|
|
1831
|
+
assert findings[0].code == "CH033"
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
def test_ch033_flags_lstrip_with_multichar_string():
|
|
1835
|
+
code = "'v1.2.3'.lstrip('v.')\n"
|
|
1836
|
+
findings = _run(code, ["CH033"])
|
|
1837
|
+
assert len(findings) == 1
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def test_ch033_flags_rstrip_with_multichar_string():
|
|
1841
|
+
code = "'name.txt'.rstrip('.txt')\n"
|
|
1842
|
+
findings = _run(code, ["CH033"])
|
|
1843
|
+
assert len(findings) == 1
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
def test_ch033_ignores_single_char_argument():
|
|
1847
|
+
code = "'name,'.strip(',')\n"
|
|
1848
|
+
assert _run(code, ["CH033"]) == []
|
|
1849
|
+
|
|
1850
|
+
|
|
1851
|
+
def test_ch033_ignores_strip_with_no_argument():
|
|
1852
|
+
code = "' name '.strip()\n"
|
|
1853
|
+
assert _run(code, ["CH033"]) == []
|
|
1854
|
+
|
|
1855
|
+
|
|
1856
|
+
def test_ch033_ignores_non_string_variable_argument():
|
|
1857
|
+
code = "chars = build_chars()\n'name'.strip(chars)\n"
|
|
1858
|
+
assert _run(code, ["CH033"]) == []
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def test_ch033_ignores_repeated_single_character():
|
|
1862
|
+
code = "'```sql```'.strip('```')\n"
|
|
1863
|
+
assert _run(code, ["CH033"]) == []
|
|
1864
|
+
|
|
1865
|
+
|
|
1866
|
+
def test_ch033_ignores_bracket_or_quote_character_set():
|
|
1867
|
+
code = "'[1,2,3]'.strip('[]')\nline.rstrip('\\r\\n')\ns.strip('\\'\"')\n"
|
|
1868
|
+
assert _run(code, ["CH033"]) == []
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
def test_ch033_flags_argument_with_letters_even_with_punctuation():
|
|
1872
|
+
code = "url.strip('/v1')\n"
|
|
1873
|
+
findings = _run(code, ["CH033"])
|
|
1874
|
+
assert len(findings) == 1
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|