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 +21 -0
- locdu-0.1.0/PKG-INFO +97 -0
- locdu-0.1.0/README.md +76 -0
- locdu-0.1.0/pyproject.toml +37 -0
- locdu-0.1.0/setup.cfg +4 -0
- locdu-0.1.0/src/locdu/__init__.py +3 -0
- locdu-0.1.0/src/locdu/__main__.py +5 -0
- locdu-0.1.0/src/locdu/cli.py +91 -0
- locdu-0.1.0/src/locdu/output.py +61 -0
- locdu-0.1.0/src/locdu/scan.py +233 -0
- locdu-0.1.0/src/locdu/tui.py +356 -0
- locdu-0.1.0/src/locdu.egg-info/PKG-INFO +97 -0
- locdu-0.1.0/src/locdu.egg-info/SOURCES.txt +15 -0
- locdu-0.1.0/src/locdu.egg-info/dependency_links.txt +1 -0
- locdu-0.1.0/src/locdu.egg-info/entry_points.txt +2 -0
- locdu-0.1.0/src/locdu.egg-info/top_level.txt +1 -0
- locdu-0.1.0/tests/test_locdu.py +306 -0
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,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()]
|