codehound 1.4.0__tar.gz → 1.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {codehound-1.4.0 → codehound-1.4.2}/PKG-INFO +46 -8
  2. {codehound-1.4.0 → codehound-1.4.2}/README.md +45 -7
  3. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/__init__.py +1 -1
  4. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/lru_cache_on_method.py +71 -0
  5. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/unclosed_socket.py +57 -9
  6. {codehound-1.4.0 → codehound-1.4.2}/tests/test_checks.py +152 -0
  7. {codehound-1.4.0 → codehound-1.4.2}/.gitignore +0 -0
  8. {codehound-1.4.0 → codehound-1.4.2}/LICENSE +0 -0
  9. {codehound-1.4.0 → codehound-1.4.2}/pyproject.toml +0 -0
  10. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/__init__.py +0 -0
  11. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/async_property.py +0 -0
  12. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
  13. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/bare_except.py +0 -0
  14. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/blocking_async.py +0 -0
  15. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/collections_abc_import.py +0 -0
  16. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/datetime_utcnow.py +0 -0
  17. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/discarded_future.py +0 -0
  18. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/floating_process.py +0 -0
  19. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/floating_task.py +0 -0
  20. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/floating_thread.py +0 -0
  21. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/get_event_loop.py +0 -0
  22. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/loop_closure_capture.py +0 -0
  23. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/mutable_defaults.py +0 -0
  24. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
  25. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/removed_getargspec.py +0 -0
  26. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/resource_leak.py +0 -0
  27. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/unawaited_coroutine.py +0 -0
  28. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/checks/unprotected_lock.py +0 -0
  29. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/cli.py +0 -0
  30. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/core.py +0 -0
  31. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/sarif.py +0 -0
  32. {codehound-1.4.0 → codehound-1.4.2}/src/codehound/terminal.py +0 -0
  33. {codehound-1.4.0 → codehound-1.4.2}/tests/test_output_formats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codehound
3
- Version: 1.4.0
3
+ Version: 1.4.2
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
@@ -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.4.0
135
+ rev: v1.4.2
136
136
  hooks:
137
137
  - id: codehound
138
138
  ```
@@ -153,7 +153,7 @@ repos:
153
153
  | **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) |
154
154
  | **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 |
155
155
  | **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 |
156
- | **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. | hardening rulereal hits across litellm, vllm, accelerate, marimo, dspy |
156
+ | **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 |
157
157
  | **CH012** | `floating-process` | A non-daemon `multiprocessing.Process` that's `.start()`ed but never `.join()`ed — the process analog of CH009. | hardening rule |
158
158
  | **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 |
159
159
  | **CH014** | `unprotected-lock-acquire` | `lock.acquire()` outside a `with`, whose matching `.release()` isn't inside a `finally:` — an exception between acquire and release deadlocks every future caller of that lock. | hardening rule — real hits in vllm, accelerate, torchtune |
@@ -167,11 +167,12 @@ repos:
167
167
  `codehound list` prints this from the source of truth.
168
168
 
169
169
  CH007-CH020 don't have found-and-merged bugs behind all of them the way
170
- CH001-CH006 and CH010 do - most are hardening rules for well-known Python
170
+ CH001-CH006 do - most are hardening rules for well-known Python
171
171
  correctness gotchas rather than something this project personally
172
- tracked down first. CH010 is the exception: it found a genuine, serious
173
- bug on its own, in HuggingFace's `accelerate` - see below. Building
174
- CH007-CH010 surfaced real false positives, each one fixed before shipping:
172
+ tracked down first. CH010 and CH011 are the exceptions: both found
173
+ genuine bugs on their own, in HuggingFace's `accelerate`, optuna, and
174
+ llama_index - see below. Building CH007-CH010 surfaced real false
175
+ positives, each one fixed before shipping:
175
176
 
176
177
  - **CH007** (agno): a bare `self.foo()` call matched against an unrelated
177
178
  same-named `async def foo` on a *different* class (agno's own
@@ -209,6 +210,43 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
209
210
  match same-file names by design, and most real cases of either are
210
211
  plausibly cross-module.
211
212
 
213
+ **The optuna and llama_index finds (CH011):** both are `@lru_cache(maxsize=
214
+ None)` decorating an instance method - a strong reference to `self`
215
+ retained forever. In optuna, `_FanovaTree`'s node-lookup methods leak
216
+ every tree built for a `get_param_importances()` call (one per
217
+ random-forest estimator). In llama_index, `VectaraIndex._get_corpus_key`
218
+ leaks the index itself - and since `VectaraIndex.__del__` exists
219
+ specifically to close the index's `requests.Session` on garbage
220
+ collection, the leak silently disables that cleanup too, so an HTTP
221
+ session leaks along with every index. Both fixed the same way: move the
222
+ cache from a class-level decorator to a per-instance one built in
223
+ `__init__`, so it's freed with the instance instead of outliving it. Both
224
+ have a regression test verified to fail pre-fix and pass post-fix.
225
+ PRs: [optuna/optuna#6859](https://github.com/optuna/optuna/pull/6859),
226
+ [run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089).
227
+
228
+ **A third CH011 shape needed a guard instead of a PR:** dspy's `Image` (a
229
+ pydantic model) caches `format()` the same way, but `Image` is frozen
230
+ (`model_config = ConfigDict(frozen=True)`), which makes it hashable and
231
+ equal *by field value*, not identity. Checked directly rather than
232
+ assumed: two separately constructed instances with equal fields hash
233
+ equal, and the second one's call is served from the first's cache entry
234
+ without ever being inserted itself — bounded, value-based memoization,
235
+ not a leak. CH011 now recognizes a frozen `@dataclass` or a frozen
236
+ pydantic model and skips it.
237
+
238
+ **CH016 found and fixed its own false positive the day it shipped.** The
239
+ first real-corpus scan of `unclosed-socket` turned up three hits in
240
+ vllm's distributed process-group rendezvous code - all three sockets were
241
+ actually handed off correctly (returned inside a tuple, collected into a
242
+ list that's itself returned, passed as an argument into a function that
243
+ takes ownership), just not in a shape CH005 (the check this one was
244
+ modeled on) ever needed to recognize, since files aren't handed off this
245
+ way nearly as often as rendezvous sockets are. Fixed by treating a name
246
+ as escaped when it's returned as part of a tuple/list or passed as an
247
+ argument to any call. A full corpus rescan afterward found zero remaining
248
+ CH016 hits.
249
+
212
250
  **Two checks we built and did not ship.** `exception-chaining` (`except X
213
251
  as e: raise Y(...)` with no `from e`, discarding the real traceback -
214
252
  overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
@@ -269,7 +307,7 @@ codehound/
269
307
 
270
308
  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.
271
309
 
272
- **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. 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. 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."
310
+ **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. 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."
273
311
 
274
312
  ---
275
313
 
@@ -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.4.0
115
+ rev: v1.4.2
116
116
  hooks:
117
117
  - id: codehound
118
118
  ```
@@ -133,7 +133,7 @@ repos:
133
133
  | **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) |
134
134
  | **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 |
135
135
  | **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 |
136
- | **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. | hardening rulereal hits across litellm, vllm, accelerate, marimo, dspy |
136
+ | **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 |
137
137
  | **CH012** | `floating-process` | A non-daemon `multiprocessing.Process` that's `.start()`ed but never `.join()`ed — the process analog of CH009. | hardening rule |
138
138
  | **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 |
139
139
  | **CH014** | `unprotected-lock-acquire` | `lock.acquire()` outside a `with`, whose matching `.release()` isn't inside a `finally:` — an exception between acquire and release deadlocks every future caller of that lock. | hardening rule — real hits in vllm, accelerate, torchtune |
@@ -147,11 +147,12 @@ repos:
147
147
  `codehound list` prints this from the source of truth.
148
148
 
149
149
  CH007-CH020 don't have found-and-merged bugs behind all of them the way
150
- CH001-CH006 and CH010 do - most are hardening rules for well-known Python
150
+ CH001-CH006 do - most are hardening rules for well-known Python
151
151
  correctness gotchas rather than something this project personally
152
- tracked down first. CH010 is the exception: it found a genuine, serious
153
- bug on its own, in HuggingFace's `accelerate` - see below. Building
154
- CH007-CH010 surfaced real false positives, each one fixed before shipping:
152
+ tracked down first. CH010 and CH011 are the exceptions: both found
153
+ genuine bugs on their own, in HuggingFace's `accelerate`, optuna, and
154
+ llama_index - see below. Building CH007-CH010 surfaced real false
155
+ positives, each one fixed before shipping:
155
156
 
156
157
  - **CH007** (agno): a bare `self.foo()` call matched against an unrelated
157
158
  same-named `async def foo` on a *different* class (agno's own
@@ -189,6 +190,43 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
189
190
  match same-file names by design, and most real cases of either are
190
191
  plausibly cross-module.
191
192
 
193
+ **The optuna and llama_index finds (CH011):** both are `@lru_cache(maxsize=
194
+ None)` decorating an instance method - a strong reference to `self`
195
+ retained forever. In optuna, `_FanovaTree`'s node-lookup methods leak
196
+ every tree built for a `get_param_importances()` call (one per
197
+ random-forest estimator). In llama_index, `VectaraIndex._get_corpus_key`
198
+ leaks the index itself - and since `VectaraIndex.__del__` exists
199
+ specifically to close the index's `requests.Session` on garbage
200
+ collection, the leak silently disables that cleanup too, so an HTTP
201
+ session leaks along with every index. Both fixed the same way: move the
202
+ cache from a class-level decorator to a per-instance one built in
203
+ `__init__`, so it's freed with the instance instead of outliving it. Both
204
+ have a regression test verified to fail pre-fix and pass post-fix.
205
+ PRs: [optuna/optuna#6859](https://github.com/optuna/optuna/pull/6859),
206
+ [run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089).
207
+
208
+ **A third CH011 shape needed a guard instead of a PR:** dspy's `Image` (a
209
+ pydantic model) caches `format()` the same way, but `Image` is frozen
210
+ (`model_config = ConfigDict(frozen=True)`), which makes it hashable and
211
+ equal *by field value*, not identity. Checked directly rather than
212
+ assumed: two separately constructed instances with equal fields hash
213
+ equal, and the second one's call is served from the first's cache entry
214
+ without ever being inserted itself — bounded, value-based memoization,
215
+ not a leak. CH011 now recognizes a frozen `@dataclass` or a frozen
216
+ pydantic model and skips it.
217
+
218
+ **CH016 found and fixed its own false positive the day it shipped.** The
219
+ first real-corpus scan of `unclosed-socket` turned up three hits in
220
+ vllm's distributed process-group rendezvous code - all three sockets were
221
+ actually handed off correctly (returned inside a tuple, collected into a
222
+ list that's itself returned, passed as an argument into a function that
223
+ takes ownership), just not in a shape CH005 (the check this one was
224
+ modeled on) ever needed to recognize, since files aren't handed off this
225
+ way nearly as often as rendezvous sockets are. Fixed by treating a name
226
+ as escaped when it's returned as part of a tuple/list or passed as an
227
+ argument to any call. A full corpus rescan afterward found zero remaining
228
+ CH016 hits.
229
+
192
230
  **Two checks we built and did not ship.** `exception-chaining` (`except X
193
231
  as e: raise Y(...)` with no `from e`, discarding the real traceback -
194
232
  overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
@@ -249,7 +287,7 @@ codehound/
249
287
 
250
288
  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.
251
289
 
252
- **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. 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. 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."
290
+ **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. 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."
253
291
 
254
292
  ---
255
293
 
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
  from codehound.checks import ALL_CHECKS, get_checks
13
13
  from codehound.core import Check, Finding, scan_file, scan_path
14
14
 
15
- __version__ = "1.4.0"
15
+ __version__ = "1.4.2"
16
16
 
17
17
  __all__ = [
18
18
  "ALL_CHECKS",
@@ -12,6 +12,19 @@ instance alive), not a style nit.
12
12
  Only fires on a method - a plain module-level function decorated the same
13
13
  way is exactly what ``lru_cache`` is for, and caches correctly with no
14
14
  leak (nothing but the arguments themselves are held).
15
+
16
+ Also doesn't fire on a *frozen* value class (a stdlib ``@dataclass(frozen=
17
+ True)`` or a pydantic model with ``model_config = ConfigDict(frozen=True)``
18
+ / ``class Config: frozen = True``). A frozen class is hashable and equal by
19
+ its field values, not by identity - verified directly: two separately
20
+ constructed instances with the same field values hash equal, and a second,
21
+ distinct instance's call is served straight from the first instance's
22
+ cache entry without ever being inserted itself (dspy's `Image.format()`,
23
+ checked this way, turned out to be exactly this - real, bounded-by-value
24
+ memoization on an immutable type, not a leak). The cache still only ever
25
+ holds as many distinct instances as there are distinct field-value
26
+ combinations seen, capped at `maxsize`, which is the same bound an
27
+ equivalent free function cached by value would have.
15
28
  """
16
29
 
17
30
  from __future__ import annotations
@@ -32,6 +45,62 @@ def _is_cache_decorator(node: ast.expr) -> bool:
32
45
  return False
33
46
 
34
47
 
48
+ def _is_frozen_dataclass(decorators: list[ast.expr]) -> bool:
49
+ for d in decorators:
50
+ if not isinstance(d, ast.Call):
51
+ continue
52
+ target = d.func
53
+ name = target.id if isinstance(target, ast.Name) else getattr(target, "attr", None)
54
+ if name != "dataclass":
55
+ continue
56
+ for kw in d.keywords:
57
+ if kw.arg == "frozen" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
58
+ return True
59
+ return False
60
+
61
+
62
+ def _is_frozen_pydantic_model(cls: ast.ClassDef) -> bool:
63
+ for stmt in cls.body:
64
+ # model_config = ConfigDict(..., frozen=True, ...) or {"frozen": True}
65
+ if (
66
+ isinstance(stmt, ast.Assign)
67
+ and len(stmt.targets) == 1
68
+ and isinstance(stmt.targets[0], ast.Name)
69
+ and stmt.targets[0].id == "model_config"
70
+ ):
71
+ value = stmt.value
72
+ if isinstance(value, ast.Call):
73
+ for kw in value.keywords:
74
+ if kw.arg == "frozen" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
75
+ return True
76
+ elif isinstance(value, ast.Dict):
77
+ for k, v in zip(value.keys, value.values):
78
+ if (
79
+ isinstance(k, ast.Constant)
80
+ and k.value == "frozen"
81
+ and isinstance(v, ast.Constant)
82
+ and v.value is True
83
+ ):
84
+ return True
85
+ # Old-style pydantic v1: `class Config: frozen = True`
86
+ if isinstance(stmt, ast.ClassDef) and stmt.name == "Config":
87
+ for inner in stmt.body:
88
+ if (
89
+ isinstance(inner, ast.Assign)
90
+ and len(inner.targets) == 1
91
+ and isinstance(inner.targets[0], ast.Name)
92
+ and inner.targets[0].id == "frozen"
93
+ and isinstance(inner.value, ast.Constant)
94
+ and inner.value.value is True
95
+ ):
96
+ return True
97
+ return False
98
+
99
+
100
+ def _is_frozen_value_class(cls: ast.ClassDef) -> bool:
101
+ return _is_frozen_dataclass(cls.decorator_list) or _is_frozen_pydantic_model(cls)
102
+
103
+
35
104
  def _is_static_or_classmethod(decorators: list[ast.expr]) -> bool:
36
105
  for d in decorators:
37
106
  target = d.func if isinstance(d, ast.Call) else d
@@ -56,6 +125,8 @@ class LruCacheOnMethod(Check):
56
125
  continue
57
126
  if _is_static_or_classmethod(node.decorator_list):
58
127
  continue
128
+ if _is_frozen_value_class(parent):
129
+ continue
59
130
  if not node.args.args or node.args.args[0].arg not in ("self",):
60
131
  continue
61
132
  for dec in node.decorator_list:
@@ -2,10 +2,22 @@
2
2
 
3
3
  The same shape as CH005's unclosed file handle, for a different resource:
4
4
  `s = socket.socket(...)` outside a `with` block, with no matching
5
- `s.close()` anywhere in the function and no `return`ed ownership hand-off,
6
- leaks the file descriptor exactly like an unclosed file does - sockets are
7
- file descriptors on POSIX systems, and Python's socket module supports
8
- the context-manager protocol specifically so this doesn't happen.
5
+ `s.close()` anywhere in the function and no ownership hand-off, leaks the
6
+ file descriptor exactly like an unclosed file does - sockets are file
7
+ descriptors on POSIX systems, and Python's socket module supports the
8
+ context-manager protocol specifically so this doesn't happen.
9
+
10
+ Unlike a file handle, a socket used for rendezvous/handshake code is
11
+ routinely handed off by passing it into another call rather than by
12
+ `return`ing it bare or closing it locally - real false positives found in
13
+ vllm's distributed process-group setup: `return port, s` (returned inside
14
+ a tuple, not as the bare name), `socks.append(s)` with the list itself
15
+ returned, and `listen_socket=listen_socket` passed straight into a
16
+ `create_tcp_store(...)` call that takes ownership of it. So a socket name
17
+ is also treated as escaped if it's returned as part of a tuple/list, or
18
+ passed as an argument to any call other than a method called *on* the
19
+ socket itself (`s.bind(...)`, `s.close()`, etc., where the socket is the
20
+ receiver, not an argument).
9
21
  """
10
22
 
11
23
  from __future__ import annotations
@@ -50,6 +62,36 @@ def _has_close_call(scope: ast.AST, name: str) -> bool:
50
62
  return False
51
63
 
52
64
 
65
+ def _is_passed_as_argument(scope: ast.AST, name: str) -> bool:
66
+ """Is `name` ever passed into a call as an argument (not as the receiver
67
+ of an attribute call, e.g. `name.bind(...)`)? A socket handed to another
68
+ call - `store_it(s)`, `things.append(s)`, `f(listen_socket=s)` - has its
69
+ ownership transferred there, same as a `return`."""
70
+ for node in ast.walk(scope):
71
+ if not isinstance(node, ast.Call):
72
+ continue
73
+ for arg in node.args:
74
+ if isinstance(arg, ast.Name) and arg.id == name:
75
+ return True
76
+ for kw in node.keywords:
77
+ if isinstance(kw.value, ast.Name) and kw.value.id == name:
78
+ return True
79
+ return False
80
+
81
+
82
+ def _return_targets(value: ast.expr | None) -> list[str]:
83
+ if value is None:
84
+ return []
85
+ if isinstance(value, ast.Name):
86
+ return [value.id]
87
+ if isinstance(value, (ast.Tuple, ast.List)):
88
+ out: list[str] = []
89
+ for elt in value.elts:
90
+ out.extend(_return_targets(elt))
91
+ return out
92
+ return []
93
+
94
+
53
95
  class UnclosedSocket(Check):
54
96
  code = "CH016"
55
97
  name = "unclosed-socket"
@@ -70,12 +112,18 @@ class UnclosedSocket(Check):
70
112
  fn = enclosing_function(node, parents)
71
113
  if fn is None:
72
114
  continue
73
- returns_socket = any(
74
- isinstance(n, ast.Return) and isinstance(n.value, ast.Name) and n.value.id in names
75
- for n in ast.walk(fn)
76
- )
115
+ returned_names = {
116
+ n
117
+ for r in ast.walk(fn)
118
+ if isinstance(r, ast.Return)
119
+ for n in _return_targets(r.value)
120
+ }
77
121
  for name in names:
78
- if returns_socket or _has_close_call(fn, name):
122
+ if (
123
+ name in returned_names
124
+ or _has_close_call(fn, name)
125
+ or _is_passed_as_argument(fn, name)
126
+ ):
79
127
  continue
80
128
  findings.append(
81
129
  Finding(
@@ -517,6 +517,92 @@ def test_ch011_ignores_uncached_instance_method():
517
517
  assert _run(code, ["CH011"]) == []
518
518
 
519
519
 
520
+ def test_ch011_ignores_frozen_dataclass():
521
+ # A frozen dataclass is hashable and equal by field value, not identity -
522
+ # verified empirically: two separately constructed instances with the
523
+ # same field values hash equal, and lru_cache serves the second one
524
+ # straight from the first's cache entry without ever storing it. Bounded
525
+ # by maxsize the same way an equivalent free function cached by value
526
+ # would be - not a leak.
527
+ code = (
528
+ "from dataclasses import dataclass\n"
529
+ "from functools import lru_cache\n"
530
+ "@dataclass(frozen=True)\n"
531
+ "class Point:\n"
532
+ " x: int\n"
533
+ " @lru_cache\n"
534
+ " def scaled(self, factor):\n"
535
+ " return self.x * factor\n"
536
+ )
537
+ assert _run(code, ["CH011"]) == []
538
+
539
+
540
+ def test_ch011_ignores_frozen_pydantic_model():
541
+ # Real false positive found in dspy: Image (model_config =
542
+ # ConfigDict(frozen=True)) caches format() with @lru_cache(maxsize=32) -
543
+ # confirmed via a REPL check that this is value-based memoization, not a
544
+ # per-instance leak.
545
+ code = (
546
+ "import pydantic\n"
547
+ "from functools import lru_cache\n"
548
+ "class Image(pydantic.BaseModel):\n"
549
+ " url: str\n"
550
+ " model_config = pydantic.ConfigDict(frozen=True)\n"
551
+ " @lru_cache(maxsize=32)\n"
552
+ " def format(self):\n"
553
+ " return self.url.upper()\n"
554
+ )
555
+ assert _run(code, ["CH011"]) == []
556
+
557
+
558
+ def test_ch011_ignores_frozen_pydantic_model_v1_style_config():
559
+ code = (
560
+ "import pydantic\n"
561
+ "from functools import lru_cache\n"
562
+ "class Image(pydantic.BaseModel):\n"
563
+ " url: str\n"
564
+ " class Config:\n"
565
+ " frozen = True\n"
566
+ " @lru_cache\n"
567
+ " def format(self):\n"
568
+ " return self.url.upper()\n"
569
+ )
570
+ assert _run(code, ["CH011"]) == []
571
+
572
+
573
+ def test_ch011_still_flags_mutable_dataclass():
574
+ # Guard against the frozen-class check over-suppressing: a plain (not
575
+ # frozen) dataclass is not hashable by value, so this must still flag.
576
+ code = (
577
+ "from dataclasses import dataclass\n"
578
+ "from functools import lru_cache\n"
579
+ "@dataclass\n"
580
+ "class Point:\n"
581
+ " x: int\n"
582
+ " @lru_cache\n"
583
+ " def scaled(self, factor):\n"
584
+ " return self.x * factor\n"
585
+ )
586
+ findings = _run(code, ["CH011"])
587
+ assert len(findings) == 1
588
+ assert findings[0].code == "CH011"
589
+
590
+
591
+ def test_ch011_still_flags_pydantic_model_without_frozen_config():
592
+ code = (
593
+ "import pydantic\n"
594
+ "from functools import lru_cache\n"
595
+ "class Image(pydantic.BaseModel):\n"
596
+ " url: str\n"
597
+ " @lru_cache\n"
598
+ " def format(self):\n"
599
+ " return self.url.upper()\n"
600
+ )
601
+ findings = _run(code, ["CH011"])
602
+ assert len(findings) == 1
603
+ assert findings[0].code == "CH011"
604
+
605
+
520
606
  # --- CH012 floating-process ----------------------------------------------------------
521
607
 
522
608
 
@@ -724,6 +810,72 @@ def test_ch016_ignores_returned_socket():
724
810
  assert _run(code, ["CH016"]) == []
725
811
 
726
812
 
813
+ def test_ch016_ignores_socket_returned_inside_a_tuple():
814
+ # Real false positive found in vllm's distributed process-group setup:
815
+ # `return port, s` hands the socket off to the caller just as much as a
816
+ # bare `return s` does - it's just wrapped in a tuple alongside other data.
817
+ code = (
818
+ "import socket\n"
819
+ "def bind():\n"
820
+ " s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n"
821
+ " s.bind(('localhost', 0))\n"
822
+ " port = s.getsockname()[1]\n"
823
+ " return port, s\n"
824
+ )
825
+ assert _run(code, ["CH016"]) == []
826
+
827
+
828
+ def test_ch016_ignores_socket_appended_to_a_returned_list():
829
+ # Real false positive found in vllm: sockets are collected into a list
830
+ # inside a loop, and the list itself (not any individual socket name) is
831
+ # returned - `socks.append(s)` is the hand-off.
832
+ code = (
833
+ "import socket\n"
834
+ "def bind_group():\n"
835
+ " socks = []\n"
836
+ " for _ in range(3):\n"
837
+ " s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n"
838
+ " s.bind(('localhost', 0))\n"
839
+ " socks.append(s)\n"
840
+ " return socks\n"
841
+ )
842
+ assert _run(code, ["CH016"]) == []
843
+
844
+
845
+ def test_ch016_ignores_socket_passed_as_call_argument():
846
+ # Real false positive found in vllm: a listen socket is built, then
847
+ # handed straight into another function that takes ownership of it
848
+ # (`create_tcp_store(..., listen_socket=listen_socket)`), never returned
849
+ # and never closed in this function because the callee owns it now.
850
+ code = (
851
+ "import socket\n"
852
+ "def setup(host, port):\n"
853
+ " listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n"
854
+ " listen_socket.bind((host, port))\n"
855
+ " listen_socket.listen()\n"
856
+ " store = create_tcp_store(host, port, listen_socket=listen_socket)\n"
857
+ " return store\n"
858
+ )
859
+ assert _run(code, ["CH016"]) == []
860
+
861
+
862
+ def test_ch016_still_flags_socket_used_only_as_a_call_receiver():
863
+ # Guard against the passed-as-argument escape swallowing real bugs:
864
+ # `s.connect(...)`/`s.recv(...)` use `s` as the receiver of the call, not
865
+ # as an argument, so this must still be flagged.
866
+ code = (
867
+ "import socket\n"
868
+ "def f():\n"
869
+ " s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n"
870
+ " s.connect(('localhost', 80))\n"
871
+ " data = s.recv(1024)\n"
872
+ " print(data)\n"
873
+ )
874
+ findings = _run(code, ["CH016"])
875
+ assert len(findings) == 1
876
+ assert findings[0].code == "CH016"
877
+
878
+
727
879
  # --- CH017 collections-abc-import ----------------------------------------------------
728
880
 
729
881
 
File without changes
File without changes
File without changes