repoglance 0.4.2__tar.gz → 0.4.3__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 (58) hide show
  1. {repoglance-0.4.2 → repoglance-0.4.3}/CHANGELOG.md +14 -0
  2. {repoglance-0.4.2 → repoglance-0.4.3}/PKG-INFO +11 -6
  3. {repoglance-0.4.2 → repoglance-0.4.3}/README.md +10 -5
  4. {repoglance-0.4.2 → repoglance-0.4.3}/pyproject.toml +1 -1
  5. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/__init__.py +1 -1
  6. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/cli.py +5 -4
  7. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/complexity.py +22 -7
  8. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/scanner.py +19 -9
  9. {repoglance-0.4.2 → repoglance-0.4.3}/tests/test_v041.py +14 -0
  10. {repoglance-0.4.2 → repoglance-0.4.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  11. {repoglance-0.4.2 → repoglance-0.4.3}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  12. {repoglance-0.4.2 → repoglance-0.4.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  13. {repoglance-0.4.2 → repoglance-0.4.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  14. {repoglance-0.4.2 → repoglance-0.4.3}/.github/workflows/ci.yml +0 -0
  15. {repoglance-0.4.2 → repoglance-0.4.3}/.github/workflows/release.yml +0 -0
  16. {repoglance-0.4.2 → repoglance-0.4.3}/.github/workflows/repoglance.yml +0 -0
  17. {repoglance-0.4.2 → repoglance-0.4.3}/.gitignore +0 -0
  18. {repoglance-0.4.2 → repoglance-0.4.3}/.pre-commit-hooks.yaml +0 -0
  19. {repoglance-0.4.2 → repoglance-0.4.3}/.repoglance-badge.json +0 -0
  20. {repoglance-0.4.2 → repoglance-0.4.3}/CODE_OF_CONDUCT.md +0 -0
  21. {repoglance-0.4.2 → repoglance-0.4.3}/CONTRIBUTING.md +0 -0
  22. {repoglance-0.4.2 → repoglance-0.4.3}/Dockerfile +0 -0
  23. {repoglance-0.4.2 → repoglance-0.4.3}/LICENSE +0 -0
  24. {repoglance-0.4.2 → repoglance-0.4.3}/RELEASING.md +0 -0
  25. {repoglance-0.4.2 → repoglance-0.4.3}/SECURITY.md +0 -0
  26. {repoglance-0.4.2 → repoglance-0.4.3}/action.yml +0 -0
  27. {repoglance-0.4.2 → repoglance-0.4.3}/assets/badge.svg +0 -0
  28. {repoglance-0.4.2 → repoglance-0.4.3}/assets/demo.svg +0 -0
  29. {repoglance-0.4.2 → repoglance-0.4.3}/assets/demo.tape +0 -0
  30. {repoglance-0.4.2 → repoglance-0.4.3}/assets/showcase/flask.svg +0 -0
  31. {repoglance-0.4.2 → repoglance-0.4.3}/assets/showcase/httpie.svg +0 -0
  32. {repoglance-0.4.2 → repoglance-0.4.3}/assets/showcase/requests.svg +0 -0
  33. {repoglance-0.4.2 → repoglance-0.4.3}/benchmarks/bench.py +0 -0
  34. {repoglance-0.4.2 → repoglance-0.4.3}/docs/index.md +0 -0
  35. {repoglance-0.4.2 → repoglance-0.4.3}/editors/vscode/README.md +0 -0
  36. {repoglance-0.4.2 → repoglance-0.4.3}/editors/vscode/extension.js +0 -0
  37. {repoglance-0.4.2 → repoglance-0.4.3}/editors/vscode/package.json +0 -0
  38. {repoglance-0.4.2 → repoglance-0.4.3}/integrations/gitlab-ci.yml +0 -0
  39. {repoglance-0.4.2 → repoglance-0.4.3}/mkdocs.yml +0 -0
  40. {repoglance-0.4.2 → repoglance-0.4.3}/packaging/homebrew/repoglance.rb +0 -0
  41. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/__main__.py +0 -0
  42. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/badge.py +0 -0
  43. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/cache.py +0 -0
  44. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/config.py +0 -0
  45. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/dedup.py +0 -0
  46. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/gitinfo.py +0 -0
  47. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/languages.py +0 -0
  48. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/metrics.py +0 -0
  49. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/plugins.py +0 -0
  50. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/report.py +0 -0
  51. {repoglance-0.4.2 → repoglance-0.4.3}/src/repoglance/vendored.py +0 -0
  52. {repoglance-0.4.2 → repoglance-0.4.3}/tests/test_cli.py +0 -0
  53. {repoglance-0.4.2 → repoglance-0.4.3}/tests/test_complexity.py +0 -0
  54. {repoglance-0.4.2 → repoglance-0.4.3}/tests/test_features.py +0 -0
  55. {repoglance-0.4.2 → repoglance-0.4.3}/tests/test_scanner.py +0 -0
  56. {repoglance-0.4.2 → repoglance-0.4.3}/tests/test_scanner_extra.py +0 -0
  57. {repoglance-0.4.2 → repoglance-0.4.3}/tests/test_v030.py +0 -0
  58. {repoglance-0.4.2 → repoglance-0.4.3}/tests/test_v040.py +0 -0
@@ -4,6 +4,20 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.4.3] - 2026-08-01
8
+
9
+ ### Added
10
+ - **`--fast` mode**: counts only (languages, LOC, sizes), skipping per-function
11
+ complexity parsing — the expensive step. Combined with the process pool this
12
+ scans **20 million lines in ~4.4 s** (~4.5M LOC/s). Use it for size/inventory
13
+ passes on very large monorepos; drop it for full complexity/health analysis.
14
+
15
+ ### Changed
16
+ - **Python complexity now uses CPython's C-accelerated `ast`** instead of the
17
+ pure-Python tokenizer, roughly **halving full-analysis time on Python-heavy
18
+ repos** (Django 415k LOC: ~10 s → ~5 s on 8 cores). Other languages still use
19
+ lizard. Adaptive process-pool chunk sizing reduces IPC overhead.
20
+
7
21
  ## [0.4.2] - 2026-08-01
8
22
 
9
23
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repoglance
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Instant, gorgeous insight into any code repository — languages, complexity hotspots, TODOs and git activity in one terminal command.
5
5
  Project-URL: Homepage, https://github.com/SRJ-ai/repoglance
6
6
  Project-URL: Repository, https://github.com/SRJ-ai/repoglance
@@ -302,11 +302,16 @@ millions of lines, thousands of files — not just small projects:
302
302
 
303
303
  Measured on Django (3,180 files, ~415k lines of code), single machine:
304
304
 
305
- | Run | Time |
306
- |---|--:|
307
- | Cold scan, threads | ~20.5 s |
308
- | Cold scan, process pool (auto on large repos) | ~10.1 s |
309
- | Re-run with `--cache` | ~1.1 s |
305
+ | Run | Repo | Time |
306
+ |---|---|--:|
307
+ | Cold scan, full analysis (8 cores) | Django, 415k LOC | ~5.0 s |
308
+ | Re-run with `--cache` | Django, 415k LOC | ~1.1 s |
309
+ | **`--fast` (counts only), process pool** | **20,000,000 LOC** | **~4.4 s** |
310
+
311
+ `--fast` skips per-function complexity parsing (the expensive step) and reports
312
+ languages, line counts and sizes only — that's what makes a 20-million-line
313
+ scan finish in seconds (~4.5M LOC/s here). Drop `--fast` when you want the full
314
+ complexity/health analysis.
310
315
 
311
316
  The cold scan is dominated by real per-function complexity parsing. The process
312
317
  pool spreads that across cores (~2× here); the incremental cache (`--cache
@@ -270,11 +270,16 @@ millions of lines, thousands of files — not just small projects:
270
270
 
271
271
  Measured on Django (3,180 files, ~415k lines of code), single machine:
272
272
 
273
- | Run | Time |
274
- |---|--:|
275
- | Cold scan, threads | ~20.5 s |
276
- | Cold scan, process pool (auto on large repos) | ~10.1 s |
277
- | Re-run with `--cache` | ~1.1 s |
273
+ | Run | Repo | Time |
274
+ |---|---|--:|
275
+ | Cold scan, full analysis (8 cores) | Django, 415k LOC | ~5.0 s |
276
+ | Re-run with `--cache` | Django, 415k LOC | ~1.1 s |
277
+ | **`--fast` (counts only), process pool** | **20,000,000 LOC** | **~4.4 s** |
278
+
279
+ `--fast` skips per-function complexity parsing (the expensive step) and reports
280
+ languages, line counts and sizes only — that's what makes a 20-million-line
281
+ scan finish in seconds (~4.5M LOC/s here). Drop `--fast` when you want the full
282
+ complexity/health analysis.
278
283
 
279
284
  The cold scan is dominated by real per-function complexity parsing. The process
280
285
  pool spreads that across cores (~2× here); the incremental cache (`--cache
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "repoglance"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "Instant, gorgeous insight into any code repository — languages, complexity hotspots, TODOs and git activity in one terminal command."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """repoglance — instant, gorgeous insight into any code repository."""
2
2
 
3
- __version__ = "0.4.2"
3
+ __version__ = "0.4.3"
@@ -41,6 +41,7 @@ def _resolve(cli_val, cfg, key, default):
41
41
  @click.option("--max-bytes", type=int, default=None, help="Skip files larger than this many bytes.")
42
42
  @click.option("--jobs", type=int, default=None, help="Number of parallel workers for scanning.")
43
43
  @click.option("--processes/--threads", "use_processes", default=None, help="Force process- or thread-based parallelism (default: auto by repo size).")
44
+ @click.option("--fast", is_flag=True, help="Counts only (languages, LOC) — skip complexity parsing for maximum speed on huge repos.")
44
45
  @click.option("--duplicates", "want_dupes", is_flag=True, help="Detect duplicated code blocks.")
45
46
  @click.option("--owners", "want_owners", is_flag=True, help="Attribute top complexity hotspots to authors (git blame).")
46
47
  @click.option("--include-vendored", is_flag=True, help="Analyze vendored/generated files instead of excluding them.")
@@ -57,9 +58,9 @@ def _resolve(cli_val, cfg, key, default):
57
58
  @click.version_option(__version__, "-V", "--version", prog_name="repoglance")
58
59
  def main(path, as_json, as_md, as_csv, as_sarif, sarif_threshold, html_path, svg_path,
59
60
  badge_path, badge_json_path, baseline_out, since_rev, include, exclude, ignore,
60
- no_git, max_bytes, jobs, use_processes, want_dupes, want_owners, include_vendored,
61
- cache_path, watch, compare_to, fail_on_regression, ci, max_complexity, max_todos,
62
- fail_under):
61
+ no_git, max_bytes, jobs, use_processes, fast, want_dupes, want_owners,
62
+ include_vendored, cache_path, watch, compare_to, fail_on_regression, ci,
63
+ max_complexity, max_todos, fail_under):
63
64
  """Instant, gorgeous insight into any code repository.
64
65
 
65
66
  PATH defaults to the current directory. Configuration may be supplied via
@@ -82,7 +83,7 @@ def main(path, as_json, as_md, as_csv, as_sarif, sarif_threshold, html_path, svg
82
83
  "max_bytes": max_bytes, "extra_ignores": ignores, "include": include,
83
84
  "exclude": exclude, "changed_only": changed, "jobs": jobs,
84
85
  "include_vendored": include_vendored, "keep_contents": want_dupes,
85
- "processes": use_processes,
86
+ "processes": use_processes, "fast": fast,
86
87
  }
87
88
 
88
89
  if watch:
@@ -70,7 +70,7 @@ def _lizard_scores(source: str, rel_path: str) -> Optional[List[FuncScore]]:
70
70
 
71
71
 
72
72
  def python_complexity(source: str, rel_path: str) -> List[FuncScore]:
73
- """AST-based fallback for Python when lizard is unavailable."""
73
+ """Fast AST-based complexity for Python (CPython's C parser)."""
74
74
  try:
75
75
  tree = ast.parse(source)
76
76
  except SyntaxError:
@@ -79,17 +79,24 @@ def python_complexity(source: str, rel_path: str) -> List[FuncScore]:
79
79
  scores: List[FuncScore] = []
80
80
 
81
81
  class Visitor(ast.NodeVisitor):
82
- def _score(self, node) -> int:
82
+ def _measure(self, node):
83
83
  complexity = 1
84
+ nodes = 0
84
85
  for child in ast.walk(node):
86
+ nodes += 1
85
87
  if isinstance(child, _BRANCH_NODES):
86
88
  complexity += 1
87
89
  elif isinstance(child, _BOOLOP_EXTRA):
88
90
  complexity += len(child.values) - 1
89
- return complexity
91
+ end = getattr(node, "end_lineno", node.lineno) or node.lineno
92
+ nloc = max(1, end - node.lineno + 1)
93
+ args = node.args
94
+ params = len(args.args) + len(getattr(args, "posonlyargs", [])) + len(args.kwonlyargs)
95
+ return complexity, nloc, nodes, params
90
96
 
91
97
  def visit_FunctionDef(self, node):
92
- scores.append(FuncScore(rel_path, node.name, node.lineno, self._score(node)))
98
+ cx, nloc, tokens, params = self._measure(node)
99
+ scores.append(FuncScore(rel_path, node.name, node.lineno, cx, nloc, tokens, params))
93
100
  self.generic_visit(node)
94
101
 
95
102
  visit_AsyncFunctionDef = visit_FunctionDef
@@ -104,14 +111,22 @@ def heuristic_complexity(source: str, rel_path: str) -> int:
104
111
 
105
112
 
106
113
  def analyze_complexity(source: str, rel_path: str, language: str) -> List[FuncScore]:
107
- """Return per-function complexity, preferring lizard's real analysis."""
114
+ """Return per-function complexity.
115
+
116
+ Python is analyzed with CPython's C-accelerated ``ast`` (much faster than a
117
+ pure-Python tokenizer), which matters a lot on Python-heavy monorepos. Other
118
+ languages use lizard; anything lizard cannot read falls back to a heuristic.
119
+ """
108
120
  if language in NON_CODE_LANGS:
109
121
  return []
122
+ if language == "Python":
123
+ scores = python_complexity(source, rel_path)
124
+ if scores:
125
+ return scores
126
+ # Empty may mean a syntax error (e.g. Python 2); let lizard try.
110
127
  scores = _lizard_scores(source, rel_path)
111
128
  if scores is not None:
112
129
  return scores
113
- if language == "Python":
114
- return python_complexity(source, rel_path)
115
130
  score = heuristic_complexity(source, rel_path)
116
131
  return [FuncScore(rel_path, "(file)", 1, score)] if score > 1 else []
117
132
 
@@ -18,7 +18,7 @@ from typing import Dict, Iterable, List, Optional
18
18
 
19
19
  from .complexity import FuncScore, analyze_complexity
20
20
  from .languages import lang_for
21
- from .vendored import is_vendored
21
+ from .vendored import is_vendored, is_vendored_path
22
22
 
23
23
  # Above this many files we default to process-based parallelism: complexity
24
24
  # parsing is CPU-bound and the GIL prevents threads from using multiple cores.
@@ -204,6 +204,7 @@ def scan(
204
204
  keep_contents: bool = False,
205
205
  cache_data: Optional[dict] = None,
206
206
  processes: Optional[bool] = None,
207
+ fast: bool = False,
207
208
  ) -> ScanResult:
208
209
  """Walk ``root`` and gather per-file line/language/complexity stats.
209
210
 
@@ -250,7 +251,7 @@ def scan(
250
251
  else:
251
252
  to_parse = todo
252
253
 
253
- processed = _parallel_process(to_parse, root_path, max_bytes, keep_contents, jobs, processes)
254
+ processed = _parallel_process(to_parse, root_path, max_bytes, keep_contents, jobs, processes, fast)
254
255
 
255
256
  for full, item in zip(to_parse, processed):
256
257
  if item is None:
@@ -303,14 +304,15 @@ def _absorb(result, stat, todos, scores, vendored, include_vendored, text, keep_
303
304
  result.contents[stat.path] = text
304
305
 
305
306
 
306
- def _parallel_process(to_parse, root_path, max_bytes, keep_contents, jobs, processes):
307
+ def _parallel_process(to_parse, root_path, max_bytes, keep_contents, jobs, processes, fast=False):
307
308
  """Analyze files in parallel. Uses processes for large, CPU-bound scans so
308
309
  complexity parsing actually spreads across cores (threads can't, due to the
309
310
  GIL); falls back to threads for small scans or when contents must be kept."""
310
311
  if not to_parse:
311
312
  return []
312
313
  worker = functools.partial(
313
- _process_file, root_path=root_path, max_bytes=max_bytes, keep_contents=keep_contents,
314
+ _process_file, root_path=root_path, max_bytes=max_bytes,
315
+ keep_contents=keep_contents, fast=fast,
314
316
  )
315
317
  use_proc = processes if processes is not None else (
316
318
  not keep_contents and len(to_parse) >= _PROCESS_THRESHOLD and (os.cpu_count() or 1) > 1
@@ -318,8 +320,10 @@ def _parallel_process(to_parse, root_path, max_bytes, keep_contents, jobs, proce
318
320
  if use_proc:
319
321
  try:
320
322
  workers = jobs or min(8, os.cpu_count() or 2)
323
+ # Larger chunks amortize per-task IPC/pickling overhead.
324
+ chunk = max(16, len(to_parse) // (workers * 4))
321
325
  with ProcessPoolExecutor(max_workers=workers) as pool:
322
- return list(pool.map(worker, to_parse, chunksize=16))
326
+ return list(pool.map(worker, to_parse, chunksize=chunk))
323
327
  except Exception:
324
328
  pass # fall back to threads if the process pool cannot start
325
329
  workers = jobs or min(32, (os.cpu_count() or 4) * 4)
@@ -327,9 +331,15 @@ def _parallel_process(to_parse, root_path, max_bytes, keep_contents, jobs, proce
327
331
  return list(pool.map(worker, to_parse))
328
332
 
329
333
 
330
- def _process_file(full: Path, root_path: Path, max_bytes: int, keep_contents: bool = False):
334
+ def _process_file(full: Path, root_path: Path, max_bytes: int,
335
+ keep_contents: bool = False, fast: bool = False):
331
336
  """Read and analyze one file. Returns (FileStat, todos, scores, text, vendored)
332
- or None. Top-level and picklable so it works with a process pool."""
337
+ or None. Top-level and picklable so it works with a process pool.
338
+
339
+ In ``fast`` mode we only count lines and detect language/vendoring — the
340
+ per-function complexity parse (the expensive step) is skipped, which is what
341
+ lets very large repositories be scanned in seconds.
342
+ """
333
343
  fn = full.name
334
344
  ext = fn.rsplit(".", 1)[-1] if "." in fn else ""
335
345
  language = lang_for(ext, fn)
@@ -344,10 +354,10 @@ def _process_file(full: Path, root_path: Path, max_bytes: int, keep_contents: bo
344
354
  rel = full.relative_to(root_path).as_posix()
345
355
  total, code, blank, comment = _count_lines(text, language)
346
356
  stat = FileStat(rel, language, total, code, blank, comment, size)
347
- vendored = is_vendored(rel, text)
357
+ vendored = is_vendored_path(rel) if fast else is_vendored(rel, text)
348
358
  todos: List[Todo] = []
349
359
  scores: List[FuncScore] = []
350
- if not vendored:
360
+ if not vendored and not fast:
351
361
  _collect_todos(text, rel, language, todos)
352
362
  _score_complexity(text, rel, language, scores)
353
363
  # Avoid shipping file text back across a process boundary unless needed.
@@ -7,6 +7,20 @@ from repoglance.cli import main
7
7
  from repoglance.config import load_config
8
8
 
9
9
 
10
+ def test_fast_mode_counts_without_complexity(tmp_path):
11
+ from repoglance.scanner import scan
12
+
13
+ (tmp_path / "a.py").write_text(
14
+ "def f(x):\n if x:\n return 1 # TODO\n return 0\n", encoding="utf-8"
15
+ )
16
+ fast = scan(tmp_path, fast=True)
17
+ full = scan(tmp_path, fast=False)
18
+ assert fast.total_code == full.total_code # counts still accurate
19
+ assert fast.func_scores == [] # complexity skipped
20
+ assert fast.todos == [] # todo scan skipped too
21
+ assert full.func_scores # full mode still analyzes
22
+
23
+
10
24
  def test_process_and_thread_modes_agree(tmp_path):
11
25
  from repoglance.scanner import scan
12
26
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes