codehound 1.5.0__tar.gz → 1.6.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.5.0 → codehound-1.6.0}/PKG-INFO +76 -7
- {codehound-1.5.0 → codehound-1.6.0}/README.md +75 -6
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/__init__.py +4 -4
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/__init__.py +12 -0
- codehound-1.6.0/src/codehound/checks/floating_timer.py +169 -0
- codehound-1.6.0/src/codehound/checks/is_literal_comparison.py +80 -0
- codehound-1.6.0/src/codehound/checks/mutable_class_attribute.py +108 -0
- codehound-1.6.0/src/codehound/checks/removed_stdlib_attribute.py +75 -0
- codehound-1.6.0/src/codehound/checks/unittest_deprecated_alias.py +68 -0
- codehound-1.6.0/src/codehound/checks/unwaited_subprocess.py +146 -0
- {codehound-1.5.0 → codehound-1.6.0}/tests/test_checks.py +323 -0
- {codehound-1.5.0 → codehound-1.6.0}/.gitignore +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/LICENSE +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/pyproject.toml +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/async_property.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/asyncio_coroutine_decorator.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/bare_except.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/blocking_async.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/collections_abc_import.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/datetime_utcnow.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/discarded_future.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/floating_process.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/floating_task.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/floating_thread.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/get_event_loop.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/loop_closure_capture.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/lru_cache_on_method.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/mutable_defaults.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/removed_getargspec.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/removed_stdlib_module.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/resource_leak.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/unclosed_socket.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/unprotected_lock.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/cli.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/core.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/sarif.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/src/codehound/terminal.py +0 -0
- {codehound-1.5.0 → codehound-1.6.0}/tests/test_output_formats.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: codehound
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.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
|
|
@@ -24,7 +24,7 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
|
|
25
25
|
<h1 align="center">codehound</h1>
|
|
26
26
|
|
|
27
|
-
**An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty-
|
|
27
|
+
**An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty-eight 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
28
|
|
|
29
29
|
[](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
|
|
30
30
|
[](https://pypi.org/project/codehound/)
|
|
@@ -71,7 +71,7 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
|
|
|
71
71
|
pip install codehound
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
Zero dependencies — it's ~
|
|
74
|
+
Zero dependencies — it's ~3,200 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
|
|
75
75
|
|
|
76
76
|
<details>
|
|
77
77
|
<summary>From a clone instead (for development)</summary>
|
|
@@ -132,7 +132,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
|
|
|
132
132
|
```yaml
|
|
133
133
|
repos:
|
|
134
134
|
- repo: https://github.com/kratos0718/codehound
|
|
135
|
-
rev: v1.
|
|
135
|
+
rev: v1.6.0
|
|
136
136
|
hooks:
|
|
137
137
|
- id: codehound
|
|
138
138
|
```
|
|
@@ -165,10 +165,16 @@ repos:
|
|
|
165
165
|
| **CH020** | `bare-except` | A bare `except:` (or unused `except BaseException:`) — also catches `KeyboardInterrupt`/`SystemExit`, so Ctrl-C stops working and `sys.exit()` gets silently absorbed. | hardening rule — real hits in agno, llama_index, marimo, litellm |
|
|
166
166
|
| **CH021** | `removed-stdlib-module` | `import distutils` (removed 3.12) or any of the 19 PEP 594 "dead battery" modules (`cgi`, `imghdr`, `telnetlib`, `nntplib`, …, removed 3.13) — `ImportError` the moment the module loads. | hardening rule — real hit in agno (already guarded, see below) |
|
|
167
167
|
| **CH022** | `removed-asyncio-coroutine-decorator` | `@asyncio.coroutine` — removed in Python 3.11 after a generator-based-coroutine bridge that predates `async def`; `AttributeError` the moment the decorator line runs. | hardening rule |
|
|
168
|
+
| **CH023** | `removed-stdlib-attribute` | A specific removed function on a module that still imports fine — `time.clock()` (3.8), `platform.linux_distribution()`/`.dist()` (3.8), `cgi.escape()` (3.8), `base64.encodestring()`/`.decodestring()` (3.9). | hardening rule — real hit in scikit-learn |
|
|
169
|
+
| **CH024** | `unittest-deprecated-alias` | `self.assertEquals(...)`/`self.failUnless(...)` and a dozen other legacy `unittest.TestCase` aliases — removed in Python 3.12. | hardening rule |
|
|
170
|
+
| **CH025** | `is-literal-comparison` | `x is 1000` / `x is not "foo"` — `is` checks identity, not equality; relies on CPython's small-int caching / string interning, neither guaranteed. (pyflakes F632) | hardening rule — real false positive fixed in litellm's own code, see below |
|
|
171
|
+
| **CH026** | `mutable-class-attribute` | `class C: items = []` mutated via `self.items.append(...)` without ever being reassigned per instance — every instance shares and mutates the *same* list. | **vllm**, **llama_index**, **optuna**, **transformers** — see below |
|
|
172
|
+
| **CH027** | `unwaited-subprocess` | `subprocess.Popen(...)` never `.wait()`ed/`.communicate()`d with, and not context-managed — risks a zombie process and a full pipe buffer deadlocking the child. | hardening rule — real hit in dspy (already handled, see below) |
|
|
173
|
+
| **CH028** | `floating-timer` | `threading.Timer(...)` started but never `.cancel()`ed or handed off — nothing can stop the callback from firing later, on stale context. | hardening rule — real hits in marimo, transformers |
|
|
168
174
|
|
|
169
175
|
`codehound list` prints this from the source of truth.
|
|
170
176
|
|
|
171
|
-
CH007-
|
|
177
|
+
CH007-CH028 don't have found-and-merged bugs behind all of them the way
|
|
172
178
|
CH001-CH006 do - most are hardening rules for well-known Python
|
|
173
179
|
correctness gotchas rather than something this project personally
|
|
174
180
|
tracked down first. CH010 and CH011 are the exceptions: both found
|
|
@@ -271,6 +277,60 @@ a `try:` body whose `except` catches `ImportError` (or anything
|
|
|
271
277
|
broader). A full corpus rescan after both fixes found zero remaining
|
|
272
278
|
CH021 hits.
|
|
273
279
|
|
|
280
|
+
**CH026 found four real, previously-unreported bugs on its first real
|
|
281
|
+
scan.** All four are the exact same shape: a class-level mutable default
|
|
282
|
+
(`items = []`) mutated in place via `self.items.append(...)` (or
|
|
283
|
+
subscript assignment) with no per-instance reassignment anywhere, so
|
|
284
|
+
every instance of the class shares and corrupts the *same* object. In
|
|
285
|
+
vllm's `AXK1ForCausalLM`, `self.packed_modules_mapping["qkv_proj"] =
|
|
286
|
+
[...]` patches a routing table shared by every instance of the model
|
|
287
|
+
class. In llama_index's `ZapierToolSpec`, `self.spec_functions.append(...)`
|
|
288
|
+
means a second tool-spec instance (a different API key, a different
|
|
289
|
+
user) inherits every action name the first instance ever registered. In
|
|
290
|
+
optuna's CLI `_Studies` command, `self._study_list_header.append(...)`
|
|
291
|
+
does the same to a table-header list. In HuggingFace transformers'
|
|
292
|
+
`CodeGenTokenizer`, `self.model_input_names.append("token_type_ids")`
|
|
293
|
+
means constructing one tokenizer with `return_token_type_ids=True`
|
|
294
|
+
silently changes what field every *other* `CodeGenTokenizer` instance in
|
|
295
|
+
the same process expects, regardless of how it was configured - exactly
|
|
296
|
+
the "spooky action at a distance" class of bug this project exists to
|
|
297
|
+
catch. None have PRs yet: vllm and transformers both require AI-assisted
|
|
298
|
+
PRs to carry an explicit disclosure, which this project's own policy
|
|
299
|
+
doesn't do, so those two are documented here rather than filed.
|
|
300
|
+
|
|
301
|
+
**CH028 hit the exact same name-collision problem CH018/CH022 already
|
|
302
|
+
had a guard for, because that guard didn't get reused.** The very first
|
|
303
|
+
corpus scan came back with ~30 hits, almost all in agno - which doesn't
|
|
304
|
+
use `threading.Timer` at all. `from agno.utils.timer import Timer` is
|
|
305
|
+
agno's own unrelated stopwatch class, called as `Timer()` with zero
|
|
306
|
+
arguments (real `threading.Timer` requires `interval` and `function` and
|
|
307
|
+
would raise `TypeError` immediately). Fixed by requiring `from threading
|
|
308
|
+
import Timer` before trusting a bare `Timer(...)` call - the same guard
|
|
309
|
+
already built for CH022's `coroutine` minutes earlier in the same
|
|
310
|
+
session, just not applied here the first time. Also missed CH009's
|
|
311
|
+
`daemon=True` escape entirely (found in weaviate-python-client's
|
|
312
|
+
watchdog timer, `_timeout_timer.daemon = True`, a deliberate
|
|
313
|
+
"outlive the caller" choice) - added both a constructor-kwarg and a
|
|
314
|
+
post-construction-assignment check for it, matching CH009 exactly. Real
|
|
315
|
+
hits remain in marimo (a non-daemon browser-opening timer, never
|
|
316
|
+
cancelled) and transformers (a chained, never-captured checkpoint-retry
|
|
317
|
+
timer).
|
|
318
|
+
|
|
319
|
+
**CH025 and CH027 each found one real bug in the first scan, and one
|
|
320
|
+
real gap in the check.** CH025 (is-literal-comparison) flagged litellm's
|
|
321
|
+
`if "usage" in response_obj is not None:` - but for the wrong reason.
|
|
322
|
+
`ast.Compare` puts every operand and every op from a chained comparison
|
|
323
|
+
in one node; checking "is there a literal anywhere" and "is there an
|
|
324
|
+
`is`/`is not` anywhere" independently, without pairing each op with its
|
|
325
|
+
own adjacent operands, matched the string literal (paired with `in`)
|
|
326
|
+
against the wrong op (`is not`, actually comparing `response_obj` to the
|
|
327
|
+
allowed singleton `None`). Fixed by walking the chain as adjacent
|
|
328
|
+
`(left, op, right)` triples. CH027 (unwaited-subprocess) flagged dspy's
|
|
329
|
+
`process = subprocess.Popen(...)`, followed by `lm.process = process` -
|
|
330
|
+
a real hand-off to a different object, reaped later through a separate
|
|
331
|
+
`terminate_process(lm.process)` call, the same "stored as any object's
|
|
332
|
+
attribute" escape CH009/CH016/CH028 already needed. Added it.
|
|
333
|
+
|
|
274
334
|
**Two checks we built and did not ship.** `exception-chaining` (`except X
|
|
275
335
|
as e: raise Y(...)` with no `from e`, discarding the real traceback -
|
|
276
336
|
overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
|
|
@@ -328,12 +388,18 @@ codehound/
|
|
|
328
388
|
├── removed_getargspec.py (CH019)
|
|
329
389
|
├── bare_except.py (CH020)
|
|
330
390
|
├── removed_stdlib_module.py (CH021)
|
|
331
|
-
|
|
391
|
+
├── asyncio_coroutine_decorator.py (CH022)
|
|
392
|
+
├── removed_stdlib_attribute.py (CH023)
|
|
393
|
+
├── unittest_deprecated_alias.py (CH024)
|
|
394
|
+
├── is_literal_comparison.py (CH025)
|
|
395
|
+
├── mutable_class_attribute.py (CH026)
|
|
396
|
+
├── unwaited_subprocess.py (CH027)
|
|
397
|
+
└── floating_timer.py (CH028)
|
|
332
398
|
```
|
|
333
399
|
|
|
334
400
|
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.
|
|
335
401
|
|
|
336
|
-
**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. 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."
|
|
402
|
+
**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. 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."
|
|
337
403
|
|
|
338
404
|
---
|
|
339
405
|
|
|
@@ -365,6 +431,9 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
365
431
|
sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
|
|
366
432
|
- [x] 22 checks — removed stdlib modules (`distutils`, PEP 594 "dead
|
|
367
433
|
batteries"), removed `@asyncio.coroutine` decorator — CH021-CH022
|
|
434
|
+
- [x] 28 checks — removed stdlib functions, deprecated unittest aliases,
|
|
435
|
+
`is`-literal comparisons, mutable class attributes, unwaited
|
|
436
|
+
subprocesses, floating timers — CH023-CH028
|
|
368
437
|
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
369
438
|
- [ ] Sync HTTP clients constructed inside async request handlers
|
|
370
439
|
- [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
|
|
@@ -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 — twenty-
|
|
7
|
+
**An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty-eight 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/)
|
|
@@ -51,7 +51,7 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
|
|
|
51
51
|
pip install codehound
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
Zero dependencies — it's ~
|
|
54
|
+
Zero dependencies — it's ~3,200 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
|
|
55
55
|
|
|
56
56
|
<details>
|
|
57
57
|
<summary>From a clone instead (for development)</summary>
|
|
@@ -112,7 +112,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
|
|
|
112
112
|
```yaml
|
|
113
113
|
repos:
|
|
114
114
|
- repo: https://github.com/kratos0718/codehound
|
|
115
|
-
rev: v1.
|
|
115
|
+
rev: v1.6.0
|
|
116
116
|
hooks:
|
|
117
117
|
- id: codehound
|
|
118
118
|
```
|
|
@@ -145,10 +145,16 @@ repos:
|
|
|
145
145
|
| **CH020** | `bare-except` | A bare `except:` (or unused `except BaseException:`) — also catches `KeyboardInterrupt`/`SystemExit`, so Ctrl-C stops working and `sys.exit()` gets silently absorbed. | hardening rule — real hits in agno, llama_index, marimo, litellm |
|
|
146
146
|
| **CH021** | `removed-stdlib-module` | `import distutils` (removed 3.12) or any of the 19 PEP 594 "dead battery" modules (`cgi`, `imghdr`, `telnetlib`, `nntplib`, …, removed 3.13) — `ImportError` the moment the module loads. | hardening rule — real hit in agno (already guarded, see below) |
|
|
147
147
|
| **CH022** | `removed-asyncio-coroutine-decorator` | `@asyncio.coroutine` — removed in Python 3.11 after a generator-based-coroutine bridge that predates `async def`; `AttributeError` the moment the decorator line runs. | hardening rule |
|
|
148
|
+
| **CH023** | `removed-stdlib-attribute` | A specific removed function on a module that still imports fine — `time.clock()` (3.8), `platform.linux_distribution()`/`.dist()` (3.8), `cgi.escape()` (3.8), `base64.encodestring()`/`.decodestring()` (3.9). | hardening rule — real hit in scikit-learn |
|
|
149
|
+
| **CH024** | `unittest-deprecated-alias` | `self.assertEquals(...)`/`self.failUnless(...)` and a dozen other legacy `unittest.TestCase` aliases — removed in Python 3.12. | hardening rule |
|
|
150
|
+
| **CH025** | `is-literal-comparison` | `x is 1000` / `x is not "foo"` — `is` checks identity, not equality; relies on CPython's small-int caching / string interning, neither guaranteed. (pyflakes F632) | hardening rule — real false positive fixed in litellm's own code, see below |
|
|
151
|
+
| **CH026** | `mutable-class-attribute` | `class C: items = []` mutated via `self.items.append(...)` without ever being reassigned per instance — every instance shares and mutates the *same* list. | **vllm**, **llama_index**, **optuna**, **transformers** — see below |
|
|
152
|
+
| **CH027** | `unwaited-subprocess` | `subprocess.Popen(...)` never `.wait()`ed/`.communicate()`d with, and not context-managed — risks a zombie process and a full pipe buffer deadlocking the child. | hardening rule — real hit in dspy (already handled, see below) |
|
|
153
|
+
| **CH028** | `floating-timer` | `threading.Timer(...)` started but never `.cancel()`ed or handed off — nothing can stop the callback from firing later, on stale context. | hardening rule — real hits in marimo, transformers |
|
|
148
154
|
|
|
149
155
|
`codehound list` prints this from the source of truth.
|
|
150
156
|
|
|
151
|
-
CH007-
|
|
157
|
+
CH007-CH028 don't have found-and-merged bugs behind all of them the way
|
|
152
158
|
CH001-CH006 do - most are hardening rules for well-known Python
|
|
153
159
|
correctness gotchas rather than something this project personally
|
|
154
160
|
tracked down first. CH010 and CH011 are the exceptions: both found
|
|
@@ -251,6 +257,60 @@ a `try:` body whose `except` catches `ImportError` (or anything
|
|
|
251
257
|
broader). A full corpus rescan after both fixes found zero remaining
|
|
252
258
|
CH021 hits.
|
|
253
259
|
|
|
260
|
+
**CH026 found four real, previously-unreported bugs on its first real
|
|
261
|
+
scan.** All four are the exact same shape: a class-level mutable default
|
|
262
|
+
(`items = []`) mutated in place via `self.items.append(...)` (or
|
|
263
|
+
subscript assignment) with no per-instance reassignment anywhere, so
|
|
264
|
+
every instance of the class shares and corrupts the *same* object. In
|
|
265
|
+
vllm's `AXK1ForCausalLM`, `self.packed_modules_mapping["qkv_proj"] =
|
|
266
|
+
[...]` patches a routing table shared by every instance of the model
|
|
267
|
+
class. In llama_index's `ZapierToolSpec`, `self.spec_functions.append(...)`
|
|
268
|
+
means a second tool-spec instance (a different API key, a different
|
|
269
|
+
user) inherits every action name the first instance ever registered. In
|
|
270
|
+
optuna's CLI `_Studies` command, `self._study_list_header.append(...)`
|
|
271
|
+
does the same to a table-header list. In HuggingFace transformers'
|
|
272
|
+
`CodeGenTokenizer`, `self.model_input_names.append("token_type_ids")`
|
|
273
|
+
means constructing one tokenizer with `return_token_type_ids=True`
|
|
274
|
+
silently changes what field every *other* `CodeGenTokenizer` instance in
|
|
275
|
+
the same process expects, regardless of how it was configured - exactly
|
|
276
|
+
the "spooky action at a distance" class of bug this project exists to
|
|
277
|
+
catch. None have PRs yet: vllm and transformers both require AI-assisted
|
|
278
|
+
PRs to carry an explicit disclosure, which this project's own policy
|
|
279
|
+
doesn't do, so those two are documented here rather than filed.
|
|
280
|
+
|
|
281
|
+
**CH028 hit the exact same name-collision problem CH018/CH022 already
|
|
282
|
+
had a guard for, because that guard didn't get reused.** The very first
|
|
283
|
+
corpus scan came back with ~30 hits, almost all in agno - which doesn't
|
|
284
|
+
use `threading.Timer` at all. `from agno.utils.timer import Timer` is
|
|
285
|
+
agno's own unrelated stopwatch class, called as `Timer()` with zero
|
|
286
|
+
arguments (real `threading.Timer` requires `interval` and `function` and
|
|
287
|
+
would raise `TypeError` immediately). Fixed by requiring `from threading
|
|
288
|
+
import Timer` before trusting a bare `Timer(...)` call - the same guard
|
|
289
|
+
already built for CH022's `coroutine` minutes earlier in the same
|
|
290
|
+
session, just not applied here the first time. Also missed CH009's
|
|
291
|
+
`daemon=True` escape entirely (found in weaviate-python-client's
|
|
292
|
+
watchdog timer, `_timeout_timer.daemon = True`, a deliberate
|
|
293
|
+
"outlive the caller" choice) - added both a constructor-kwarg and a
|
|
294
|
+
post-construction-assignment check for it, matching CH009 exactly. Real
|
|
295
|
+
hits remain in marimo (a non-daemon browser-opening timer, never
|
|
296
|
+
cancelled) and transformers (a chained, never-captured checkpoint-retry
|
|
297
|
+
timer).
|
|
298
|
+
|
|
299
|
+
**CH025 and CH027 each found one real bug in the first scan, and one
|
|
300
|
+
real gap in the check.** CH025 (is-literal-comparison) flagged litellm's
|
|
301
|
+
`if "usage" in response_obj is not None:` - but for the wrong reason.
|
|
302
|
+
`ast.Compare` puts every operand and every op from a chained comparison
|
|
303
|
+
in one node; checking "is there a literal anywhere" and "is there an
|
|
304
|
+
`is`/`is not` anywhere" independently, without pairing each op with its
|
|
305
|
+
own adjacent operands, matched the string literal (paired with `in`)
|
|
306
|
+
against the wrong op (`is not`, actually comparing `response_obj` to the
|
|
307
|
+
allowed singleton `None`). Fixed by walking the chain as adjacent
|
|
308
|
+
`(left, op, right)` triples. CH027 (unwaited-subprocess) flagged dspy's
|
|
309
|
+
`process = subprocess.Popen(...)`, followed by `lm.process = process` -
|
|
310
|
+
a real hand-off to a different object, reaped later through a separate
|
|
311
|
+
`terminate_process(lm.process)` call, the same "stored as any object's
|
|
312
|
+
attribute" escape CH009/CH016/CH028 already needed. Added it.
|
|
313
|
+
|
|
254
314
|
**Two checks we built and did not ship.** `exception-chaining` (`except X
|
|
255
315
|
as e: raise Y(...)` with no `from e`, discarding the real traceback -
|
|
256
316
|
overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
|
|
@@ -308,12 +368,18 @@ codehound/
|
|
|
308
368
|
├── removed_getargspec.py (CH019)
|
|
309
369
|
├── bare_except.py (CH020)
|
|
310
370
|
├── removed_stdlib_module.py (CH021)
|
|
311
|
-
|
|
371
|
+
├── asyncio_coroutine_decorator.py (CH022)
|
|
372
|
+
├── removed_stdlib_attribute.py (CH023)
|
|
373
|
+
├── unittest_deprecated_alias.py (CH024)
|
|
374
|
+
├── is_literal_comparison.py (CH025)
|
|
375
|
+
├── mutable_class_attribute.py (CH026)
|
|
376
|
+
├── unwaited_subprocess.py (CH027)
|
|
377
|
+
└── floating_timer.py (CH028)
|
|
312
378
|
```
|
|
313
379
|
|
|
314
380
|
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.
|
|
315
381
|
|
|
316
|
-
**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. 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."
|
|
382
|
+
**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. 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."
|
|
317
383
|
|
|
318
384
|
---
|
|
319
385
|
|
|
@@ -345,6 +411,9 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
345
411
|
sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
|
|
346
412
|
- [x] 22 checks — removed stdlib modules (`distutils`, PEP 594 "dead
|
|
347
413
|
batteries"), removed `@asyncio.coroutine` decorator — CH021-CH022
|
|
414
|
+
- [x] 28 checks — removed stdlib functions, deprecated unittest aliases,
|
|
415
|
+
`is`-literal comparisons, mutable class attributes, unwaited
|
|
416
|
+
subprocesses, floating timers — CH023-CH028
|
|
348
417
|
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
349
418
|
- [ ] Sync HTTP clients constructed inside async request handlers
|
|
350
419
|
- [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""codehound - an AST-based static analyzer that hunts real bugs in Python code.
|
|
2
2
|
|
|
3
|
-
Twenty-
|
|
4
|
-
and fixed (or opened as a PR) in a popular open-source AI framework
|
|
3
|
+
Twenty-eight checks. Eight are each backed by a bug that was actually
|
|
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-CH028) 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
|
"""
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
from codehound.checks import ALL_CHECKS, get_checks
|
|
14
14
|
from codehound.core import Check, Finding, scan_file, scan_path
|
|
15
15
|
|
|
16
|
-
__version__ = "1.
|
|
16
|
+
__version__ = "1.6.0"
|
|
17
17
|
|
|
18
18
|
__all__ = [
|
|
19
19
|
"ALL_CHECKS",
|
|
@@ -13,17 +13,23 @@ from codehound.checks.discarded_future import DiscardedFuture
|
|
|
13
13
|
from codehound.checks.floating_process import FloatingProcess
|
|
14
14
|
from codehound.checks.floating_task import FloatingTask
|
|
15
15
|
from codehound.checks.floating_thread import FloatingThread
|
|
16
|
+
from codehound.checks.floating_timer import FloatingTimer
|
|
16
17
|
from codehound.checks.get_event_loop import DeprecatedGetEventLoop
|
|
18
|
+
from codehound.checks.is_literal_comparison import IsLiteralComparison
|
|
17
19
|
from codehound.checks.loop_closure_capture import LoopClosureCapture
|
|
18
20
|
from codehound.checks.lru_cache_on_method import LruCacheOnMethod
|
|
21
|
+
from codehound.checks.mutable_class_attribute import MutableClassAttribute
|
|
19
22
|
from codehound.checks.mutable_defaults import MutableDefaultArgument
|
|
20
23
|
from codehound.checks.removed_asyncio_task_methods import RemovedAsyncioTaskMethods
|
|
21
24
|
from codehound.checks.removed_getargspec import RemovedGetargspec
|
|
25
|
+
from codehound.checks.removed_stdlib_attribute import RemovedStdlibAttribute
|
|
22
26
|
from codehound.checks.removed_stdlib_module import RemovedStdlibModule
|
|
23
27
|
from codehound.checks.resource_leak import UnclosedFileHandle
|
|
24
28
|
from codehound.checks.unawaited_coroutine import UnawaitedCoroutineCall
|
|
25
29
|
from codehound.checks.unclosed_socket import UnclosedSocket
|
|
30
|
+
from codehound.checks.unittest_deprecated_alias import UnittestDeprecatedAlias
|
|
26
31
|
from codehound.checks.unprotected_lock import UnprotectedLockAcquire
|
|
32
|
+
from codehound.checks.unwaited_subprocess import UnwaitedSubprocess
|
|
27
33
|
from codehound.core import Check
|
|
28
34
|
|
|
29
35
|
ALL_CHECKS: list[type[Check]] = [
|
|
@@ -49,6 +55,12 @@ ALL_CHECKS: list[type[Check]] = [
|
|
|
49
55
|
BareExcept,
|
|
50
56
|
RemovedStdlibModule,
|
|
51
57
|
AsyncioCoroutineDecorator,
|
|
58
|
+
RemovedStdlibAttribute,
|
|
59
|
+
UnittestDeprecatedAlias,
|
|
60
|
+
IsLiteralComparison,
|
|
61
|
+
MutableClassAttribute,
|
|
62
|
+
UnwaitedSubprocess,
|
|
63
|
+
FloatingTimer,
|
|
52
64
|
]
|
|
53
65
|
|
|
54
66
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""CH028 - ``threading.Timer`` started without keeping a way to cancel it.
|
|
2
|
+
|
|
3
|
+
A `Timer` schedules its callback to run *later*, on its own thread, once
|
|
4
|
+
the interval elapses. Unlike a plain `Thread` doing work right now, the
|
|
5
|
+
risk with an un-captured or un-cancelled `Timer` isn't just "outlives the
|
|
6
|
+
function" - it's that nothing can stop the callback from firing after
|
|
7
|
+
the context it was meant to act on is already gone: a retry timer that
|
|
8
|
+
fires after the operation it would retry has already completed, a
|
|
9
|
+
debounce timer that resets state on an object that was already torn
|
|
10
|
+
down. `.cancel()` before that happens is the only way to prevent it; if
|
|
11
|
+
nothing ever keeps a reference capable of calling it, the callback is
|
|
12
|
+
guaranteed to run no matter what changes in the meantime.
|
|
13
|
+
|
|
14
|
+
Same two shapes as CH009's floating-thread:
|
|
15
|
+
- Chained: ``threading.Timer(30, cb).start()`` - never captured, so
|
|
16
|
+
cancelling it later is structurally impossible.
|
|
17
|
+
- Assigned: ``t = threading.Timer(30, cb); t.start()`` - captured, but no
|
|
18
|
+
matching ``t.cancel()`` before the enclosing function returns, and not
|
|
19
|
+
handed off (returned, stored as any object's attribute), and not
|
|
20
|
+
marked ``daemon=True`` (a deliberate "let this outlive the caller"
|
|
21
|
+
choice, the same escape CH009 recognizes).
|
|
22
|
+
|
|
23
|
+
A bare ``Timer(...)`` is only trusted to mean `threading.Timer` if the
|
|
24
|
+
file actually imported it via `from threading import Timer` - the same
|
|
25
|
+
name-collision guard CH018/CH022 use. A real false positive found while
|
|
26
|
+
building this: agno defines its own unrelated stopwatch-style `Timer`
|
|
27
|
+
class (`from agno.utils.timer import Timer`, called as `Timer()` with no
|
|
28
|
+
arguments at all - `threading.Timer` requires `interval` and `function`
|
|
29
|
+
and would raise `TypeError` immediately if it were really that class),
|
|
30
|
+
and every one of its ~30 corpus hits was this same collision.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import ast
|
|
36
|
+
|
|
37
|
+
from codehound.core import Check, Finding, enclosing_function
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _imports_timer_from_threading(tree: ast.AST) -> bool:
|
|
41
|
+
for node in ast.walk(tree):
|
|
42
|
+
if isinstance(node, ast.ImportFrom) and node.module == "threading":
|
|
43
|
+
if any(alias.name == "Timer" for alias in node.names):
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_timer_call(node: ast.expr, trust_bare_name: bool) -> bool:
|
|
49
|
+
if not isinstance(node, ast.Call):
|
|
50
|
+
return False
|
|
51
|
+
func = node.func
|
|
52
|
+
if isinstance(func, ast.Attribute):
|
|
53
|
+
return func.attr == "Timer" and isinstance(func.value, ast.Name) and func.value.id == "threading"
|
|
54
|
+
if isinstance(func, ast.Name):
|
|
55
|
+
return trust_bare_name and func.id == "Timer"
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _has_daemon_true_kwarg(call: ast.Call) -> bool:
|
|
60
|
+
for kw in call.keywords:
|
|
61
|
+
if kw.arg == "daemon" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FloatingTimer(Check):
|
|
67
|
+
code = "CH028"
|
|
68
|
+
name = "floating-timer"
|
|
69
|
+
description = "threading.Timer started without keeping a way to cancel() it."
|
|
70
|
+
|
|
71
|
+
def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
|
|
72
|
+
findings: list[Finding] = []
|
|
73
|
+
trust_bare_name = _imports_timer_from_threading(tree)
|
|
74
|
+
|
|
75
|
+
# Chained: threading.Timer(...).start() with no assignment at all.
|
|
76
|
+
for node in ast.walk(tree):
|
|
77
|
+
if not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call):
|
|
78
|
+
continue
|
|
79
|
+
call = node.value
|
|
80
|
+
if not (isinstance(call.func, ast.Attribute) and call.func.attr == "start"):
|
|
81
|
+
continue
|
|
82
|
+
receiver = call.func.value
|
|
83
|
+
if not _is_timer_call(receiver, trust_bare_name):
|
|
84
|
+
continue
|
|
85
|
+
if isinstance(receiver, ast.Call) and _has_daemon_true_kwarg(receiver):
|
|
86
|
+
continue
|
|
87
|
+
findings.append(
|
|
88
|
+
Finding(
|
|
89
|
+
path=path,
|
|
90
|
+
line=node.lineno,
|
|
91
|
+
col=node.col_offset,
|
|
92
|
+
code=self.code,
|
|
93
|
+
message=(
|
|
94
|
+
"`threading.Timer(...).start()` - the timer is never captured, so it can "
|
|
95
|
+
"never be cancelled; keep a reference so it can be cancel()ed if the "
|
|
96
|
+
"context it would act on goes away before it fires."
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Assigned: t = threading.Timer(...); ... t.start() ... [no t.cancel()]
|
|
102
|
+
for node in ast.walk(tree):
|
|
103
|
+
if not isinstance(node, ast.Assign) or not _is_timer_call(node.value, trust_bare_name):
|
|
104
|
+
continue
|
|
105
|
+
if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name):
|
|
106
|
+
continue
|
|
107
|
+
name = node.targets[0].id
|
|
108
|
+
if _has_daemon_true_kwarg(node.value):
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
fn = enclosing_function(node, parents)
|
|
112
|
+
if fn is None:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
started = False
|
|
116
|
+
cancelled = False
|
|
117
|
+
escapes = False
|
|
118
|
+
for n in ast.walk(fn):
|
|
119
|
+
if (
|
|
120
|
+
isinstance(n, ast.Call)
|
|
121
|
+
and isinstance(n.func, ast.Attribute)
|
|
122
|
+
and isinstance(n.func.value, ast.Name)
|
|
123
|
+
and n.func.value.id == name
|
|
124
|
+
):
|
|
125
|
+
if n.func.attr == "start":
|
|
126
|
+
started = True
|
|
127
|
+
elif n.func.attr == "cancel":
|
|
128
|
+
cancelled = True
|
|
129
|
+
elif isinstance(n, ast.Assign):
|
|
130
|
+
for tgt in n.targets:
|
|
131
|
+
if (
|
|
132
|
+
isinstance(tgt, ast.Attribute)
|
|
133
|
+
and tgt.attr != "daemon"
|
|
134
|
+
and isinstance(n.value, ast.Name)
|
|
135
|
+
and n.value.id == name
|
|
136
|
+
):
|
|
137
|
+
# Handed off as any object's attribute - the
|
|
138
|
+
# receiver can cancel it later.
|
|
139
|
+
escapes = True
|
|
140
|
+
if (
|
|
141
|
+
isinstance(tgt, ast.Attribute)
|
|
142
|
+
and isinstance(tgt.value, ast.Name)
|
|
143
|
+
and tgt.value.id == name
|
|
144
|
+
and tgt.attr == "daemon"
|
|
145
|
+
and isinstance(n.value, ast.Constant)
|
|
146
|
+
and n.value.value is True
|
|
147
|
+
):
|
|
148
|
+
escapes = True # daemon set post-construction
|
|
149
|
+
elif isinstance(n, ast.Return) and isinstance(n.value, ast.Name) and n.value.id == name:
|
|
150
|
+
escapes = True
|
|
151
|
+
|
|
152
|
+
if not started or cancelled or escapes:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
findings.append(
|
|
156
|
+
Finding(
|
|
157
|
+
path=path,
|
|
158
|
+
line=node.lineno,
|
|
159
|
+
col=node.col_offset,
|
|
160
|
+
code=self.code,
|
|
161
|
+
message=(
|
|
162
|
+
f"`{name} = threading.Timer(...)` in `{fn.name}` is started but never "
|
|
163
|
+
f"cancelled or handed off; nothing can stop it from firing later. Call "
|
|
164
|
+
f"`{name}.cancel()` when it's no longer needed, return/store it so the "
|
|
165
|
+
f"caller can, or pass `daemon=True` if that's intentional."
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
return findings
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""CH025 - ``is``/``is not`` comparing against a str/bytes/int/float literal.
|
|
2
|
+
|
|
3
|
+
`is` checks object *identity*, not equality - `x is 5` only happens to
|
|
4
|
+
work because CPython caches small integers (-5 to 256) and sometimes
|
|
5
|
+
interns short string literals, neither of which is a language guarantee.
|
|
6
|
+
`x is 1000` can be `False` even when `x == 1000` is `True`, and whether a
|
|
7
|
+
given string literal gets interned depends on the compiler and the
|
|
8
|
+
string's exact contents. Code that "works" this way is one PyPy run, one
|
|
9
|
+
larger number, or one differently-compiled string away from silently
|
|
10
|
+
returning the wrong answer - this is pyflakes' own F632, included here
|
|
11
|
+
because a bug this easy to introduce by typo (`is` for `==`) and this
|
|
12
|
+
quiet when it happens to pass by accident earns a place in a tool whose
|
|
13
|
+
whole premise is "subtle correctness bugs," not just style.
|
|
14
|
+
|
|
15
|
+
`is None` / `is True` / `is False` / `is ...` are the actual idiomatic
|
|
16
|
+
uses of `is` and are never flagged.
|
|
17
|
+
|
|
18
|
+
A chained comparison (`a in b is not None`) has every operand and every
|
|
19
|
+
op in one `ast.Compare` node, but they aren't a free-for-all - each op
|
|
20
|
+
only applies to the two operands adjacent to it. `"usage" in response_obj
|
|
21
|
+
is not None` is `("usage" in response_obj) and (response_obj is not
|
|
22
|
+
None)`: the string literal is the left side of `in`, not of `is not`,
|
|
23
|
+
which is actually comparing `response_obj` against the *allowed*
|
|
24
|
+
singleton `None`. Real false positive found in litellm: checking "is
|
|
25
|
+
there a literal anywhere in the chain" and "is there an Is/IsNot op
|
|
26
|
+
anywhere in the chain" independently, without pairing each op with its
|
|
27
|
+
own adjacent operands, matched this for the wrong reason. Fixed by
|
|
28
|
+
walking the chain as adjacent (left, op, right) triples.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import ast
|
|
34
|
+
|
|
35
|
+
from codehound.core import Check, Finding
|
|
36
|
+
|
|
37
|
+
_ALLOWED_SINGLETONS = (None, True, False, Ellipsis)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_flagged_literal(node: ast.expr) -> bool:
|
|
41
|
+
if not isinstance(node, ast.Constant):
|
|
42
|
+
return False
|
|
43
|
+
value = node.value
|
|
44
|
+
if any(value is s for s in _ALLOWED_SINGLETONS):
|
|
45
|
+
return False
|
|
46
|
+
return isinstance(value, (str, bytes, int, float, complex))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class IsLiteralComparison(Check):
|
|
50
|
+
code = "CH025"
|
|
51
|
+
name = "is-literal-comparison"
|
|
52
|
+
description = "`is`/`is not` compares identity, not equality; use ==/!= for str/bytes/int/float literals."
|
|
53
|
+
|
|
54
|
+
def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
|
|
55
|
+
findings: list[Finding] = []
|
|
56
|
+
for node in ast.walk(tree):
|
|
57
|
+
if not isinstance(node, ast.Compare):
|
|
58
|
+
continue
|
|
59
|
+
operands = [node.left, *node.comparators]
|
|
60
|
+
for left, op, right in zip(operands, node.ops, operands[1:]):
|
|
61
|
+
if not isinstance(op, (ast.Is, ast.IsNot)):
|
|
62
|
+
continue
|
|
63
|
+
if not (_is_flagged_literal(left) or _is_flagged_literal(right)):
|
|
64
|
+
continue
|
|
65
|
+
op_text = "is not" if isinstance(op, ast.IsNot) else "is"
|
|
66
|
+
findings.append(
|
|
67
|
+
Finding(
|
|
68
|
+
path=path,
|
|
69
|
+
line=node.lineno,
|
|
70
|
+
col=node.col_offset,
|
|
71
|
+
code=self.code,
|
|
72
|
+
message=(
|
|
73
|
+
f"`{op_text}` compares identity, not equality - comparing against a "
|
|
74
|
+
f"literal here relies on CPython implementation details (small-int "
|
|
75
|
+
f"caching, string interning) that aren't guaranteed; use "
|
|
76
|
+
f"`{'!=' if op_text == 'is not' else '=='}` instead."
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
return findings
|