python-du 0.1.0__py3-none-any.whl
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.
- python_du/__init__.py +0 -0
- python_du/cli.py +82 -0
- python_du/renderer.py +155 -0
- python_du/scanner.py +95 -0
- python_du-0.1.0.dist-info/METADATA +5 -0
- python_du-0.1.0.dist-info/RECORD +9 -0
- python_du-0.1.0.dist-info/WHEEL +5 -0
- python_du-0.1.0.dist-info/entry_points.txt +2 -0
- python_du-0.1.0.dist-info/top_level.txt +1 -0
python_du/__init__.py
ADDED
|
File without changes
|
python_du/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .renderer import RenderConfig, render_tree
|
|
8
|
+
from .scanner import scan
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str] | None = None) -> None:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="python-du",
|
|
14
|
+
description="Disk usage analyzer — scan a directory and print a visual size map.",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"path",
|
|
18
|
+
nargs="?",
|
|
19
|
+
default=".",
|
|
20
|
+
help="directory to scan (default: current directory)",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"-d",
|
|
24
|
+
"--max-depth",
|
|
25
|
+
type=int,
|
|
26
|
+
default=4,
|
|
27
|
+
help="maximum depth to display (default: 4)",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"-m",
|
|
31
|
+
"--min-percent",
|
|
32
|
+
type=float,
|
|
33
|
+
default=0.5,
|
|
34
|
+
help="hide entries smaller than this percentage (default: 0.5)",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--no-color",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="disable colored output",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--sort-alpha",
|
|
43
|
+
action="store_true",
|
|
44
|
+
help="sort entries alphabetically instead of by size",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--scan-depth",
|
|
48
|
+
type=int,
|
|
49
|
+
default=None,
|
|
50
|
+
help="maximum directory depth to scan (default: unlimited)",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"-L",
|
|
54
|
+
"--follow-symlinks",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="follow symbolic links",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
args = parser.parse_args(argv)
|
|
60
|
+
target = Path(args.path)
|
|
61
|
+
|
|
62
|
+
if not target.exists():
|
|
63
|
+
print(f"python-du: error: '{target}' does not exist", file=sys.stderr)
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
if not target.is_dir():
|
|
66
|
+
print(f"python-du: error: '{target}' is not a directory", file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
tree = scan(target, max_depth=args.scan_depth, follow_symlinks=args.follow_symlinks)
|
|
70
|
+
|
|
71
|
+
config = RenderConfig(
|
|
72
|
+
max_depth=args.max_depth,
|
|
73
|
+
min_percent=args.min_percent,
|
|
74
|
+
use_color=not args.no_color and sys.stdout.isatty(),
|
|
75
|
+
sort_by_size=not args.sort_alpha,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
print(render_tree(tree, config))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
main()
|
python_du/renderer.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from .scanner import DirNode
|
|
7
|
+
|
|
8
|
+
BLOCK_CHARS = " ░▒▓█"
|
|
9
|
+
BAR_COLORS = {
|
|
10
|
+
"red": "\033[91m",
|
|
11
|
+
"yellow": "\033[93m",
|
|
12
|
+
"green": "\033[92m",
|
|
13
|
+
"cyan": "\033[96m",
|
|
14
|
+
"blue": "\033[94m",
|
|
15
|
+
"magenta": "\033[95m",
|
|
16
|
+
"white": "\033[97m",
|
|
17
|
+
}
|
|
18
|
+
RESET = "\033[0m"
|
|
19
|
+
DIM = "\033[2m"
|
|
20
|
+
BOLD = "\033[1m"
|
|
21
|
+
|
|
22
|
+
PALETTE = ["blue", "cyan", "green", "yellow", "magenta", "red"]
|
|
23
|
+
|
|
24
|
+
TREE_PIPE = "│ "
|
|
25
|
+
TREE_TEE = "├── "
|
|
26
|
+
TREE_BEND = "└── "
|
|
27
|
+
TREE_BLANK = " "
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_size(size: int) -> str:
|
|
31
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
32
|
+
if abs(size) < 1024:
|
|
33
|
+
if unit == "B":
|
|
34
|
+
return f"{size} B"
|
|
35
|
+
return f"{size:.1f} {unit}"
|
|
36
|
+
size /= 1024 # type: ignore[assignment]
|
|
37
|
+
return f"{size:.1f} PB"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class RenderConfig:
|
|
42
|
+
max_depth: int = 4
|
|
43
|
+
min_percent: float = 0.5
|
|
44
|
+
bar_width: int | None = None # auto-detect from terminal
|
|
45
|
+
use_color: bool = True
|
|
46
|
+
sort_by_size: bool = True
|
|
47
|
+
show_percent: bool = True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render_tree(root: DirNode, config: RenderConfig | None = None) -> str:
|
|
51
|
+
cfg = config or RenderConfig()
|
|
52
|
+
term_width = shutil.get_terminal_size((80, 24)).columns
|
|
53
|
+
bar_width = cfg.bar_width or max(20, min(50, term_width - 60))
|
|
54
|
+
lines: list[str] = []
|
|
55
|
+
|
|
56
|
+
total = root.total_size or 1
|
|
57
|
+
|
|
58
|
+
header = f"{BOLD}{root.name}{RESET}" if cfg.use_color else root.name
|
|
59
|
+
lines.append(f"{header} [{format_size(root.total_size)}]")
|
|
60
|
+
lines.append("")
|
|
61
|
+
|
|
62
|
+
_render_node(root, lines, cfg, bar_width, total, prefix="", depth=0, color_idx=0)
|
|
63
|
+
|
|
64
|
+
lines.append("")
|
|
65
|
+
lines.append(_summary_line(root, cfg))
|
|
66
|
+
return "\n".join(lines)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _render_node(
|
|
70
|
+
node: DirNode,
|
|
71
|
+
lines: list[str],
|
|
72
|
+
cfg: RenderConfig,
|
|
73
|
+
bar_width: int,
|
|
74
|
+
root_total: int,
|
|
75
|
+
prefix: str,
|
|
76
|
+
depth: int,
|
|
77
|
+
color_idx: int,
|
|
78
|
+
) -> None:
|
|
79
|
+
children = node.children
|
|
80
|
+
if cfg.sort_by_size:
|
|
81
|
+
children = sorted(children, key=lambda c: c.total_size, reverse=True)
|
|
82
|
+
|
|
83
|
+
own_files_size = node.own_size
|
|
84
|
+
entries: list[tuple[str, int, DirNode | None]] = []
|
|
85
|
+
for child in children:
|
|
86
|
+
pct = (child.total_size / root_total * 100) if root_total else 0
|
|
87
|
+
if pct >= cfg.min_percent or depth < 1:
|
|
88
|
+
entries.append((child.name, child.total_size, child))
|
|
89
|
+
|
|
90
|
+
if own_files_size > 0:
|
|
91
|
+
pct = (own_files_size / root_total * 100) if root_total else 0
|
|
92
|
+
if pct >= cfg.min_percent or depth < 1:
|
|
93
|
+
entries.append(("<files>", own_files_size, None))
|
|
94
|
+
|
|
95
|
+
for i, (name, size, child_node) in enumerate(entries):
|
|
96
|
+
is_last = i == len(entries) - 1
|
|
97
|
+
connector = TREE_BEND if is_last else TREE_TEE
|
|
98
|
+
child_prefix = TREE_BLANK if is_last else TREE_PIPE
|
|
99
|
+
|
|
100
|
+
pct = (size / root_total * 100) if root_total else 0
|
|
101
|
+
bar = _make_bar(pct, bar_width, PALETTE[(color_idx + i) % len(PALETTE)], cfg.use_color)
|
|
102
|
+
|
|
103
|
+
size_str = format_size(size)
|
|
104
|
+
pct_str = f" ({pct:5.1f}%)" if cfg.show_percent else ""
|
|
105
|
+
|
|
106
|
+
if cfg.use_color and child_node is None:
|
|
107
|
+
name_str = f"{DIM}{name}{RESET}"
|
|
108
|
+
elif cfg.use_color:
|
|
109
|
+
name_str = name
|
|
110
|
+
else:
|
|
111
|
+
name_str = name
|
|
112
|
+
|
|
113
|
+
lines.append(f"{prefix}{connector}{name_str:<30s} {size_str:>10s}{pct_str} {bar}")
|
|
114
|
+
|
|
115
|
+
if child_node and depth < cfg.max_depth:
|
|
116
|
+
_render_node(
|
|
117
|
+
child_node,
|
|
118
|
+
lines,
|
|
119
|
+
cfg,
|
|
120
|
+
bar_width,
|
|
121
|
+
root_total,
|
|
122
|
+
prefix=prefix + child_prefix,
|
|
123
|
+
depth=depth + 1,
|
|
124
|
+
color_idx=color_idx + i,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _make_bar(pct: float, width: int, color: str, use_color: bool) -> str:
|
|
129
|
+
filled = int(pct / 100 * width)
|
|
130
|
+
filled = max(0, min(filled, width))
|
|
131
|
+
|
|
132
|
+
if use_color:
|
|
133
|
+
c = BAR_COLORS.get(color, "")
|
|
134
|
+
bar = f"{c}{'█' * filled}{RESET}{'░' * (width - filled)}"
|
|
135
|
+
else:
|
|
136
|
+
bar = "█" * filled + "░" * (width - filled)
|
|
137
|
+
return f"▕{bar}▏"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _summary_line(root: DirNode, cfg: RenderConfig) -> str:
|
|
141
|
+
total_files = _count_files(root)
|
|
142
|
+
total_dirs = _count_dirs(root)
|
|
143
|
+
size_str = format_size(root.total_size)
|
|
144
|
+
summary = f"Total: {size_str} | {total_dirs} directories, {total_files} files"
|
|
145
|
+
if cfg.use_color:
|
|
146
|
+
return f"{DIM}{summary}{RESET}"
|
|
147
|
+
return summary
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _count_files(node: DirNode) -> int:
|
|
151
|
+
return node.file_count + sum(_count_files(c) for c in node.children)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _count_dirs(node: DirNode) -> int:
|
|
155
|
+
return len(node.children) + sum(_count_dirs(c) for c in node.children)
|
python_du/scanner.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class DirNode:
|
|
10
|
+
"""Represents a directory and its computed disk usage."""
|
|
11
|
+
|
|
12
|
+
path: Path
|
|
13
|
+
own_size: int = 0 # size of files directly in this dir
|
|
14
|
+
total_size: int = 0 # recursive total including children
|
|
15
|
+
children: list[DirNode] = field(default_factory=list)
|
|
16
|
+
file_count: int = 0
|
|
17
|
+
error: str | None = None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def name(self) -> str:
|
|
21
|
+
return self.path.name or str(self.path)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def scan(root: Path, *, max_depth: int | None = None, follow_symlinks: bool = False) -> DirNode:
|
|
25
|
+
"""Walk *root* and return a tree of DirNode objects with computed sizes."""
|
|
26
|
+
return _scan_dir(root.resolve(), depth=0, max_depth=max_depth, follow_symlinks=follow_symlinks)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _scan_dir(
|
|
30
|
+
path: Path,
|
|
31
|
+
*,
|
|
32
|
+
depth: int,
|
|
33
|
+
max_depth: int | None,
|
|
34
|
+
follow_symlinks: bool,
|
|
35
|
+
) -> DirNode:
|
|
36
|
+
node = DirNode(path=path)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
entries = sorted(os.scandir(path), key=lambda e: e.name)
|
|
40
|
+
except PermissionError:
|
|
41
|
+
node.error = "permission denied"
|
|
42
|
+
return node
|
|
43
|
+
except OSError as exc:
|
|
44
|
+
node.error = str(exc)
|
|
45
|
+
return node
|
|
46
|
+
|
|
47
|
+
for entry in entries:
|
|
48
|
+
try:
|
|
49
|
+
if entry.is_symlink() and not follow_symlinks:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
if entry.is_file(follow_symlinks=follow_symlinks):
|
|
53
|
+
try:
|
|
54
|
+
node.own_size += entry.stat(follow_symlinks=follow_symlinks).st_size
|
|
55
|
+
node.file_count += 1
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
elif entry.is_dir(follow_symlinks=follow_symlinks):
|
|
60
|
+
if max_depth is not None and depth >= max_depth:
|
|
61
|
+
child = _shallow_size(Path(entry.path))
|
|
62
|
+
else:
|
|
63
|
+
child = _scan_dir(
|
|
64
|
+
Path(entry.path),
|
|
65
|
+
depth=depth + 1,
|
|
66
|
+
max_depth=max_depth,
|
|
67
|
+
follow_symlinks=follow_symlinks,
|
|
68
|
+
)
|
|
69
|
+
node.children.append(child)
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
node.total_size = node.own_size + sum(c.total_size for c in node.children)
|
|
74
|
+
return node
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _shallow_size(path: Path) -> DirNode:
|
|
78
|
+
"""Get total size of a directory without building a child tree."""
|
|
79
|
+
node = DirNode(path=path)
|
|
80
|
+
total = 0
|
|
81
|
+
fcount = 0
|
|
82
|
+
try:
|
|
83
|
+
for dirpath, _dirnames, filenames in os.walk(path):
|
|
84
|
+
for fname in filenames:
|
|
85
|
+
try:
|
|
86
|
+
total += os.path.getsize(os.path.join(dirpath, fname))
|
|
87
|
+
fcount += 1
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
except PermissionError:
|
|
91
|
+
node.error = "permission denied"
|
|
92
|
+
node.own_size = total
|
|
93
|
+
node.total_size = total
|
|
94
|
+
node.file_count = fcount
|
|
95
|
+
return node
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
python_du/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
python_du/cli.py,sha256=0dY0IorPKHsqG7mPhF9ElXEqnZeX868HmTij6v7APZ0,2144
|
|
3
|
+
python_du/renderer.py,sha256=grBwc1TCVRaTRG2NFU4I-9yyIYasV7Q6GZDhoKL5HQ0,4589
|
|
4
|
+
python_du/scanner.py,sha256=nmfI8bL9_OzOST44_pN-BlZ3XkNWBQ5aDRNm4DMaPS8,2882
|
|
5
|
+
python_du-0.1.0.dist-info/METADATA,sha256=d06m7hakzgKnzo3TyLgw2dy0eWe6R3CF5ZglaH-D8sM,148
|
|
6
|
+
python_du-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
7
|
+
python_du-0.1.0.dist-info/entry_points.txt,sha256=m8Km0ZaanWCYWdBPcLTC3XREnJtG-CwE95yKdAli9kw,49
|
|
8
|
+
python_du-0.1.0.dist-info/top_level.txt,sha256=PLwVAljOX1Zv_ACc2YSYHmL7MhJ_szkcvz7XyY2StKc,10
|
|
9
|
+
python_du-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
python_du
|