codehound 1.2.0__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {codehound-1.2.0 → codehound-1.3.0}/PKG-INFO +38 -3
  2. {codehound-1.2.0 → codehound-1.3.0}/README.md +37 -2
  3. {codehound-1.2.0 → codehound-1.3.0}/src/codehound/__init__.py +1 -1
  4. {codehound-1.2.0 → codehound-1.3.0}/src/codehound/cli.py +20 -16
  5. codehound-1.3.0/src/codehound/sarif.py +67 -0
  6. codehound-1.3.0/src/codehound/terminal.py +57 -0
  7. codehound-1.3.0/tests/test_output_formats.py +74 -0
  8. {codehound-1.2.0 → codehound-1.3.0}/.gitignore +0 -0
  9. {codehound-1.2.0 → codehound-1.3.0}/LICENSE +0 -0
  10. {codehound-1.2.0 → codehound-1.3.0}/pyproject.toml +0 -0
  11. {codehound-1.2.0 → codehound-1.3.0}/src/codehound/checks/__init__.py +0 -0
  12. {codehound-1.2.0 → codehound-1.3.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
  13. {codehound-1.2.0 → codehound-1.3.0}/src/codehound/checks/blocking_async.py +0 -0
  14. {codehound-1.2.0 → codehound-1.3.0}/src/codehound/checks/datetime_utcnow.py +0 -0
  15. {codehound-1.2.0 → codehound-1.3.0}/src/codehound/checks/floating_task.py +0 -0
  16. {codehound-1.2.0 → codehound-1.3.0}/src/codehound/checks/floating_thread.py +0 -0
  17. {codehound-1.2.0 → codehound-1.3.0}/src/codehound/checks/get_event_loop.py +0 -0
  18. {codehound-1.2.0 → codehound-1.3.0}/src/codehound/checks/loop_closure_capture.py +0 -0
  19. {codehound-1.2.0 → codehound-1.3.0}/src/codehound/checks/mutable_defaults.py +0 -0
  20. {codehound-1.2.0 → codehound-1.3.0}/src/codehound/checks/resource_leak.py +0 -0
  21. {codehound-1.2.0 → codehound-1.3.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
  22. {codehound-1.2.0 → codehound-1.3.0}/src/codehound/core.py +0 -0
  23. {codehound-1.2.0 → codehound-1.3.0}/tests/test_checks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codehound
3
- Version: 1.2.0
3
+ Version: 1.3.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
@@ -91,6 +91,9 @@ PYTHONPATH=src python -m codehound.cli scan path/to/project
91
91
  # scan a project (skips tests/, docs/, examples/, vendored code by default)
92
92
  codehound scan path/to/project
93
93
 
94
+ # scan multiple files/directories in one invocation (what pre-commit does)
95
+ codehound scan file1.py file2.py src/
96
+
94
97
  # only run specific checks
95
98
  codehound scan path/to/project --select CH001,CH006
96
99
 
@@ -98,6 +101,9 @@ codehound scan path/to/project --select CH001,CH006
98
101
  codehound scan path/to/project --format json
99
102
  codehound scan path/to/project --format csv
100
103
 
104
+ # GitHub Code Scanning (Security tab) can ingest this directly
105
+ codehound scan path/to/project --format sarif > results.sarif
106
+
101
107
  # list every available check
102
108
  codehound list
103
109
  ```
@@ -108,6 +114,29 @@ codehound list
108
114
  - run: codehound scan src # fails the build on a regression
109
115
  ```
110
116
 
117
+ ### GitHub Action
118
+
119
+ ```yaml
120
+ - uses: kratos0718/codehound@v1.2.0
121
+ with:
122
+ path: src
123
+ # select: CH001,CH006 # optional, defaults to all checks
124
+ # fail-on-findings: "false" # optional, report without failing the build
125
+ # upload-sarif: "false" # optional, skip the Code Scanning upload
126
+ ```
127
+
128
+ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in addition to failing the step (unless `fail-on-findings: "false"`).
129
+
130
+ ### pre-commit
131
+
132
+ ```yaml
133
+ repos:
134
+ - repo: https://github.com/kratos0718/codehound
135
+ rev: v1.2.0
136
+ hooks:
137
+ - id: codehound
138
+ ```
139
+
111
140
  ---
112
141
 
113
142
  ## The checks
@@ -189,7 +218,9 @@ codehound/
189
218
  ├── core.py # file discovery, AST parsing, the Finding/Check contract,
190
219
  │ # and a child→parent map so checks can ask "what's my
191
220
  │ # enclosing function / am I inside a `with`?"
192
- ├── cli.py # `scan` / `list`, text|json|csv output, CI-friendly exit codes
221
+ ├── cli.py # `scan` / `list`, text|json|csv|sarif output, CI-friendly exit codes
222
+ ├── sarif.py # SARIF 2.1.0 output for GitHub Code Scanning
223
+ ├── terminal.py # colored text output (auto-disabled for non-TTY / NO_COLOR)
193
224
  └── checks/ # one small, independently-tested class per rule
194
225
  ├── blocking_async.py (CH001)
195
226
  ├── mutable_defaults.py (CH002)
@@ -227,10 +258,14 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
227
258
  - [x] `asyncio.run()` inside a running loop — CH008
228
259
  - [x] Non-daemon thread started without a join — CH009 (the thread analog of CH006)
229
260
  - [x] Loop-variable closure capture in lambdas — CH010
261
+ - [x] Pre-commit hook — `.pre-commit-hooks.yaml`
262
+ - [x] GitHub Action — `action.yml`, uploads SARIF to Code Scanning
263
+ - [x] SARIF output — `--format sarif`
264
+ - [x] Colored terminal output (auto-disabled for non-TTY / `NO_COLOR`)
265
+ - [x] Multi-path `scan` invocation (what the pre-commit hook needs)
230
266
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
231
267
  - [ ] Sync HTTP clients constructed inside async request handlers
232
268
  - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
233
- - [ ] Pre-commit hook
234
269
 
235
270
  ---
236
271
 
@@ -71,6 +71,9 @@ PYTHONPATH=src python -m codehound.cli scan path/to/project
71
71
  # scan a project (skips tests/, docs/, examples/, vendored code by default)
72
72
  codehound scan path/to/project
73
73
 
74
+ # scan multiple files/directories in one invocation (what pre-commit does)
75
+ codehound scan file1.py file2.py src/
76
+
74
77
  # only run specific checks
75
78
  codehound scan path/to/project --select CH001,CH006
76
79
 
@@ -78,6 +81,9 @@ codehound scan path/to/project --select CH001,CH006
78
81
  codehound scan path/to/project --format json
79
82
  codehound scan path/to/project --format csv
80
83
 
84
+ # GitHub Code Scanning (Security tab) can ingest this directly
85
+ codehound scan path/to/project --format sarif > results.sarif
86
+
81
87
  # list every available check
82
88
  codehound list
83
89
  ```
@@ -88,6 +94,29 @@ codehound list
88
94
  - run: codehound scan src # fails the build on a regression
89
95
  ```
90
96
 
97
+ ### GitHub Action
98
+
99
+ ```yaml
100
+ - uses: kratos0718/codehound@v1.2.0
101
+ with:
102
+ path: src
103
+ # select: CH001,CH006 # optional, defaults to all checks
104
+ # fail-on-findings: "false" # optional, report without failing the build
105
+ # upload-sarif: "false" # optional, skip the Code Scanning upload
106
+ ```
107
+
108
+ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in addition to failing the step (unless `fail-on-findings: "false"`).
109
+
110
+ ### pre-commit
111
+
112
+ ```yaml
113
+ repos:
114
+ - repo: https://github.com/kratos0718/codehound
115
+ rev: v1.2.0
116
+ hooks:
117
+ - id: codehound
118
+ ```
119
+
91
120
  ---
92
121
 
93
122
  ## The checks
@@ -169,7 +198,9 @@ codehound/
169
198
  ├── core.py # file discovery, AST parsing, the Finding/Check contract,
170
199
  │ # and a child→parent map so checks can ask "what's my
171
200
  │ # enclosing function / am I inside a `with`?"
172
- ├── cli.py # `scan` / `list`, text|json|csv output, CI-friendly exit codes
201
+ ├── cli.py # `scan` / `list`, text|json|csv|sarif output, CI-friendly exit codes
202
+ ├── sarif.py # SARIF 2.1.0 output for GitHub Code Scanning
203
+ ├── terminal.py # colored text output (auto-disabled for non-TTY / NO_COLOR)
173
204
  └── checks/ # one small, independently-tested class per rule
174
205
  ├── blocking_async.py (CH001)
175
206
  ├── mutable_defaults.py (CH002)
@@ -207,10 +238,14 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
207
238
  - [x] `asyncio.run()` inside a running loop — CH008
208
239
  - [x] Non-daemon thread started without a join — CH009 (the thread analog of CH006)
209
240
  - [x] Loop-variable closure capture in lambdas — CH010
241
+ - [x] Pre-commit hook — `.pre-commit-hooks.yaml`
242
+ - [x] GitHub Action — `action.yml`, uploads SARIF to Code Scanning
243
+ - [x] SARIF output — `--format sarif`
244
+ - [x] Colored terminal output (auto-disabled for non-TTY / `NO_COLOR`)
245
+ - [x] Multi-path `scan` invocation (what the pre-commit hook needs)
210
246
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
211
247
  - [ ] Sync HTTP clients constructed inside async request handlers
212
248
  - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
213
- - [ ] Pre-commit hook
214
249
 
215
250
  ---
216
251
 
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
  from codehound.checks import ALL_CHECKS, get_checks
12
12
  from codehound.core import Check, Finding, scan_file, scan_path
13
13
 
14
- __version__ = "1.2.0"
14
+ __version__ = "1.3.0"
15
15
 
16
16
  __all__ = [
17
17
  "ALL_CHECKS",
@@ -9,6 +9,8 @@ import sys
9
9
  from codehound import __version__
10
10
  from codehound.checks import ALL_CHECKS, get_checks
11
11
  from codehound.core import DEFAULT_SKIP_DIRS, scan_path
12
+ from codehound.sarif import to_sarif
13
+ from codehound.terminal import format_findings_text, format_summary
12
14
 
13
15
 
14
16
  def _cmd_scan(args: argparse.Namespace) -> int:
@@ -21,7 +23,10 @@ def _cmd_scan(args: argparse.Namespace) -> int:
21
23
  skip = set(DEFAULT_SKIP_DIRS)
22
24
  if args.include_tests:
23
25
  skip -= {"tests", "test", "testing"}
24
- findings = scan_path(args.path, checks, skip_dirs=frozenset(skip))
26
+ findings = []
27
+ for path in args.paths:
28
+ findings.extend(scan_path(path, checks, skip_dirs=frozenset(skip)))
29
+ findings.sort(key=lambda f: (f.path, f.line, f.col, f.code))
25
30
 
26
31
  if args.format == "json":
27
32
  print(json.dumps([f.as_dict() for f in findings], indent=2))
@@ -30,18 +35,12 @@ def _cmd_scan(args: argparse.Namespace) -> int:
30
35
  for f in findings:
31
36
  msg = f.message.replace('"', "'")
32
37
  print(f'{f.path},{f.line},{f.col},{f.code},"{msg}"')
38
+ elif args.format == "sarif":
39
+ print(json.dumps(to_sarif(findings, ALL_CHECKS), indent=2))
33
40
  else: # text
34
- for f in findings:
35
- print(f.as_text())
36
- counts: dict[str, int] = {}
37
- for f in findings:
38
- counts[f.code] = counts.get(f.code, 0) + 1
39
- summary = ", ".join(f"{k}: {v}" for k, v in sorted(counts.items()))
40
- print(
41
- f"\nFound {len(findings)} issue(s)"
42
- + (f" ({summary})" if summary else ""),
43
- file=sys.stderr,
44
- )
41
+ for line in format_findings_text(findings):
42
+ print(line)
43
+ print(f"\n{format_summary(findings)}", file=sys.stderr)
45
44
 
46
45
  if findings and not args.exit_zero:
47
46
  return 1
@@ -62,17 +61,22 @@ def build_parser() -> argparse.ArgumentParser:
62
61
  parser.add_argument("--version", action="version", version=f"codehound {__version__}")
63
62
  sub = parser.add_subparsers(dest="command", required=True)
64
63
 
65
- scan = sub.add_parser("scan", help="scan a file or directory for issues")
66
- scan.add_argument("path", help="file or directory to scan")
64
+ scan = sub.add_parser("scan", help="scan one or more files/directories for issues")
65
+ scan.add_argument(
66
+ "paths",
67
+ nargs="+",
68
+ metavar="path",
69
+ help="file(s) or director(y/ies) to scan (accepts multiple, for pre-commit)",
70
+ )
67
71
  scan.add_argument(
68
72
  "--select",
69
73
  help="comma-separated check codes/names to run (default: all), e.g. CH001,CH006",
70
74
  )
71
75
  scan.add_argument(
72
76
  "--format",
73
- choices=["text", "json", "csv"],
77
+ choices=["text", "json", "csv", "sarif"],
74
78
  default="text",
75
- help="output format (default: text)",
79
+ help="output format (default: text; sarif for GitHub Code Scanning)",
76
80
  )
77
81
  scan.add_argument(
78
82
  "--include-tests",
@@ -0,0 +1,67 @@
1
+ """SARIF 2.1.0 output, so a `codehound` run can feed GitHub's Code Scanning
2
+ tab directly (``github/codeql-action/upload-sarif``) instead of only being
3
+ readable as CI log text.
4
+
5
+ Deliberately minimal - just the fields GitHub's ingester actually needs:
6
+ one ``tool.driver`` with a ``rules`` array (so finding codes get a name and
7
+ description in the UI instead of a bare code), and one ``result`` per
8
+ finding with a single physical location. No fingerprinting, no nested
9
+ regions, no multi-run merging - those are real SARIF features this
10
+ doesn't need yet.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from codehound import __version__
16
+ from codehound.core import Check, Finding
17
+
18
+ SARIF_SCHEMA = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
19
+
20
+
21
+ def _rule(check_cls: type[Check]) -> dict:
22
+ return {
23
+ "id": check_cls.code,
24
+ "name": check_cls.name,
25
+ "shortDescription": {"text": check_cls.description},
26
+ "helpUri": "https://github.com/kratos0718/codehound#the-checks",
27
+ "properties": {"tags": ["correctness", "codehound"]},
28
+ }
29
+
30
+
31
+ def _result(finding: Finding) -> dict:
32
+ return {
33
+ "ruleId": finding.code,
34
+ "level": "error",
35
+ "message": {"text": finding.message},
36
+ "locations": [
37
+ {
38
+ "physicalLocation": {
39
+ "artifactLocation": {"uri": finding.path.replace("\\", "/")},
40
+ "region": {
41
+ "startLine": max(finding.line, 1),
42
+ "startColumn": max(finding.col + 1, 1),
43
+ },
44
+ }
45
+ }
46
+ ],
47
+ }
48
+
49
+
50
+ def to_sarif(findings: list[Finding], all_checks: list[type[Check]]) -> dict:
51
+ return {
52
+ "$schema": SARIF_SCHEMA,
53
+ "version": "2.1.0",
54
+ "runs": [
55
+ {
56
+ "tool": {
57
+ "driver": {
58
+ "name": "codehound",
59
+ "informationUri": "https://github.com/kratos0718/codehound",
60
+ "version": __version__,
61
+ "rules": [_rule(c) for c in all_checks],
62
+ }
63
+ },
64
+ "results": [_result(f) for f in findings],
65
+ }
66
+ ],
67
+ }
@@ -0,0 +1,57 @@
1
+ """Colored text output for a terminal, plain text everywhere else.
2
+
3
+ Color only when stdout is actually a terminal, and never when ``NO_COLOR``
4
+ is set (https://no-color.org) or ``TERM=dumb`` - the same convention ruff,
5
+ eslint, and most modern CLI tools follow, so piping to a file or into `less`
6
+ never ends up with raw escape codes in it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+
14
+ _RED = "\033[31m"
15
+ _YELLOW = "\033[33m"
16
+ _CYAN = "\033[36m"
17
+ _DIM = "\033[2m"
18
+ _BOLD = "\033[1m"
19
+ _RESET = "\033[0m"
20
+
21
+
22
+ def _color_enabled(stream=None) -> bool:
23
+ stream = stream or sys.stdout
24
+ if os.environ.get("NO_COLOR") is not None:
25
+ return False
26
+ if os.environ.get("TERM") == "dumb":
27
+ return False
28
+ return hasattr(stream, "isatty") and stream.isatty()
29
+
30
+
31
+ def format_finding_text(finding, color: bool) -> str:
32
+ if not color:
33
+ return finding.as_text()
34
+ return (
35
+ f"{_CYAN}{finding.path}{_RESET}:{_DIM}{finding.line}:{finding.col}{_RESET}: "
36
+ f"{_RED}{_BOLD}{finding.code}{_RESET} {finding.message}"
37
+ )
38
+
39
+
40
+ def format_findings_text(findings, stream=None) -> list[str]:
41
+ color = _color_enabled(stream)
42
+ return [format_finding_text(f, color) for f in findings]
43
+
44
+
45
+ def format_summary(findings, stream=None) -> str:
46
+ color = _color_enabled(stream)
47
+ counts: dict[str, int] = {}
48
+ for f in findings:
49
+ counts[f.code] = counts.get(f.code, 0) + 1
50
+ summary = ", ".join(f"{k}: {v}" for k, v in sorted(counts.items()))
51
+ count_str = str(len(findings))
52
+ if color and findings:
53
+ count_str = f"{_YELLOW}{_BOLD}{count_str}{_RESET}"
54
+ line = f"Found {count_str} issue(s)"
55
+ if summary:
56
+ line += f" ({summary})"
57
+ return line
@@ -0,0 +1,74 @@
1
+ """Tests for the non-default output formats: SARIF and colored text.
2
+
3
+ Scan results themselves are covered by test_checks.py; these tests only
4
+ check that each format serializes a Finding correctly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from codehound.checks import ALL_CHECKS
10
+ from codehound.core import Finding
11
+ from codehound.sarif import to_sarif
12
+ from codehound.terminal import format_finding_text, format_summary
13
+
14
+
15
+ def _sample_finding() -> Finding:
16
+ return Finding(path="pkg/mod.py", line=10, col=4, code="CH002", message="test message")
17
+
18
+
19
+ def test_sarif_has_required_top_level_shape():
20
+ doc = to_sarif([_sample_finding()], ALL_CHECKS)
21
+ assert doc["version"] == "2.1.0"
22
+ assert "$schema" in doc
23
+ assert len(doc["runs"]) == 1
24
+ run = doc["runs"][0]
25
+ assert run["tool"]["driver"]["name"] == "codehound"
26
+
27
+
28
+ def test_sarif_includes_a_rule_entry_per_check():
29
+ doc = to_sarif([], ALL_CHECKS)
30
+ rule_ids = {r["id"] for r in doc["runs"][0]["tool"]["driver"]["rules"]}
31
+ assert rule_ids == {c.code for c in ALL_CHECKS}
32
+
33
+
34
+ def test_sarif_result_location_and_1_indexed_column():
35
+ doc = to_sarif([_sample_finding()], ALL_CHECKS)
36
+ result = doc["runs"][0]["results"][0]
37
+ assert result["ruleId"] == "CH002"
38
+ loc = result["locations"][0]["physicalLocation"]
39
+ assert loc["artifactLocation"]["uri"] == "pkg/mod.py"
40
+ # Finding.col is a 0-indexed ast col_offset; SARIF columns are 1-indexed.
41
+ assert loc["region"]["startLine"] == 10
42
+ assert loc["region"]["startColumn"] == 5
43
+
44
+
45
+ def test_sarif_with_no_findings_has_empty_results():
46
+ doc = to_sarif([], ALL_CHECKS)
47
+ assert doc["runs"][0]["results"] == []
48
+
49
+
50
+ def test_colored_text_contains_plain_text_content():
51
+ finding = _sample_finding()
52
+ colored = format_finding_text(finding, color=True)
53
+ plain = format_finding_text(finding, color=False)
54
+ assert plain == finding.as_text()
55
+ assert "\033[" in colored
56
+ assert "CH002" in colored
57
+ assert "test message" in colored
58
+ assert "pkg/mod.py" in colored
59
+
60
+
61
+ def test_summary_reports_correct_counts():
62
+ findings = [
63
+ Finding(path="a.py", line=1, col=0, code="CH001", message="x"),
64
+ Finding(path="b.py", line=2, col=0, code="CH001", message="y"),
65
+ Finding(path="c.py", line=3, col=0, code="CH002", message="z"),
66
+ ]
67
+ summary = format_summary(findings)
68
+ assert "Found 3 issue(s)" in summary
69
+ assert "CH001: 2" in summary
70
+ assert "CH002: 1" in summary
71
+
72
+
73
+ def test_summary_with_no_findings():
74
+ assert format_summary([]) == "Found 0 issue(s)"
File without changes
File without changes
File without changes