locdu 0.1.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.
locdu-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kevin Müller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
locdu-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: locdu
3
+ Version: 0.1.0
4
+ Summary: ncdu for lines of code: browse where the lines in a codebase are
5
+ Author: Kevin Müller
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kevmiota/locdu
8
+ Project-URL: Issues, https://github.com/kevmiota/locdu/issues
9
+ Keywords: loc,lines of code,cloc,ncdu,tui,curses
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console :: Curses
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: POSIX
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development
16
+ Classifier: Topic :: Utilities
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # locdu
23
+
24
+ ncdu for lines of code. Scans a directory, counts the lines of every text file and lets
25
+ you drill into the tree interactively, largest first. Pure Python standard library, no
26
+ dependencies; Linux and macOS (needs curses).
27
+
28
+ ```
29
+ locdu [DIR] [-x GLOB]... [-m lines|code|files] [-p [-d N] [-n N]] [--ncdu FILE]
30
+ ```
31
+
32
+ ## Install
33
+
34
+ With [pipx](https://pipx.pypa.io) (Ubuntu: `sudo apt install pipx && pipx ensurepath`):
35
+
36
+ ```sh
37
+ pipx install locdu # from PyPI
38
+ pipx install git+https://github.com/kevmiota/locdu # latest main
39
+ ```
40
+
41
+ `uv tool install locdu` works too.
42
+
43
+ As a Debian/Ubuntu package, from the
44
+ [releases page](https://github.com/kevmiota/locdu/releases):
45
+
46
+ ```sh
47
+ sudo apt install ./locdu_0.1.0_all.deb
48
+ ```
49
+
50
+ Without pip, from a clone (needs only `python3`):
51
+
52
+ ```sh
53
+ ./install.sh # copies to ~/.local/share/locdu, launcher in ~/.local/bin
54
+ ./install.sh --uninstall
55
+ ```
56
+
57
+ ## What gets counted
58
+
59
+ - In a git repo: the files git knows about, tracked and untracked, minus `.gitignore`,
60
+ including nested repos and submodules. Elsewhere, in a folder git ignores, or with
61
+ `--no-git`: everything except `node_modules`, `dist`, `build`, `.venv`, `vendor` and
62
+ similar below DIR.
63
+ - Skipped by default: lockfiles, `*.min.js`, `*.min.css`, `*.map`
64
+ (`--no-default-excludes` counts them). Add your own with `-x`, repeatable:
65
+ a name or glob matches anywhere (`-x docs -x '*.md'`), a path matches relative
66
+ to DIR (`-x src/generated`).
67
+ - Binary files (a NUL byte in the first 8 KB), text files over 50 MB and symlinks
68
+ are skipped.
69
+ - `lines` = all lines, `code` = non-blank lines. Comments count as code; use
70
+ `scc` or `tokei` if you need comments separated.
71
+
72
+ ## Keys
73
+
74
+ | Key | |
75
+ |---|---|
76
+ | arrows, `hjkl`, enter, backspace | navigate |
77
+ | `m` | metric: lines, code lines, files |
78
+ | `s` / `n` | sort by count / name, again to reverse |
79
+ | `t` | file types in the current directory |
80
+ | `e` | open the file in `$EDITOR`, recounted afterwards |
81
+ | `d` / `u` | hide from the view / undo; nothing on disk is touched |
82
+ | `r` | rescan |
83
+ | `?` / `q` | help / quit |
84
+
85
+ ## Without the UI
86
+
87
+ ```sh
88
+ locdu -p -d 2 -n 10 # tree, two levels, top 10 per directory
89
+ locdu | less # prints automatically when stdout is not a terminal
90
+ locdu --ncdu - | ncdu -f - # hand the counts to ncdu (shown as sizes)
91
+ ```
92
+
93
+ ## Tests
94
+
95
+ ```sh
96
+ python3 -m unittest discover -s tests -v
97
+ ```
locdu-0.1.0/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # locdu
2
+
3
+ ncdu for lines of code. Scans a directory, counts the lines of every text file and lets
4
+ you drill into the tree interactively, largest first. Pure Python standard library, no
5
+ dependencies; Linux and macOS (needs curses).
6
+
7
+ ```
8
+ locdu [DIR] [-x GLOB]... [-m lines|code|files] [-p [-d N] [-n N]] [--ncdu FILE]
9
+ ```
10
+
11
+ ## Install
12
+
13
+ With [pipx](https://pipx.pypa.io) (Ubuntu: `sudo apt install pipx && pipx ensurepath`):
14
+
15
+ ```sh
16
+ pipx install locdu # from PyPI
17
+ pipx install git+https://github.com/kevmiota/locdu # latest main
18
+ ```
19
+
20
+ `uv tool install locdu` works too.
21
+
22
+ As a Debian/Ubuntu package, from the
23
+ [releases page](https://github.com/kevmiota/locdu/releases):
24
+
25
+ ```sh
26
+ sudo apt install ./locdu_0.1.0_all.deb
27
+ ```
28
+
29
+ Without pip, from a clone (needs only `python3`):
30
+
31
+ ```sh
32
+ ./install.sh # copies to ~/.local/share/locdu, launcher in ~/.local/bin
33
+ ./install.sh --uninstall
34
+ ```
35
+
36
+ ## What gets counted
37
+
38
+ - In a git repo: the files git knows about, tracked and untracked, minus `.gitignore`,
39
+ including nested repos and submodules. Elsewhere, in a folder git ignores, or with
40
+ `--no-git`: everything except `node_modules`, `dist`, `build`, `.venv`, `vendor` and
41
+ similar below DIR.
42
+ - Skipped by default: lockfiles, `*.min.js`, `*.min.css`, `*.map`
43
+ (`--no-default-excludes` counts them). Add your own with `-x`, repeatable:
44
+ a name or glob matches anywhere (`-x docs -x '*.md'`), a path matches relative
45
+ to DIR (`-x src/generated`).
46
+ - Binary files (a NUL byte in the first 8 KB), text files over 50 MB and symlinks
47
+ are skipped.
48
+ - `lines` = all lines, `code` = non-blank lines. Comments count as code; use
49
+ `scc` or `tokei` if you need comments separated.
50
+
51
+ ## Keys
52
+
53
+ | Key | |
54
+ |---|---|
55
+ | arrows, `hjkl`, enter, backspace | navigate |
56
+ | `m` | metric: lines, code lines, files |
57
+ | `s` / `n` | sort by count / name, again to reverse |
58
+ | `t` | file types in the current directory |
59
+ | `e` | open the file in `$EDITOR`, recounted afterwards |
60
+ | `d` / `u` | hide from the view / undo; nothing on disk is touched |
61
+ | `r` | rescan |
62
+ | `?` / `q` | help / quit |
63
+
64
+ ## Without the UI
65
+
66
+ ```sh
67
+ locdu -p -d 2 -n 10 # tree, two levels, top 10 per directory
68
+ locdu | less # prints automatically when stdout is not a terminal
69
+ locdu --ncdu - | ncdu -f - # hand the counts to ncdu (shown as sizes)
70
+ ```
71
+
72
+ ## Tests
73
+
74
+ ```sh
75
+ python3 -m unittest discover -s tests -v
76
+ ```
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "locdu"
7
+ dynamic = ["version"]
8
+ description = "ncdu for lines of code: browse where the lines in a codebase are"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Kevin Müller" }]
13
+ requires-python = ">=3.10"
14
+ dependencies = []
15
+ keywords = ["loc", "lines of code", "cloc", "ncdu", "tui", "curses"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console :: Curses",
19
+ "Intended Audience :: Developers",
20
+ "Operating System :: POSIX",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Software Development",
23
+ "Topic :: Utilities",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/kevmiota/locdu"
28
+ Issues = "https://github.com/kevmiota/locdu/issues"
29
+
30
+ [project.scripts]
31
+ locdu = "locdu.cli:main"
32
+
33
+ [tool.setuptools.dynamic]
34
+ version = { attr = "locdu.__version__" }
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
locdu-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """locdu: ncdu for lines of code."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,91 @@
1
+ """Command line entry point."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+ from . import __version__
10
+ from .output import fmt, render_text, to_ncdu
11
+ from .scan import DEFAULT_EXCLUDES, METRICS, scan
12
+
13
+ EPILOG = """\
14
+ examples:
15
+ locdu browse the current directory
16
+ locdu ~/work/client/repo -x docs -x '*.md'
17
+ locdu -p -d 2 -n 10 print the top 10 per directory, two levels deep
18
+ locdu --ncdu - | ncdu -f - hand the counts to ncdu
19
+
20
+ In a git repo, the file list comes from git (tracked + untracked, minus .gitignore).
21
+ Lockfiles, *.min.js, *.min.css and *.map are skipped unless --no-default-excludes is set.
22
+ """
23
+
24
+
25
+ def _progress(done: int, total: int) -> None:
26
+ if done < total:
27
+ sys.stderr.write(f"\rscanning {fmt(done)}/{fmt(total)} files")
28
+ else:
29
+ sys.stderr.write("\r\033[K")
30
+ sys.stderr.flush()
31
+
32
+
33
+ def build_parser() -> argparse.ArgumentParser:
34
+ ap = argparse.ArgumentParser(
35
+ prog="locdu",
36
+ description="ncdu for lines of code: see where the lines in a codebase are.",
37
+ epilog=EPILOG,
38
+ formatter_class=argparse.RawDescriptionHelpFormatter,
39
+ )
40
+ ap.add_argument("dir", nargs="?", default=".", help="directory to scan (default: .)")
41
+ ap.add_argument("-x", "--exclude", action="append", default=[], metavar="GLOB",
42
+ help="skip files/dirs matching GLOB (a name anywhere, or a path relative to DIR); repeatable")
43
+ ap.add_argument("--no-default-excludes", action="store_true",
44
+ help="also count lockfiles, *.min.js, *.min.css and *.map")
45
+ ap.add_argument("--no-git", action="store_true",
46
+ help="walk the directory instead of asking git for the file list")
47
+ ap.add_argument("-m", "--metric", choices=METRICS, default="lines",
48
+ help="lines (all), code (non-blank) or files (default: lines)")
49
+ ap.add_argument("-p", "--print", dest="print_tree", action="store_true",
50
+ help="print a tree instead of the interactive view (default when output is not a terminal)")
51
+ ap.add_argument("-d", "--depth", type=int, default=1, metavar="N", help="with --print: levels to show (default: 1)")
52
+ ap.add_argument("-n", "--top", type=int, default=None, metavar="N", help="with --print: at most N entries per directory")
53
+ ap.add_argument("--ncdu", metavar="FILE", help="write ncdu's JSON format to FILE (- for stdout), for `ncdu -f`")
54
+ ap.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
55
+ return ap
56
+
57
+
58
+ def main(argv: list[str] | None = None) -> int:
59
+ args = build_parser().parse_args(argv)
60
+ patterns = [p.rstrip("/") for p in args.exclude if p.rstrip("/")]
61
+ if not args.no_default_excludes:
62
+ patterns += DEFAULT_EXCLUDES
63
+ interactive = not (args.print_tree or args.ncdu) and sys.stdin.isatty() and sys.stdout.isatty()
64
+
65
+ def do_scan(show_progress: bool):
66
+ progress = _progress if show_progress and sys.stderr.isatty() else None
67
+ return scan(args.dir, patterns, use_git=not args.no_git, progress=progress)
68
+
69
+ try:
70
+ result = do_scan(show_progress=True)
71
+ if args.ncdu:
72
+ data = to_ncdu(result.root, args.metric)
73
+ if args.ncdu == "-":
74
+ json.dump(data, sys.stdout)
75
+ else:
76
+ with open(args.ncdu, "w", encoding="utf-8") as f:
77
+ json.dump(data, f)
78
+ elif interactive:
79
+ from .tui import run
80
+
81
+ run(result, lambda: do_scan(show_progress=False), args.metric)
82
+ else:
83
+ print(render_text(result.root, args.metric, max(1, args.depth), args.top))
84
+ except NotADirectoryError as e:
85
+ print(f"locdu: not a directory: {e}", file=sys.stderr)
86
+ return 2
87
+ except KeyboardInterrupt:
88
+ return 130
89
+ except BrokenPipeError:
90
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
91
+ return 0
@@ -0,0 +1,61 @@
1
+ """Non-interactive output: a text tree and ncdu's JSON export format."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import time
6
+
7
+ from . import __version__
8
+ from .scan import Node
9
+
10
+
11
+ def fmt(n: int) -> str:
12
+ return f"{n:,}".replace(",", "'")
13
+
14
+
15
+ def printable(text: str) -> str:
16
+ """Show bytes of non-UTF-8 file names as \\xNN instead of failing to encode them."""
17
+ return os.fsencode(text).decode("utf-8", "backslashreplace")
18
+
19
+
20
+ def sorted_children(node: Node, metric: str) -> list[Node]:
21
+ return sorted(node.children.values(), key=lambda n: (-n.value(metric), n.name.lower()))
22
+
23
+
24
+ def render_text(root: Node, metric: str = "lines", depth: int = 1, top: int | None = None) -> str:
25
+ total = root.value(metric)
26
+ width = len(fmt(total))
27
+ out = [f"{fmt(total)} {metric} in {fmt(root.files)} files {root.name}"]
28
+
29
+ def walk(node: Node, level: int) -> None:
30
+ kids = sorted_children(node, metric)
31
+ shown = kids[:top] if top else kids
32
+ indent = " " * level
33
+ for kid in shown:
34
+ value = kid.value(metric)
35
+ pct = 100 * value / total if total else 0.0
36
+ suffix = "/" if kid.is_dir else ""
37
+ out.append(f"{fmt(value):>{width}} {pct:5.1f}% {indent}{kid.name}{suffix}")
38
+ if kid.is_dir and level + 1 < depth:
39
+ walk(kid, level + 1)
40
+ if len(kids) > len(shown):
41
+ out.append(f"{'':>{width}} {indent}... {len(kids) - len(shown)} more")
42
+
43
+ walk(root, 0)
44
+ return printable("\n".join(out))
45
+
46
+
47
+ def to_ncdu(root: Node, metric: str = "lines") -> list:
48
+ """ncdu export format; the metric goes into the size fields, so `ncdu -f` shows it as size."""
49
+
50
+ def emit(node: Node, name: str) -> list:
51
+ entries: list = [{"name": name}]
52
+ for kid in sorted(node.children.values(), key=lambda n: n.name):
53
+ if kid.is_dir:
54
+ entries.append(emit(kid, kid.name))
55
+ else:
56
+ value = kid.value(metric)
57
+ entries.append({"name": kid.name, "asize": value, "dsize": value})
58
+ return entries
59
+
60
+ meta = {"progname": "locdu", "progver": __version__, "timestamp": int(time.time())}
61
+ return [1, 0, meta, emit(root, root.name)]
@@ -0,0 +1,233 @@
1
+ """Find files, count their lines and build the directory tree."""
2
+ from __future__ import annotations
3
+
4
+ import fnmatch
5
+ import os
6
+ import re
7
+ import stat
8
+ import subprocess
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from dataclasses import dataclass
11
+ from typing import Callable, Iterable
12
+
13
+ METRICS = ("lines", "code", "files")
14
+
15
+ # Only used without git; in a git repo, .gitignore decides.
16
+ SKIP_DIRS = {
17
+ ".git", ".hg", ".svn", "node_modules", ".next", ".nuxt", ".turbo", ".cache",
18
+ "dist", "build", "coverage", ".venv", "venv", "__pycache__", "vendor",
19
+ }
20
+
21
+ DEFAULT_EXCLUDES = [
22
+ "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb",
23
+ "composer.lock", "Cargo.lock", "poetry.lock", "uv.lock", "Gemfile.lock", "go.sum",
24
+ "*.min.js", "*.min.css", "*.map",
25
+ ]
26
+
27
+ SNIFF_BYTES = 8192
28
+ MAX_BYTES = 50 * 2**20 # bigger text files are data dumps, not code
29
+
30
+
31
+ class Node:
32
+ """A file or directory. Directory counts are the sums of their children."""
33
+
34
+ __slots__ = ("name", "parent", "children", "lines", "code", "files")
35
+
36
+ def __init__(self, name: str, parent: Node | None = None, is_dir: bool = False):
37
+ self.name = name
38
+ self.parent = parent
39
+ self.children: dict[str, Node] | None = {} if is_dir else None
40
+ self.lines = 0
41
+ self.code = 0
42
+ self.files = 0
43
+
44
+ @property
45
+ def is_dir(self) -> bool:
46
+ return self.children is not None
47
+
48
+ def value(self, metric: str) -> int:
49
+ return getattr(self, metric)
50
+
51
+ def relpath(self) -> str:
52
+ parts = []
53
+ node = self
54
+ while node.parent is not None:
55
+ parts.append(node.name)
56
+ node = node.parent
57
+ return "/".join(reversed(parts))
58
+
59
+ def find(self, relpath: str) -> Node | None:
60
+ node = self
61
+ for part in filter(None, relpath.split("/")):
62
+ if not node.is_dir or part not in node.children:
63
+ return None
64
+ node = node.children[part]
65
+ return node
66
+
67
+ def _add_to_ancestors(self, lines: int, code: int, files: int) -> None:
68
+ node = self.parent
69
+ while node is not None:
70
+ node.lines += lines
71
+ node.code += code
72
+ node.files += files
73
+ node = node.parent
74
+
75
+ def detach(self) -> None:
76
+ """Take this node out of the tree (view only) and update the totals above it."""
77
+ del self.parent.children[self.name]
78
+ self._add_to_ancestors(-self.lines, -self.code, -self.files)
79
+
80
+ def reattach(self) -> None:
81
+ self.parent.children[self.name] = self
82
+ self._add_to_ancestors(self.lines, self.code, self.files)
83
+
84
+ def set_counts(self, lines: int, code: int) -> None:
85
+ """Replace a file's counts, e.g. after it was edited."""
86
+ delta_lines, delta_code = lines - self.lines, code - self.code
87
+ self.lines, self.code = lines, code
88
+ self._add_to_ancestors(delta_lines, delta_code, 0)
89
+
90
+
91
+ @dataclass
92
+ class ScanResult:
93
+ root: Node
94
+ skipped: int # binary or larger than MAX_BYTES
95
+
96
+
97
+ def count_lines(path: str) -> tuple[int, int] | None:
98
+ """(lines, non-blank lines) of a text file; None if it is binary, too large or unreadable."""
99
+ try:
100
+ with open(path, "rb") as f:
101
+ data = f.read(SNIFF_BYTES)
102
+ if b"\0" in data or os.fstat(f.fileno()).st_size > MAX_BYTES:
103
+ return None
104
+ data += f.read()
105
+ except OSError:
106
+ return None
107
+ if not data:
108
+ return 0, 0
109
+ lines = data.count(b"\n") + (not data.endswith(b"\n"))
110
+ code = sum(1 for line in data.split(b"\n") if line.strip())
111
+ return lines, code
112
+
113
+
114
+ def exclude_matcher(patterns: Iterable[str]) -> Callable[[str], bool]:
115
+ """Test for relative paths: a pattern matches any single path component, or a path prefix."""
116
+ patterns = list(patterns)
117
+ if not patterns:
118
+ return lambda relpath: False
119
+ regex = re.compile("|".join(fnmatch.translate(p) for p in patterns))
120
+
121
+ def is_excluded(relpath: str) -> bool:
122
+ parts = relpath.split("/")
123
+ candidates = parts + ["/".join(parts[:i]) for i in range(2, len(parts) + 1)]
124
+ return any(regex.match(c) for c in candidates)
125
+
126
+ return is_excluded
127
+
128
+
129
+ def list_files(root: str, use_git: bool = True) -> list[str]:
130
+ """Candidate file paths relative to root."""
131
+ if use_git:
132
+ try:
133
+ out = subprocess.run(
134
+ ["git", "-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
135
+ capture_output=True,
136
+ check=True,
137
+ ).stdout
138
+ except (OSError, subprocess.CalledProcessError):
139
+ pass
140
+ else:
141
+ paths = []
142
+ for p in sorted({os.fsdecode(p).rstrip("/") for p in out.split(b"\0")} - {"", "."}):
143
+ full = os.path.join(root, p)
144
+ if os.path.isdir(full) and not os.path.islink(full):
145
+ # A submodule or nested repo: git lists only the directory, not its files.
146
+ paths.extend(f"{p}/{q}" for q in list_files(full))
147
+ else:
148
+ paths.append(p)
149
+ if paths:
150
+ return paths
151
+ # Not a repo, or git lists nothing here (e.g. a folder in .gitignore): walk it.
152
+
153
+ paths = []
154
+ for dirpath, dirnames, filenames in os.walk(root):
155
+ dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
156
+ rel = os.path.relpath(dirpath, root)
157
+ prefix = "" if rel == "." else rel.replace(os.sep, "/") + "/"
158
+ paths.extend(prefix + f for f in sorted(filenames))
159
+ return paths
160
+
161
+
162
+ def _count_regular(path: str) -> tuple[int, int] | None | bool:
163
+ """False for anything that is not a regular file (symlinks, deleted files, submodules)."""
164
+ try:
165
+ if not stat.S_ISREG(os.lstat(path).st_mode):
166
+ return False
167
+ except OSError:
168
+ return False
169
+ return count_lines(path)
170
+
171
+
172
+ def _insert(root: Node, relpath: str, lines: int, code: int) -> None:
173
+ *dirs, name = relpath.split("/")
174
+ node = root
175
+ for d in dirs:
176
+ child = node.children.get(d)
177
+ if child is None:
178
+ child = node.children[d] = Node(d, node, is_dir=True)
179
+ node = child
180
+ leaf = node.children[name] = Node(name, node)
181
+ leaf.lines, leaf.code, leaf.files = lines, code, 1
182
+ leaf._add_to_ancestors(lines, code, 1)
183
+
184
+
185
+ def scan(
186
+ root_dir: str,
187
+ excludes: Iterable[str] = (),
188
+ use_git: bool = True,
189
+ progress: Callable[[int, int], None] | None = None,
190
+ ) -> ScanResult:
191
+ root_dir = os.path.abspath(root_dir)
192
+ if not os.path.isdir(root_dir):
193
+ raise NotADirectoryError(root_dir)
194
+ is_excluded = exclude_matcher(excludes)
195
+ kept = [p for p in list_files(root_dir, use_git) if not is_excluded(p)]
196
+
197
+ root = Node(root_dir, is_dir=True)
198
+ skipped = 0
199
+ workers = min(32, (os.cpu_count() or 1) * 4)
200
+ with ThreadPoolExecutor(max_workers=workers) as pool:
201
+ counts = pool.map(lambda p: _count_regular(os.path.join(root_dir, p)), kept)
202
+ for i, (relpath, result) in enumerate(zip(kept, counts), 1):
203
+ if result is None:
204
+ skipped += 1
205
+ elif result is not False:
206
+ _insert(root, relpath, *result)
207
+ if progress and i % 500 == 0:
208
+ progress(i, len(kept))
209
+ if progress:
210
+ progress(len(kept), len(kept))
211
+ return ScanResult(root, skipped)
212
+
213
+
214
+ def file_type(name: str) -> str:
215
+ """Extension in lower case, or the whole name for files without one (Dockerfile, .gitignore)."""
216
+ ext = os.path.splitext(name)[1]
217
+ return ext.lower() if ext else name
218
+
219
+
220
+ def by_file_type(node: Node) -> list[tuple[str, int, int, int]]:
221
+ """(type, lines, code, files) for every file type below node."""
222
+ stats: dict[str, list[int]] = {}
223
+ stack = [node]
224
+ while stack:
225
+ n = stack.pop()
226
+ if n.is_dir:
227
+ stack.extend(n.children.values())
228
+ else:
229
+ s = stats.setdefault(file_type(n.name), [0, 0, 0])
230
+ s[0] += n.lines
231
+ s[1] += n.code
232
+ s[2] += 1
233
+ return [(k, *v) for k, v in stats.items()]