codebase-stats 0.0.1__tar.gz → 0.0.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 (34) hide show
  1. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/PKG-INFO +1 -1
  2. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats/__init__.py +6 -2
  3. codebase_stats-0.0.2/codebase_stats/cli.py +381 -0
  4. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats/core.py +19 -19
  5. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats/coverage.py +15 -9
  6. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats/duration.py +17 -10
  7. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats/lowcov.py +9 -8
  8. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats/metrics.py +19 -16
  9. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats/radon.py +22 -6
  10. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats/reporter.py +1 -1
  11. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats/sizes.py +7 -3
  12. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats/tree.py +8 -4
  13. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats/utils.py +5 -6
  14. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats.egg-info/PKG-INFO +1 -1
  15. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats.egg-info/SOURCES.txt +1 -0
  16. codebase_stats-0.0.2/codebase_stats.egg-info/entry_points.txt +2 -0
  17. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/pyproject.toml +2 -3
  18. codebase_stats-0.0.1/codebase_stats.egg-info/entry_points.txt +0 -2
  19. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/README.md +0 -0
  20. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats.egg-info/dependency_links.txt +0 -0
  21. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats.egg-info/requires.txt +0 -0
  22. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/codebase_stats.egg-info/top_level.txt +0 -0
  23. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/setup.cfg +0 -0
  24. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/tests/test_core.py +0 -0
  25. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/tests/test_coverage.py +0 -0
  26. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/tests/test_coverage_gaps.py +0 -0
  27. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/tests/test_duration.py +0 -0
  28. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/tests/test_integration.py +0 -0
  29. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/tests/test_lowcov.py +0 -0
  30. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/tests/test_metrics.py +0 -0
  31. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/tests/test_metrics_mocked.py +0 -0
  32. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/tests/test_sizes.py +0 -0
  33. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/tests/test_tree.py +0 -0
  34. {codebase_stats-0.0.1 → codebase_stats-0.0.2}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebase-stats
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
4
  Summary: Comprehensive codebase analysis library with coverage, metrics, and test duration analysis
5
5
  Author-email: Your Name <your.email@example.com>
6
6
  License: MIT
@@ -45,12 +45,16 @@ from .coverage import (
45
45
  show_pragma_histogram,
46
46
  )
47
47
 
48
- # Code quality metrics
49
- from .metrics import (
48
+ # Radon analysis
49
+ from .radon import (
50
50
  run_radon,
51
51
  run_radon_mi,
52
52
  run_radon_raw,
53
53
  run_radon_hal,
54
+ )
55
+
56
+ # Code quality metrics
57
+ from .metrics import (
54
58
  show_complexity_histogram,
55
59
  show_mi_histogram,
56
60
  show_raw_histogram,
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ coverage_report.py — Coverage inspector + test report analyser.
4
+
5
+ Features:
6
+ 1. Coverage distribution histogram (needs coverage.json)
7
+ 2. Low-coverage file listing (needs coverage.json)
8
+ 3. Test duration histogram (needs --report report.json)
9
+ 4. File size distribution histogram (scanned from disk)
10
+
11
+ Generate the inputs with:
12
+ pip install pytest-json-report
13
+ pytest ... --cov=app --cov-report=json \
14
+ --json-report --json-report-file=report.json
15
+
16
+ --show options (combinable, default: all available):
17
+ coverage coverage distribution histogram + blame
18
+ duration test duration histogram + blame (requires --report)
19
+ sizes file size histogram + blame (requires --fs-root)
20
+ list low-coverage file table
21
+ histograms shorthand for all histograms: coverage duration sizes complexity mi raw hal (no blame, no table)
22
+ complexity cyclomatic complexity histogram + high-CC/low-coverage blame (requires --radon-root)
23
+ mi maintainability index histogram + low-MI blame (requires --radon-root)
24
+ raw comment ratio histogram + under-documented blame (requires --radon-root)
25
+ hal halstead metrics histogram + high-bug-estimate blame (requires --radon-root)
26
+ tree file tree analyzer (counts files and subfolders) (requires --tree-root)
27
+ blame quality blame sections only (combine with histograms or coverage/duration/sizes)
28
+ all everything
29
+
30
+ Usage:
31
+ python cli.py coverage.json
32
+ python cli.py coverage.json --report report.json
33
+ python cli.py coverage.json --show histograms --report report.json --fs-root app
34
+ python cli.py coverage.json --show coverage
35
+ python cli.py coverage.json --report report.json --show duration
36
+ python cli.py coverage.json --show list --threshold 80
37
+ python cli.py coverage.json --report report.json --show coverage duration
38
+ """
39
+
40
+ import argparse
41
+ from concurrent.futures import ThreadPoolExecutor, as_completed
42
+
43
+ from codebase_stats import (
44
+ analyze_tree,
45
+ load_coverage,
46
+ load_report,
47
+ parse_sorts,
48
+ precompute_coverage_stats,
49
+ run_radon,
50
+ run_radon_hal,
51
+ run_radon_mi,
52
+ run_radon_raw,
53
+ scan_pragma_counts,
54
+ show_complexity_histogram,
55
+ show_coverage_histogram,
56
+ show_duration_histogram,
57
+ show_file_size_distribution,
58
+ show_hal_histogram,
59
+ show_low_coverage,
60
+ show_mi_histogram,
61
+ show_raw_histogram,
62
+ )
63
+
64
+ VALID_VIEWS = {
65
+ "coverage",
66
+ "duration",
67
+ "sizes",
68
+ "list",
69
+ "histograms",
70
+ "complexity",
71
+ "mi",
72
+ "raw",
73
+ "hal",
74
+ "tree",
75
+ "blame",
76
+ "all",
77
+ }
78
+
79
+
80
+ def main():
81
+ p = argparse.ArgumentParser(
82
+ description="Coverage inspector + test report analyser",
83
+ formatter_class=argparse.RawDescriptionHelpFormatter,
84
+ epilog=__doc__,
85
+ )
86
+ p.add_argument(
87
+ "coverage_file",
88
+ nargs="?",
89
+ default="coverage.json",
90
+ help="Path to coverage.json (default: coverage.json)",
91
+ )
92
+
93
+ # Duration
94
+ p.add_argument(
95
+ "--report",
96
+ metavar="FILE",
97
+ default=None,
98
+ help="pytest-json-report file for the duration histogram",
99
+ )
100
+ p.add_argument(
101
+ "--slow-threshold",
102
+ type=float,
103
+ default=1.0,
104
+ metavar="SECS",
105
+ help="Slow-test threshold in seconds (default: 1.0)",
106
+ )
107
+
108
+ # File sizes
109
+ p.add_argument(
110
+ "--fs-root",
111
+ default=".",
112
+ metavar="DIR",
113
+ help="Root directory to scan for file-size histogram (default: .)",
114
+ )
115
+ p.add_argument(
116
+ "--ext", default="py", metavar="EXT", help="File extension to measure (default: py)"
117
+ )
118
+ p.add_argument(
119
+ "--fs-percentiles",
120
+ default="25,50,75,90,95,99",
121
+ metavar="LIST",
122
+ help="Comma-separated percentiles for file-size histogram",
123
+ )
124
+ p.add_argument(
125
+ "--fs-above",
126
+ type=int,
127
+ default=None,
128
+ metavar="PCT",
129
+ help="List files above this percentile (e.g. 95)",
130
+ )
131
+
132
+ # File tree
133
+ p.add_argument(
134
+ "--tree-root",
135
+ default=None,
136
+ metavar="DIR",
137
+ help="Root directory for file tree analysis (e.g. app)",
138
+ )
139
+
140
+ # Radon analysis root (used by --show complexity / mi / raw / hal)
141
+ p.add_argument(
142
+ "--radon-root",
143
+ default=None,
144
+ metavar="DIR",
145
+ help="Root directory for all radon analyses (e.g. app)",
146
+ )
147
+ p.add_argument(
148
+ "--debug-radon",
149
+ action="store_true",
150
+ help="Print first 2 raw entries from each radon command for path diagnostics",
151
+ )
152
+
153
+ # Low-coverage listing
154
+ p.add_argument(
155
+ "--threshold", type=float, default=90.0, help="Show files below this %% (default: 50)"
156
+ )
157
+ p.add_argument(
158
+ "--max",
159
+ dest="max_threshold",
160
+ type=float,
161
+ default=None,
162
+ help="Upper bound %% (range filter)",
163
+ )
164
+ p.add_argument(
165
+ "--top",
166
+ dest="top_n",
167
+ type=int,
168
+ default=20,
169
+ help="Max rows in file listing (0 = all, default: 20)",
170
+ )
171
+ p.add_argument(
172
+ "--sort",
173
+ dest="sort_specs",
174
+ nargs="+",
175
+ default=["priority:desc"],
176
+ metavar="FIELD[:asc|desc]",
177
+ help="Sort fields: priority coverage layer missing missing_pct",
178
+ )
179
+ p.add_argument("--sort-order", choices=["asc", "desc"], default="desc")
180
+ p.add_argument(
181
+ "--show-lines", action="store_true", help="Print missing line numbers under each file"
182
+ )
183
+
184
+ # Shared
185
+ p.add_argument("--bins", type=int, default=10, help="Histogram bin count (default: 10)")
186
+ p.add_argument(
187
+ "--blame-limit",
188
+ type=int,
189
+ default=20,
190
+ metavar="N",
191
+ help="Max entries in quality blame sections (default: 20, 0 = all)",
192
+ )
193
+
194
+ # View selector
195
+ p.add_argument(
196
+ "--show",
197
+ nargs="+",
198
+ metavar="VIEW",
199
+ default=["all"],
200
+ help=(
201
+ "Sections to display. Options: "
202
+ "coverage duration sizes list histograms blame all. "
203
+ "Default: all. histograms = bars only (no blame, no table). complexity = CC histogram. "
204
+ "Add blame to include quality blame sections."
205
+ ),
206
+ )
207
+
208
+ args = p.parse_args()
209
+
210
+ # Validate views
211
+ unknown = set(args.show) - VALID_VIEWS
212
+ if unknown:
213
+ p.error(
214
+ f"Unknown --show value(s): {', '.join(sorted(unknown))}. "
215
+ f"Valid: {', '.join(sorted(VALID_VIEWS))}"
216
+ )
217
+
218
+ views = set(args.show)
219
+ show_all = "all" in views
220
+ show_hists = "histograms" in views # shorthand: coverage+duration+sizes (no blame, no list)
221
+ want_cov = show_all or show_hists or "coverage" in views
222
+ want_dur = show_all or show_hists or "duration" in views
223
+ want_size = show_all or show_hists or "sizes" in views
224
+ want_blame = show_all or "blame" in views # histograms alone → no blame
225
+ want_complexity = show_all or show_hists or "complexity" in views
226
+ want_mi = show_all or show_hists or "mi" in views
227
+ want_raw = show_all or show_hists or "raw" in views
228
+ want_hal = show_all or show_hists or "hal" in views
229
+ want_tree = show_all or "tree" in views
230
+ want_list = show_all or "list" in views
231
+
232
+ if want_dur and not args.report:
233
+ if "duration" in views or show_hists:
234
+ p.error("--show duration/histograms requires --report <report.json>")
235
+ want_dur = False # silently skip when --show all and no report provided
236
+
237
+ pcts = [int(x.strip()) for x in args.fs_percentiles.split(",") if x.strip()]
238
+ top_n = args.top_n if args.top_n > 0 else None
239
+ sorts = parse_sorts(args.sort_specs, args.sort_order)
240
+
241
+ # Optionally run radon analyses
242
+ radon_root = args.radon_root
243
+ any_radon = want_complexity or want_mi or want_raw or want_hal
244
+ if any_radon and not radon_root:
245
+ needs = [
246
+ v
247
+ for v, w in [
248
+ ("complexity", want_complexity),
249
+ ("mi", want_mi),
250
+ ("raw", want_raw),
251
+ ("hal", want_hal),
252
+ ]
253
+ if w
254
+ ]
255
+ print(f"⚠️ --show {'/'.join(needs)} requires --radon-root <dir> (skipping)")
256
+ want_complexity = want_mi = want_raw = want_hal = False
257
+
258
+ _dbg = args.debug_radon
259
+
260
+ # Run all requested radon subcommands in parallel
261
+ if radon_root:
262
+ tasks = {}
263
+ wanted = {
264
+ "cc": want_complexity,
265
+ "mi": want_mi,
266
+ "raw": want_raw,
267
+ "hal": want_hal,
268
+ }
269
+ runners = {
270
+ "cc": lambda: run_radon(radon_root, debug=_dbg),
271
+ "mi": lambda: run_radon_mi(radon_root, debug=_dbg),
272
+ "raw": lambda: run_radon_raw(radon_root, debug=_dbg),
273
+ "hal": lambda: run_radon_hal(radon_root, debug=_dbg),
274
+ }
275
+ # Always run all four when radon-root is given so the summary has full
276
+ # data — but only the explicitly-requested ones block the display.
277
+ with ThreadPoolExecutor(max_workers=4) as pool:
278
+ futures = {pool.submit(fn): key for key, fn in runners.items()}
279
+ results = {key: {} for key in runners}
280
+ for future in as_completed(futures):
281
+ key = futures[future]
282
+ try:
283
+ results[key] = future.result()
284
+ except Exception as e:
285
+ print(f"⚠️ radon {key} failed: {e}")
286
+ complexity_map, mi_map, raw_map, hal_map = (
287
+ results["cc"],
288
+ results["mi"],
289
+ results["raw"],
290
+ results["hal"],
291
+ )
292
+ if _dbg:
293
+ for name, m in [
294
+ ("cc", complexity_map),
295
+ ("mi", mi_map),
296
+ ("raw", raw_map),
297
+ ("hal", hal_map),
298
+ ]:
299
+ print(f" [debug] {name}: {len(m)} entries sample key: {next(iter(m), 'n/a')!r}")
300
+ else:
301
+ complexity_map = mi_map = raw_map = hal_map = {}
302
+
303
+ # Pragma: no cover scan (use radon_root if available, else fs_root)
304
+ pragma_root = radon_root if radon_root else (args.fs_root if args.fs_root != "." else None)
305
+ pragma_counts: dict[str, int] = {}
306
+ if want_cov and pragma_root:
307
+ pragma_counts = scan_pragma_counts(pragma_root)
308
+
309
+ # Single precompute pass over coverage.json
310
+ cov_data = load_coverage(args.coverage_file)
311
+ stats = precompute_coverage_stats(cov_data, complexity_map, mi_map, raw_map, hal_map)
312
+
313
+ if want_cov:
314
+ show_coverage_histogram(
315
+ stats,
316
+ bins=args.bins,
317
+ blame_limit=args.blame_limit,
318
+ show_blame=want_blame,
319
+ threshold=args.threshold,
320
+ pragma_counts=pragma_counts,
321
+ )
322
+
323
+ if want_dur:
324
+ show_duration_histogram(
325
+ load_report(args.report),
326
+ bins=args.bins,
327
+ slow_threshold=args.slow_threshold,
328
+ blame_limit=args.blame_limit,
329
+ show_blame=want_blame,
330
+ )
331
+
332
+ if want_size:
333
+ show_file_size_distribution(
334
+ root=args.fs_root,
335
+ extension=args.ext,
336
+ percentiles=pcts,
337
+ show_above_pct=args.fs_above,
338
+ bins=args.bins,
339
+ blame_limit=args.blame_limit,
340
+ show_blame=want_blame,
341
+ )
342
+
343
+ if want_list:
344
+ show_low_coverage(
345
+ stats,
346
+ threshold=args.threshold,
347
+ max_threshold=args.max_threshold,
348
+ top_n=top_n,
349
+ sorts=sorts,
350
+ show_lines=args.show_lines,
351
+ )
352
+
353
+ if want_complexity:
354
+ show_complexity_histogram(
355
+ stats, bins=args.bins, blame_limit=args.blame_limit, show_blame=want_blame
356
+ )
357
+
358
+ if want_mi:
359
+ show_mi_histogram(
360
+ stats, bins=args.bins, blame_limit=args.blame_limit, show_blame=want_blame
361
+ )
362
+
363
+ if want_raw:
364
+ show_raw_histogram(
365
+ stats, bins=args.bins, blame_limit=args.blame_limit, show_blame=want_blame
366
+ )
367
+
368
+ if want_hal:
369
+ show_hal_histogram(
370
+ stats, bins=args.bins, blame_limit=args.blame_limit, show_blame=want_blame
371
+ )
372
+
373
+ if want_tree:
374
+ if args.tree_root:
375
+ analyze_tree(args.tree_root, bins=args.bins, blame_limit=args.blame_limit)
376
+ else:
377
+ print("⚠️ --show tree requires --tree-root <dir>")
378
+
379
+
380
+ if __name__ == "__main__":
381
+ main()
@@ -7,13 +7,13 @@ from pathlib import Path
7
7
 
8
8
  def load_coverage(path: str) -> dict:
9
9
  """Load and parse a coverage.json file.
10
-
10
+
11
11
  Args:
12
12
  path: Path to coverage.json file
13
-
13
+
14
14
  Returns:
15
15
  Parsed coverage data dictionary
16
-
16
+
17
17
  Raises:
18
18
  SystemExit: On file not found or invalid JSON
19
19
  """
@@ -31,13 +31,13 @@ def load_coverage(path: str) -> dict:
31
31
 
32
32
  def load_report(path: str) -> dict:
33
33
  """Load and parse a pytest-json-report file.
34
-
34
+
35
35
  Args:
36
36
  path: Path to pytest JSON report file
37
-
37
+
38
38
  Returns:
39
39
  Parsed report data dictionary
40
-
40
+
41
41
  Raises:
42
42
  SystemExit: On file not found or invalid JSON
43
43
  """
@@ -78,10 +78,10 @@ LAYER_ORDER = {
78
78
 
79
79
  def extract_layer(path: str) -> str:
80
80
  """Extract the architectural layer from a file path.
81
-
81
+
82
82
  Args:
83
83
  path: File path to analyze
84
-
84
+
85
85
  Returns:
86
86
  Layer name (e.g., "Domain", "Application", "Other")
87
87
  """
@@ -94,19 +94,19 @@ def extract_layer(path: str) -> str:
94
94
 
95
95
  def build_suffix_index(radon_map: dict) -> dict:
96
96
  """Build a suffix-keyed index for path matching.
97
-
98
- This allows matching coverage.json paths (which may use different root
97
+
98
+ This allows matching coverage.json paths (which may use different root
99
99
  prefixes or be absolute vs relative) to radon output.
100
-
100
+
101
101
  Strategy: for each radon key, store it under every possible suffix:
102
102
  '/home/user/proj/app/foo/bar.py' → keys: 'bar.py', 'foo/bar.py',
103
103
  'app/foo/bar.py', ...
104
104
  Then look up a coverage path by trying progressively longer suffixes
105
105
  until one hits. The longest match wins to avoid false collisions.
106
-
106
+
107
107
  Args:
108
108
  radon_map: Dictionary mapping file paths to radon analysis data
109
-
109
+
110
110
  Returns:
111
111
  Dictionary mapping suffixes to radon values
112
112
  """
@@ -123,13 +123,13 @@ def build_suffix_index(radon_map: dict) -> dict:
123
123
 
124
124
  def suffix_lookup(index: dict, coverage_path: str):
125
125
  """Look up a coverage.json path in a suffix index.
126
-
126
+
127
127
  Try suffixes from most-specific (full path) down to basename.
128
-
128
+
129
129
  Args:
130
130
  index: Index built by build_suffix_index()
131
131
  coverage_path: Path from coverage.json to look up
132
-
132
+
133
133
  Returns:
134
134
  The radon value if found, None otherwise
135
135
  """
@@ -149,17 +149,17 @@ def precompute_coverage_stats(
149
149
  hal_map: dict = None,
150
150
  ) -> dict:
151
151
  """Precompute statistics from coverage.json and radon data.
152
-
152
+
153
153
  One pass over data['files']. Returns everything both histogram and
154
154
  list functions need so neither has to re-iterate the JSON.
155
-
155
+
156
156
  Args:
157
157
  data: Parsed coverage.json data
158
158
  complexity_map: Radon cyclomatic complexity data (optional)
159
159
  mi_map: Radon maintainability index data (optional)
160
160
  raw_map: Radon raw metrics data (optional)
161
161
  hal_map: Radon Halstead metrics data (optional)
162
-
162
+
163
163
  Returns:
164
164
  Dictionary with precomputed statistics including file_stats, project totals,
165
165
  and coverage percentiles
@@ -1,15 +1,16 @@
1
1
  """Coverage analysis functions."""
2
2
 
3
3
  from pathlib import Path
4
- from .utils import percentile, format_line_ranges, ascii_histogram, blame_header
4
+
5
+ from .utils import ascii_histogram, blame_header, format_line_ranges, percentile
5
6
 
6
7
 
7
8
  def scan_pragma_intervals(root: str) -> dict[str, list]:
8
9
  """Find sequential # pragma: no cover blocks per .py file under root.
9
-
10
+
10
11
  Args:
11
12
  root: Root directory to scan
12
-
13
+
13
14
  Returns:
14
15
  Dictionary mapping file paths to lists of (start, end) tuples
15
16
  """
@@ -40,10 +41,10 @@ def scan_pragma_intervals(root: str) -> dict[str, list]:
40
41
 
41
42
  def scan_pragma_counts(root: str) -> dict[str, int]:
42
43
  """Count '# pragma: no cover' occurrences per .py file under root.
43
-
44
+
44
45
  Args:
45
46
  root: Root directory to scan
46
-
47
+
47
48
  Returns:
48
49
  Dictionary mapping file paths to pragma counts
49
50
  """
@@ -60,12 +61,14 @@ def scan_pragma_counts(root: str) -> dict[str, int]:
60
61
  return result
61
62
 
62
63
 
63
- def show_pragma_histogram(pragma_counts: dict, bins: int = 10, blame_limit: int = 20, width: int = 80) -> None:
64
+ def show_pragma_histogram(
65
+ pragma_counts: dict, bins: int = 10, blame_limit: int = 20, width: int = 80
66
+ ) -> None:
64
67
  """Histogram of '# pragma: no cover' count per file + all-files listing.
65
68
 
66
69
  Also prints a secondary histogram and table for sequential blocks of
67
70
  pragmas (intervals) so that you can spot long runs of skipped code.
68
-
71
+
69
72
  Args:
70
73
  pragma_counts: Dictionary mapping files to pragma counts
71
74
  bins: Number of histogram bins
@@ -173,7 +176,7 @@ def show_coverage_histogram(
173
176
  width: int = 80,
174
177
  ):
175
178
  """Display coverage distribution histogram and low-coverage blame.
176
-
179
+
177
180
  Args:
178
181
  stats: Precomputed statistics from precompute_coverage_stats()
179
182
  bins: Number of histogram bins
@@ -228,6 +231,7 @@ def show_coverage_histogram(
228
231
  missing_counts = sorted(f["missing_count"] for f in stats["file_stats"] if f["pct"] < 100.0)
229
232
  if missing_counts:
230
233
  import math
234
+
231
235
  nm = len(missing_counts)
232
236
  total_missing = sum(missing_counts)
233
237
  m_max = missing_counts[-1]
@@ -282,7 +286,9 @@ def show_coverage_histogram(
282
286
  ],
283
287
  key=lambda x: x[0],
284
288
  )
285
- blame_header(f"below {blame_ceiling:.1f}% ({boundary_note})", len(blamed), blame_limit, width)
289
+ blame_header(
290
+ f"below {blame_ceiling:.1f}% ({boundary_note})", len(blamed), blame_limit, width
291
+ )
286
292
  display = blamed if not blame_limit else blamed[:blame_limit]
287
293
  if display:
288
294
  for pct, path, missing_count, missing_lines in display:
@@ -1,17 +1,18 @@
1
1
  """Test duration and performance analysis."""
2
2
 
3
3
  import math
4
- from .utils import percentile, fmt_seconds, ascii_histogram, blame_header
4
+
5
+ from .utils import ascii_histogram, blame_header, fmt_seconds, percentile
5
6
 
6
7
 
7
8
  def test_duration(t: dict):
8
9
  """Get (total_duration, breakdown_dict) for a test.
9
-
10
+
10
11
  Sum setup, call and teardown phases.
11
-
12
+
12
13
  Args:
13
14
  t: Test dictionary from pytest-json-report
14
-
15
+
15
16
  Returns:
16
17
  Tuple of (total_duration_seconds, phase_breakdown_dict)
17
18
  """
@@ -32,7 +33,7 @@ def test_duration(t: dict):
32
33
 
33
34
  def render_duration_stats(ds: list, slow_threshold: float, width: int = 80):
34
35
  """Render summary statistics for a duration list.
35
-
36
+
36
37
  Args:
37
38
  ds: Sorted list of durations
38
39
  slow_threshold: Threshold for considering tests slow
@@ -67,7 +68,7 @@ def render_duration_histogram_core(
67
68
  width: int = 80,
68
69
  ):
69
70
  """Render the ASCII histogram for a duration list.
70
-
71
+
71
72
  Args:
72
73
  ds: Sorted list of durations
73
74
  title: Title for the histogram
@@ -138,7 +139,7 @@ def show_duration_histogram(
138
139
  width: int = 80,
139
140
  ):
140
141
  """Display test duration histogram and slow test blame.
141
-
142
+
142
143
  Args:
143
144
  report: Parsed pytest-json-report data
144
145
  bins: Number of histogram bins
@@ -181,7 +182,9 @@ def show_duration_histogram(
181
182
  report_total = report.get("duration", 0)
182
183
  collection_time = report_total - total if report_total > total else 0
183
184
 
184
- print(f" Tests: {n} Total: {fmt_seconds(total)} ({fmt_seconds(report_total)} wall time) {outcome_str}")
185
+ print(
186
+ f" Tests: {n} Total: {fmt_seconds(total)} ({fmt_seconds(report_total)} wall time) {outcome_str}"
187
+ )
185
188
  if collection_time > 1:
186
189
  print(f" (includes ~{fmt_seconds(collection_time)} collection/overhead)")
187
190
 
@@ -194,7 +197,9 @@ def show_duration_histogram(
194
197
 
195
198
  # 2. Show call histogram
196
199
  if phases["call"]:
197
- render_duration_histogram_core(sorted(phases["call"]), "PHASE: CALL", bins, slow_threshold, width)
200
+ render_duration_histogram_core(
201
+ sorted(phases["call"]), "PHASE: CALL", bins, slow_threshold, width
202
+ )
198
203
  print(f"\n {'-' * (width - 4)}")
199
204
 
200
205
  # 3. Show teardown histogram
@@ -205,7 +210,9 @@ def show_duration_histogram(
205
210
  print(f"\n {'-' * (width - 4)}")
206
211
 
207
212
  # 4. Show total duration histogram
208
- render_duration_histogram_core(durations, "AGGREGATE: TOTAL DURATION", bins, slow_threshold, width)
213
+ render_duration_histogram_core(
214
+ durations, "AGGREGATE: TOTAL DURATION", bins, slow_threshold, width
215
+ )
209
216
 
210
217
  if show_blame:
211
218
  q1_dur = percentile(durations, 25)
@@ -1,19 +1,18 @@
1
1
  """Low-coverage file listing and prioritization."""
2
2
 
3
- from .utils import format_line_ranges
4
3
  from .metrics import cc_rank, mi_rank
5
-
4
+ from .utils import format_line_ranges
6
5
 
7
6
  VALID_SORT_FIELDS = {"priority", "coverage", "layer", "missing", "missing_pct", "complexity"}
8
7
 
9
8
 
10
9
  def parse_sorts(sort_specs: list, default_order: str) -> list:
11
10
  """Parse sort specifications into (field, is_descending) tuples.
12
-
11
+
13
12
  Args:
14
13
  sort_specs: List of sort specs like ["priority:desc", "coverage:asc"]
15
14
  default_order: Default sort order ("asc" or "desc")
16
-
15
+
17
16
  Returns:
18
17
  List of (field, is_descending) tuples
19
18
  """
@@ -32,16 +31,18 @@ def parse_sorts(sort_specs: list, default_order: str) -> list:
32
31
  return result
33
32
 
34
33
 
35
- def priority_score(pct: float, layer: str, missing: int, cc_avg: float = None, mi: float = None) -> int:
34
+ def priority_score(
35
+ pct: float, layer: str, missing: int, cc_avg: float = None, mi: float = None
36
+ ) -> int:
36
37
  """Calculate priority score for a file based on coverage and metrics.
37
-
38
+
38
39
  Args:
39
40
  pct: Coverage percentage
40
41
  layer: Architectural layer
41
42
  missing: Number of missing statements
42
43
  cc_avg: Average cyclomatic complexity (optional)
43
44
  mi: Maintainability index (optional)
44
-
45
+
45
46
  Returns:
46
47
  Priority score (higher = more important to fix)
47
48
  """
@@ -68,7 +69,7 @@ def show_low_coverage(
68
69
  width: int = 100,
69
70
  ):
70
71
  """Display files with low coverage and quality metrics.
71
-
72
+
72
73
  Args:
73
74
  stats: Precomputed statistics from precompute_coverage_stats()
74
75
  threshold: Show files below this coverage percentage
@@ -1,25 +1,16 @@
1
1
  """Code quality metrics analysis (complexity, maintainability, etc.)."""
2
2
 
3
3
  import bisect
4
- from .utils import percentile, format_line_ranges, ascii_histogram, blame_header
4
+
5
5
  from .radon import ( # re-export so callers can still import from here
6
6
  cc_rank,
7
7
  mi_rank,
8
- run_radon_json,
9
8
  run_radon,
10
9
  run_radon_mi,
11
10
  run_radon_raw,
12
11
  run_radon_hal,
13
- _HAL_VOLUME,
14
- _HAL_DIFFICULTY,
15
- _HAL_EFFORT,
16
- _HAL_BUGS,
17
- _parse_hal_list,
18
- _parse_hal_dict,
19
- _parse_hal_entry,
20
- _run_hal_chunk,
21
- _collect_hal_raw,
22
12
  )
13
+ from .utils import ascii_histogram, blame_header, format_line_ranges, percentile
23
14
 
24
15
 
25
16
  def _float_buckets(sorted_values: list, n_bins: int) -> tuple:
@@ -109,9 +100,7 @@ def _blame_raw(raw_files: list, q1_ratio: float, blame_limit: int, width: int) -
109
100
  [f for f in raw_files if f[0] < q1_ratio],
110
101
  key=lambda x: x[0],
111
102
  )
112
- blame_header(
113
- f"comment ratio below Q1 ({q1_ratio * 100:.1f}%)", len(blamed), blame_limit, width
114
- )
103
+ blame_header(f"comment ratio below Q1 ({q1_ratio * 100:.1f}%)", len(blamed), blame_limit, width)
115
104
  display = blamed if not blame_limit else blamed[:blame_limit]
116
105
  if display:
117
106
  print(f" {'Comment%':<10} {'SLOC':<7} {'Coverage':<10} File")
@@ -289,7 +278,7 @@ def show_raw_histogram(
289
278
  stats: dict, bins: int = 10, blame_limit: int = 20, show_blame: bool = True, width: int = 80
290
279
  ):
291
280
  """Display comment ratio histogram and under-documented blame.
292
-
281
+
293
282
  Args:
294
283
  stats: Precomputed statistics from precompute_coverage_stats()
295
284
  bins: Number of histogram bins
@@ -343,7 +332,7 @@ def show_hal_histogram(
343
332
  stats: dict, bins: int = 10, blame_limit: int = 20, show_blame: bool = True, width: int = 80
344
333
  ):
345
334
  """Display Halstead metrics histogram and bug-prone file blame.
346
-
335
+
347
336
  Args:
348
337
  stats: Precomputed statistics from precompute_coverage_stats()
349
338
  bins: Number of histogram bins
@@ -391,3 +380,17 @@ def show_hal_histogram(
391
380
  threshold = q3_b + 1.5 * (q3_b - q1_b)
392
381
  _blame_hal(hal_files, threshold, blame_limit, width)
393
382
  print(f"\n{'═' * width}")
383
+
384
+
385
+ # Re-export Halstead constants and helpers for backward compatibility
386
+ from .radon import ( # noqa: E402
387
+ _HAL_VOLUME,
388
+ _HAL_DIFFICULTY,
389
+ _HAL_EFFORT,
390
+ _HAL_BUGS,
391
+ _parse_hal_list,
392
+ _parse_hal_dict,
393
+ _parse_hal_entry,
394
+ _run_hal_chunk,
395
+ run_radon_json,
396
+ )
@@ -8,10 +8,26 @@ from pathlib import Path
8
8
  # Cyclomatic Complexity rank thresholds (McCabe scale)
9
9
  # A: 1–5 B: 6–10 C: 11–15 D: 16–20 E: 21–25 F: 26+
10
10
  _CC_RISK = {
11
- 1: "A", 2: "A", 3: "A", 4: "A", 5: "A",
12
- 6: "B", 7: "B", 8: "B", 9: "B", 10: "B",
13
- 11: "C", 12: "C", 13: "C", 14: "C", 15: "C",
14
- 16: "D", 17: "D", 18: "D", 19: "D", 20: "D",
11
+ 1: "A",
12
+ 2: "A",
13
+ 3: "A",
14
+ 4: "A",
15
+ 5: "A",
16
+ 6: "B",
17
+ 7: "B",
18
+ 8: "B",
19
+ 9: "B",
20
+ 10: "B",
21
+ 11: "C",
22
+ 12: "C",
23
+ 13: "C",
24
+ 14: "C",
25
+ 15: "C",
26
+ 16: "D",
27
+ 17: "D",
28
+ 18: "D",
29
+ 19: "D",
30
+ 20: "D",
15
31
  }
16
32
 
17
33
 
@@ -65,6 +81,7 @@ def run_radon_json(subcmd: str, root: str, extra_flags: list = None, debug: bool
65
81
  print(f"⚠️ radon {subcmd} failed: {e}")
66
82
  if debug:
67
83
  import traceback
84
+
68
85
  traceback.print_exc()
69
86
  return {}
70
87
 
@@ -247,8 +264,7 @@ def run_radon_hal(root: str, debug: bool = False) -> dict:
247
264
  print(f"⚠️ radon hal: path does not exist: {root_path}")
248
265
  return {}
249
266
  py_files = sorted(
250
- str(p) for p in root_path.rglob("*.py")
251
- if not any(part.startswith(".") for part in p.parts)
267
+ str(p) for p in root_path.rglob("*.py") if not any(part.startswith(".") for part in p.parts)
252
268
  )
253
269
  if not py_files:
254
270
  print(f"⚠️ radon hal: no .py files found under {root_path}")
@@ -12,7 +12,7 @@ from .core import (
12
12
  precompute_coverage_stats,
13
13
  )
14
14
  from .coverage import scan_pragma_counts
15
- from .metrics import (
15
+ from .radon import (
16
16
  run_radon,
17
17
  run_radon_mi,
18
18
  run_radon_raw,
@@ -1,7 +1,8 @@
1
1
  """File size distribution analysis."""
2
2
 
3
3
  from pathlib import Path
4
- from .utils import percentile, ascii_histogram, blame_header
4
+
5
+ from .utils import ascii_histogram, blame_header, percentile
5
6
 
6
7
 
7
8
  def show_file_size_distribution(
@@ -15,7 +16,7 @@ def show_file_size_distribution(
15
16
  width: int = 80,
16
17
  ):
17
18
  """Display file size distribution histogram.
18
-
19
+
19
20
  Args:
20
21
  root: Root directory to scan
21
22
  extension: File extension to analyze
@@ -88,7 +89,10 @@ def show_file_size_distribution(
88
89
  [(lc, fp) for lc, fp in files if lc > iqr_lines], key=lambda x: x[0], reverse=True
89
90
  )
90
91
  blame_header(
91
- f"size outliers Q3 + 1.5×IQR > {iqr_lines} lines", len(blamed_files), blame_limit, width
92
+ f"size outliers Q3 + 1.5×IQR > {iqr_lines} lines",
93
+ len(blamed_files),
94
+ blame_limit,
95
+ width,
92
96
  )
93
97
  display = blamed_files if not blame_limit else blamed_files[:blame_limit]
94
98
  if display:
@@ -2,14 +2,15 @@
2
2
 
3
3
  import os
4
4
  from pathlib import Path
5
- from .utils import percentile, ascii_histogram, blame_header
5
+
6
+ from .utils import ascii_histogram, blame_header, percentile
6
7
 
7
8
 
8
9
  def analyze_tree(root_path: str, bins: int = 10, blame_limit: int = 20, width: int = 80):
9
10
  """Analyze file tree structure, collecting metrics per directory.
10
-
11
+
11
12
  Displays statistics and histograms about file and folder distribution.
12
-
13
+
13
14
  Args:
14
15
  root_path: Root directory to analyze
15
16
  bins: Number of histogram bins
@@ -132,7 +133,10 @@ def analyze_tree(root_path: str, bins: int = 10, blame_limit: int = 20, width: i
132
133
  )
133
134
 
134
135
  blame_header(
135
- f"subfolder count outliers (Q3+1.5×IQR > {boundary_d:g})", len(blamed_d), blame_limit, width
136
+ f"subfolder count outliers (Q3+1.5×IQR > {boundary_d:g})",
137
+ len(blamed_d),
138
+ blame_limit,
139
+ width,
136
140
  )
137
141
  display_d = blamed_d if not blame_limit else blamed_d[:blame_limit]
138
142
  if display_d:
@@ -1,6 +1,5 @@
1
1
  """Shared utility functions for codebase analysis."""
2
2
 
3
- import math
4
3
 
5
4
 
6
5
  def percentile(sorted_vals: list, pct: int):
@@ -12,10 +11,10 @@ def percentile(sorted_vals: list, pct: int):
12
11
 
13
12
  def format_line_ranges(lines: list) -> str:
14
13
  """Convert a list of line numbers into a human-readable ranges string.
15
-
14
+
16
15
  Args:
17
16
  lines: List of line numbers
18
-
17
+
19
18
  Returns:
20
19
  String like "1-5, 10, 15-18"
21
20
  """
@@ -37,7 +36,7 @@ def ascii_histogram(
37
36
  counts: list, labels: list, bar_width: int = 36, suffixes: list = None, width: int = 80
38
37
  ) -> None:
39
38
  """Print a fixed-width ASCII histogram.
40
-
39
+
41
40
  Args:
42
41
  counts: List of counts for each bar
43
42
  labels: List of labels for each bar
@@ -57,7 +56,7 @@ def ascii_histogram(
57
56
 
58
57
  def blame_header(label: str, total: int, limit: int, width: int = 80) -> None:
59
58
  """Print a blame section header.
60
-
59
+
61
60
  Args:
62
61
  label: Description of what's being blamed
63
62
  total: Total number of blamed items
@@ -72,7 +71,7 @@ def blame_header(label: str, total: int, limit: int, width: int = 80) -> None:
72
71
 
73
72
  def fmt_seconds(seconds: float) -> str:
74
73
  """Format seconds into human-readable time string.
75
-
74
+
76
75
  Examples:
77
76
  0.5 -> "500ms"
78
77
  1.5 -> "1.50s"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebase-stats
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
4
  Summary: Comprehensive codebase analysis library with coverage, metrics, and test duration analysis
5
5
  Author-email: Your Name <your.email@example.com>
6
6
  License: MIT
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  codebase_stats/__init__.py
4
+ codebase_stats/cli.py
4
5
  codebase_stats/core.py
5
6
  codebase_stats/coverage.py
6
7
  codebase_stats/duration.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ codebase-stats = codebase_stats.cli:main
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codebase-stats"
7
- version = "0.0.1"
7
+ version = "0.0.2"
8
8
  description = "Comprehensive codebase analysis library with coverage, metrics, and test duration analysis"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -102,7 +102,6 @@ ignore = [
102
102
 
103
103
  [tool.ruff.lint.isort]
104
104
  known-first-party = ["codebase_stats"]
105
- profile = "black"
106
105
 
107
106
  [tool.mypy]
108
107
  python_version = "3.9"
@@ -112,7 +111,7 @@ disallow_untyped_defs = false
112
111
  ignore_missing_imports = true
113
112
 
114
113
  [project.scripts]
115
- codebase-stats = "cli:main"
114
+ codebase-stats = "codebase_stats.cli:main"
116
115
 
117
116
  [tool.setuptools]
118
117
  packages = ["codebase_stats"]
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- codebase-stats = cli:main
File without changes
File without changes