codehound 1.4.1__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.1 → codehound-1.4.2}/PKG-INFO +12 -2
  2. {codehound-1.4.1 → codehound-1.4.2}/README.md +11 -1
  3. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/__init__.py +1 -1
  4. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/lru_cache_on_method.py +71 -0
  5. {codehound-1.4.1 → codehound-1.4.2}/tests/test_checks.py +86 -0
  6. {codehound-1.4.1 → codehound-1.4.2}/.gitignore +0 -0
  7. {codehound-1.4.1 → codehound-1.4.2}/LICENSE +0 -0
  8. {codehound-1.4.1 → codehound-1.4.2}/pyproject.toml +0 -0
  9. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/__init__.py +0 -0
  10. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/async_property.py +0 -0
  11. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
  12. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/bare_except.py +0 -0
  13. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/blocking_async.py +0 -0
  14. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/collections_abc_import.py +0 -0
  15. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/datetime_utcnow.py +0 -0
  16. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/discarded_future.py +0 -0
  17. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/floating_process.py +0 -0
  18. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/floating_task.py +0 -0
  19. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/floating_thread.py +0 -0
  20. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/get_event_loop.py +0 -0
  21. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/loop_closure_capture.py +0 -0
  22. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/mutable_defaults.py +0 -0
  23. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
  24. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/removed_getargspec.py +0 -0
  25. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/resource_leak.py +0 -0
  26. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/unawaited_coroutine.py +0 -0
  27. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/unclosed_socket.py +0 -0
  28. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/checks/unprotected_lock.py +0 -0
  29. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/cli.py +0 -0
  30. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/core.py +0 -0
  31. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/sarif.py +0 -0
  32. {codehound-1.4.1 → codehound-1.4.2}/src/codehound/terminal.py +0 -0
  33. {codehound-1.4.1 → 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.1
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.1
135
+ rev: v1.4.2
136
136
  hooks:
137
137
  - id: codehound
138
138
  ```
@@ -225,6 +225,16 @@ have a regression test verified to fail pre-fix and pass post-fix.
225
225
  PRs: [optuna/optuna#6859](https://github.com/optuna/optuna/pull/6859),
226
226
  [run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089).
227
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
+
228
238
  **CH016 found and fixed its own false positive the day it shipped.** The
229
239
  first real-corpus scan of `unclosed-socket` turned up three hits in
230
240
  vllm's distributed process-group rendezvous code - all three sockets were
@@ -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.1
115
+ rev: v1.4.2
116
116
  hooks:
117
117
  - id: codehound
118
118
  ```
@@ -205,6 +205,16 @@ have a regression test verified to fail pre-fix and pass post-fix.
205
205
  PRs: [optuna/optuna#6859](https://github.com/optuna/optuna/pull/6859),
206
206
  [run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089).
207
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
+
208
218
  **CH016 found and fixed its own false positive the day it shipped.** The
209
219
  first real-corpus scan of `unclosed-socket` turned up three hits in
210
220
  vllm's distributed process-group rendezvous code - all three sockets were
@@ -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.1"
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:
@@ -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
 
File without changes
File without changes
File without changes