python-du 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.
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-du
3
+ Version: 0.1.0
4
+ Summary: A disk usage analyzer that prints a visual map of folder sizes
5
+ Requires-Python: >=3.9
@@ -0,0 +1,97 @@
1
+ # python-du
2
+
3
+ A disk usage analyzer that scans a directory and prints a visual tree map of folder sizes — right in your terminal.
4
+
5
+ ## Example
6
+
7
+ ```
8
+ $ python-du /usr -d 2 -m 2
9
+ usr [60.2 GB]
10
+
11
+ ├── share 27.4 GB ( 45.6%) ▕█████████░░░░░░░░░░░▏
12
+ │ └── ollama 22.6 GB ( 37.5%) ▕███████░░░░░░░░░░░░░▏
13
+ │ └── .ollama 22.6 GB ( 37.5%) ▕███████░░░░░░░░░░░░░▏
14
+ ├── lib 26.4 GB ( 43.8%) ▕████████░░░░░░░░░░░░▏
15
+ │ ├── x86_64-linux-gnu 11.7 GB ( 19.3%) ▕███░░░░░░░░░░░░░░░░░▏
16
+ │ ├── arm-none-eabi 2.7 GB ( 4.5%) ▕░░░░░░░░░░░░░░░░░░░░▏
17
+ │ ├── jvm 2.1 GB ( 3.5%) ▕░░░░░░░░░░░░░░░░░░░░▏
18
+ │ ├── dotnet 1.5 GB ( 2.5%) ▕░░░░░░░░░░░░░░░░░░░░▏
19
+ │ └── apache-netbeans 1.3 GB ( 2.2%) ▕░░░░░░░░░░░░░░░░░░░░▏
20
+ ├── local 3.8 GB ( 6.3%) ▕█░░░░░░░░░░░░░░░░░░░▏
21
+ │ └── lib 3.2 GB ( 5.3%) ▕█░░░░░░░░░░░░░░░░░░░▏
22
+ ├── bin 1.4 GB ( 2.4%) ▕░░░░░░░░░░░░░░░░░░░░▏
23
+ │ └── <files> 1.4 GB ( 2.4%) ▕░░░░░░░░░░░░░░░░░░░░▏
24
+ └── <files> 33.7 KB ( 0.0%) ▕░░░░░░░░░░░░░░░░░░░░▏
25
+
26
+ Total: 60.2 GB | 46966 directories, 390046 files
27
+ ```
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install -e .
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ```bash
38
+ python-du [path] [options]
39
+ ```
40
+
41
+ If no path is given, the current directory is scanned.
42
+
43
+ ### Options
44
+
45
+ | Flag | Description |
46
+ |------|-------------|
47
+ | `-d`, `--max-depth N` | Maximum tree depth to display (default: 4) |
48
+ | `-m`, `--min-percent N` | Hide entries smaller than N% of total (default: 0.5) |
49
+ | `--no-color` | Disable colored output |
50
+ | `--sort-alpha` | Sort alphabetically instead of by size |
51
+ | `--scan-depth N` | Limit how deep the filesystem scan goes (default: unlimited) |
52
+ | `-L`, `--follow-symlinks` | Follow symbolic links |
53
+
54
+ ### Examples
55
+
56
+ Scan the current directory with defaults:
57
+
58
+ ```bash
59
+ python-du
60
+ ```
61
+
62
+ Scan `/var/log` showing only entries above 5%, two levels deep:
63
+
64
+ ```bash
65
+ python-du /var/log -d 2 -m 5
66
+ ```
67
+
68
+ Pipe to a file (automatically disables color):
69
+
70
+ ```bash
71
+ python-du /home > usage-report.txt
72
+ ```
73
+
74
+ ## Publishing to PyPI
75
+
76
+ ```
77
+ pip install build twine
78
+ python -m build
79
+ twine upload dist/*
80
+ ```
81
+
82
+ When prompted, use `__token__` as the username and your PyPI API token as the password.
83
+
84
+ To skip the prompt, create a `~/.pypirc` file:
85
+
86
+ ```ini
87
+ [pypi]
88
+ username = __token__
89
+ password = pypi-YOUR-TOKEN-HERE
90
+ ```
91
+
92
+ > **Note:** Bump the `version` in `pyproject.toml` before each upload — PyPI rejects duplicate version numbers.
93
+
94
+ ## Requirements
95
+
96
+ - Python >= 3.9
97
+ - No external dependencies
@@ -0,0 +1,12 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "python-du"
7
+ version = "0.1.0"
8
+ description = "A disk usage analyzer that prints a visual map of folder sizes"
9
+ requires-python = ">=3.9"
10
+
11
+ [project.scripts]
12
+ python-du = "python_du.cli:main"
File without changes
@@ -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()
@@ -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)
@@ -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,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-du
3
+ Version: 0.1.0
4
+ Summary: A disk usage analyzer that prints a visual map of folder sizes
5
+ Requires-Python: >=3.9
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ python_du/__init__.py
4
+ python_du/cli.py
5
+ python_du/renderer.py
6
+ python_du/scanner.py
7
+ python_du.egg-info/PKG-INFO
8
+ python_du.egg-info/SOURCES.txt
9
+ python_du.egg-info/dependency_links.txt
10
+ python_du.egg-info/entry_points.txt
11
+ python_du.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ python-du = python_du.cli:main
@@ -0,0 +1 @@
1
+ python_du
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+