startype23 1.0.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.
- startype23/__init__.py +3 -0
- startype23/analyzer.py +131 -0
- startype23/charts.py +449 -0
- startype23/cli.py +120 -0
- startype23/extensions.py +551 -0
- startype23-1.0.0.dist-info/METADATA +88 -0
- startype23-1.0.0.dist-info/RECORD +9 -0
- startype23-1.0.0.dist-info/WHEEL +4 -0
- startype23-1.0.0.dist-info/entry_points.txt +2 -0
startype23/__init__.py
ADDED
startype23/analyzer.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Directory traversal and file extension aggregation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# Default directory names to skip during traversal.
|
|
9
|
+
DEFAULT_EXCLUDE_DIRS: set[str] = {
|
|
10
|
+
".git",
|
|
11
|
+
".hg",
|
|
12
|
+
".svn",
|
|
13
|
+
".venv",
|
|
14
|
+
"venv",
|
|
15
|
+
".tox",
|
|
16
|
+
".mypy_cache",
|
|
17
|
+
".pytest_cache",
|
|
18
|
+
"__pycache__",
|
|
19
|
+
"node_modules",
|
|
20
|
+
".idea",
|
|
21
|
+
".vscode",
|
|
22
|
+
".DS_Store",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class FileTypeInfo:
|
|
28
|
+
"""Aggregated statistics for a single file extension."""
|
|
29
|
+
|
|
30
|
+
extension: str
|
|
31
|
+
count: int
|
|
32
|
+
total_size: int = 0
|
|
33
|
+
percentage: float = 0.0
|
|
34
|
+
size_percentage: float = 0.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def scan_directory(
|
|
38
|
+
path: str = ".",
|
|
39
|
+
exclude_dirs: set[str] | None = None,
|
|
40
|
+
include_hidden: bool = False,
|
|
41
|
+
) -> list[FileTypeInfo]:
|
|
42
|
+
"""Walk *path* and aggregate file counts by extension.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
path : str
|
|
47
|
+
Root directory to scan (defaults to current working directory).
|
|
48
|
+
exclude_dirs : set[str] or None
|
|
49
|
+
Directory names to skip. When ``None``, the built-in
|
|
50
|
+
``DEFAULT_EXCLUDE_DIRS`` set is used.
|
|
51
|
+
include_hidden : bool
|
|
52
|
+
If ``False``, files whose name starts with ``"."`` and directories
|
|
53
|
+
that have a leading dot are ignored.
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
list[FileTypeInfo]
|
|
58
|
+
Sorted list (descending by count) of extension statistics.
|
|
59
|
+
"""
|
|
60
|
+
root = Path(path).resolve()
|
|
61
|
+
if not root.is_dir():
|
|
62
|
+
raise NotADirectoryError(f"Not a directory: {root}")
|
|
63
|
+
|
|
64
|
+
if exclude_dirs is None:
|
|
65
|
+
exclude_dirs = DEFAULT_EXCLUDE_DIRS
|
|
66
|
+
|
|
67
|
+
counter: Counter[str] = Counter()
|
|
68
|
+
size_map: dict[str, int] = {}
|
|
69
|
+
|
|
70
|
+
for dirpath_str, dirnames, filenames in os.walk(root):
|
|
71
|
+
dirpath = Path(dirpath_str)
|
|
72
|
+
|
|
73
|
+
# Filter out directories we should skip.
|
|
74
|
+
dirnames[:] = [
|
|
75
|
+
d
|
|
76
|
+
for d in dirnames
|
|
77
|
+
if d not in exclude_dirs and (include_hidden or not d.startswith("."))
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
for filename in filenames:
|
|
81
|
+
if not include_hidden and filename.startswith("."):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
ext = _extract_extension(filename)
|
|
85
|
+
counter[ext] += 1
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
filepath = dirpath / filename
|
|
89
|
+
size_map[ext] = size_map.get(ext, 0) + filepath.stat().st_size
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
total_files = sum(counter.values())
|
|
94
|
+
total_size_all = sum(size_map.values())
|
|
95
|
+
|
|
96
|
+
if total_files == 0:
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
results: list[FileTypeInfo] = []
|
|
100
|
+
for ext, count in counter.most_common():
|
|
101
|
+
sz = size_map.get(ext, 0)
|
|
102
|
+
info = FileTypeInfo(
|
|
103
|
+
extension=ext,
|
|
104
|
+
count=count,
|
|
105
|
+
total_size=sz,
|
|
106
|
+
percentage=round((count / total_files) * 100, 1),
|
|
107
|
+
size_percentage=round((sz / total_size_all) * 100, 1)
|
|
108
|
+
if total_size_all
|
|
109
|
+
else 0.0,
|
|
110
|
+
)
|
|
111
|
+
results.append(info)
|
|
112
|
+
|
|
113
|
+
return results
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Internal helpers
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _extract_extension(filename: str) -> str:
|
|
122
|
+
"""Return the lowercase file extension including the leading dot.
|
|
123
|
+
|
|
124
|
+
Files without an extension are grouped under the label ``"[no extension]"``.
|
|
125
|
+
Files consisting solely of an extension (e.g. ``".gitignore"``) are treated
|
|
126
|
+
as having no extension.
|
|
127
|
+
"""
|
|
128
|
+
name, dot, ext = filename.rpartition(".")
|
|
129
|
+
if dot and name:
|
|
130
|
+
return f".{ext.lower()}"
|
|
131
|
+
return "[no extension]"
|
startype23/charts.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""Chart rendering: color assignment, stacked-bar, and rich Table."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.padding import Padding
|
|
8
|
+
from rich.style import Style
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from .analyzer import FileTypeInfo
|
|
13
|
+
from .extensions import EXTENSION_INFO
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Flat-design color palette (muted / pastel tones).
|
|
17
|
+
# We cycle through these so every distinct extension gets a unique colour.
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
FLAT_COLORS: list[str] = [
|
|
20
|
+
"#7CB342", # muted green
|
|
21
|
+
"#5C6BC0", # muted indigo
|
|
22
|
+
"#FF7043", # muted orange
|
|
23
|
+
"#AB47BC", # muted purple
|
|
24
|
+
"#26A69A", # muted teal
|
|
25
|
+
"#EF5350", # muted red
|
|
26
|
+
"#FFCA28", # muted amber
|
|
27
|
+
"#EC407A", # muted pink
|
|
28
|
+
"#42A5F5", # muted blue
|
|
29
|
+
"#8D6E63", # muted brown
|
|
30
|
+
"#66BB6A", # soft green
|
|
31
|
+
"#29B6F6", # soft sky
|
|
32
|
+
"#FFA726", # soft orange
|
|
33
|
+
"#7E57C2", # soft violet
|
|
34
|
+
"#9CCC65", # lime
|
|
35
|
+
"#7986CB", # soft indigo
|
|
36
|
+
"#FF8A65", # salmon
|
|
37
|
+
"#BA68C8", # soft purple
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def assign_colors(infos: Sequence[FileTypeInfo]) -> dict[str, str]:
|
|
42
|
+
"""Map every extension in *infos* to a color from ``FLAT_COLORS``.
|
|
43
|
+
|
|
44
|
+
Colors are assigned in the order the extensions appear, cycling when the
|
|
45
|
+
palette is exhausted.
|
|
46
|
+
"""
|
|
47
|
+
palette = FLAT_COLORS
|
|
48
|
+
mapping: dict[str, str] = {}
|
|
49
|
+
for idx, info in enumerate(infos):
|
|
50
|
+
mapping[info.extension] = palette[idx % len(palette)]
|
|
51
|
+
return mapping
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Stacked proportion bar
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _render_stacked_bar(
|
|
60
|
+
infos: Sequence[FileTypeInfo],
|
|
61
|
+
colors: dict[str, str],
|
|
62
|
+
bar_width: int = 60,
|
|
63
|
+
bar_height: int = 3,
|
|
64
|
+
use_size: bool = False,
|
|
65
|
+
) -> Text:
|
|
66
|
+
"""Build a horizontal stacked bar made of coloured blocks.
|
|
67
|
+
|
|
68
|
+
The bar is *bar_height* rows tall so it has enough visual weight.
|
|
69
|
+
When *use_size* is True, segment widths are based on ``size_percentage``
|
|
70
|
+
instead of ``percentage``.
|
|
71
|
+
"""
|
|
72
|
+
if not infos:
|
|
73
|
+
return Text("")
|
|
74
|
+
|
|
75
|
+
num_segments = len(infos)
|
|
76
|
+
separator_count = max(0, num_segments - 1)
|
|
77
|
+
fill_width = bar_width - separator_count
|
|
78
|
+
|
|
79
|
+
pct_key = "size_percentage" if use_size else "percentage"
|
|
80
|
+
total_pct = sum(getattr(i, pct_key) for i in infos)
|
|
81
|
+
|
|
82
|
+
segments: list[tuple[str, int]] = []
|
|
83
|
+
allocated = 0
|
|
84
|
+
for idx, info in enumerate(infos):
|
|
85
|
+
width = max(1, round(getattr(info, pct_key) / total_pct * fill_width))
|
|
86
|
+
if idx == num_segments - 1:
|
|
87
|
+
width = fill_width - allocated
|
|
88
|
+
segments.append((info.extension, width))
|
|
89
|
+
allocated += width
|
|
90
|
+
|
|
91
|
+
lines: list[Text] = []
|
|
92
|
+
for _ in range(bar_height):
|
|
93
|
+
line = Text()
|
|
94
|
+
for seg_idx, (ext, width) in enumerate(segments):
|
|
95
|
+
if seg_idx > 0:
|
|
96
|
+
_ = line.append(" ") # separator between segments
|
|
97
|
+
hex_color = colors.get(ext, "#888888")
|
|
98
|
+
_ = line.append("\u2593" * width, style=Style(color=hex_color))
|
|
99
|
+
lines.append(line)
|
|
100
|
+
|
|
101
|
+
combined = Text()
|
|
102
|
+
for i, line in enumerate(lines):
|
|
103
|
+
if i > 0:
|
|
104
|
+
_ = combined.append("\n")
|
|
105
|
+
_ = combined.append_text(line)
|
|
106
|
+
return combined
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Size formatting
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
_SIZE_SUFFIXES = ["B", "KB", "MB", "GB", "TB"]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _format_size(size_bytes: int) -> str:
|
|
117
|
+
"""Return a human-readable size string (e.g. ``"4.2 KB"``, ``"1.5 MB"``)."""
|
|
118
|
+
if size_bytes == 0:
|
|
119
|
+
return "0 B"
|
|
120
|
+
magnitude = 0
|
|
121
|
+
remaining = float(size_bytes)
|
|
122
|
+
while remaining >= 1024 and magnitude < len(_SIZE_SUFFIXES) - 1:
|
|
123
|
+
remaining /= 1024
|
|
124
|
+
magnitude += 1
|
|
125
|
+
if magnitude == 0:
|
|
126
|
+
return f"{size_bytes} B"
|
|
127
|
+
return f"{remaining:.1f} {_SIZE_SUFFIXES[magnitude]}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Column visibility helpers
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
ColumnSet = set[str]
|
|
135
|
+
|
|
136
|
+
_COL_EXTENSION = "extension"
|
|
137
|
+
_COL_FILETYPE = "filetype"
|
|
138
|
+
_COL_COUNT = "count"
|
|
139
|
+
_COL_PERCENTAGE = "percentage"
|
|
140
|
+
_COL_DISTRIBUTION = "distribution"
|
|
141
|
+
_COL_SIZE = "size"
|
|
142
|
+
_COL_SIZE_PCT = "size_pct"
|
|
143
|
+
|
|
144
|
+
_ALL_COLUMNS: ColumnSet = {
|
|
145
|
+
_COL_EXTENSION,
|
|
146
|
+
_COL_FILETYPE,
|
|
147
|
+
_COL_COUNT,
|
|
148
|
+
_COL_PERCENTAGE,
|
|
149
|
+
_COL_DISTRIBUTION,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_ALL_SIZE_COLUMNS: ColumnSet = {
|
|
153
|
+
_COL_EXTENSION,
|
|
154
|
+
_COL_FILETYPE,
|
|
155
|
+
_COL_COUNT,
|
|
156
|
+
_COL_SIZE,
|
|
157
|
+
_COL_SIZE_PCT,
|
|
158
|
+
_COL_DISTRIBUTION,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _resolve_columns(visible: ColumnSet | None, all_available: ColumnSet) -> ColumnSet:
|
|
163
|
+
"""Return the effective set of columns to display.
|
|
164
|
+
|
|
165
|
+
When *visible* is ``None`` or empty, all available columns are shown.
|
|
166
|
+
Otherwise only the explicitly listed columns (plus Extension) are shown.
|
|
167
|
+
"""
|
|
168
|
+
if visible is None or not visible:
|
|
169
|
+
return all_available
|
|
170
|
+
result: ColumnSet = {_COL_EXTENSION, *visible}
|
|
171
|
+
return result & all_available
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Detailed table (count mode)
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _render_table(
|
|
180
|
+
infos: Sequence[FileTypeInfo],
|
|
181
|
+
colors: dict[str, str],
|
|
182
|
+
bar_width: int = 40,
|
|
183
|
+
columns: ColumnSet | None = None,
|
|
184
|
+
) -> Table:
|
|
185
|
+
"""Create a rich ``Table`` with per-extension details and a mini bar."""
|
|
186
|
+
show_cols = _resolve_columns(columns, _ALL_COLUMNS)
|
|
187
|
+
|
|
188
|
+
table = Table(
|
|
189
|
+
title="File Type Distribution (by Count)",
|
|
190
|
+
title_style="bold",
|
|
191
|
+
expand=False,
|
|
192
|
+
padding=(0, 1),
|
|
193
|
+
show_header=True,
|
|
194
|
+
header_style="bold",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
table.add_column("Extension", style="bold", no_wrap=True)
|
|
198
|
+
if _COL_FILETYPE in show_cols:
|
|
199
|
+
table.add_column("File Type", no_wrap=True)
|
|
200
|
+
if _COL_COUNT in show_cols:
|
|
201
|
+
table.add_column("Count", justify="right")
|
|
202
|
+
if _COL_PERCENTAGE in show_cols:
|
|
203
|
+
table.add_column("Percentage", justify="right")
|
|
204
|
+
if _COL_DISTRIBUTION in show_cols:
|
|
205
|
+
table.add_column("Distribution", justify="left", min_width=bar_width + 2)
|
|
206
|
+
|
|
207
|
+
max_count = max(i.count for i in infos) if infos else 1
|
|
208
|
+
|
|
209
|
+
for info in infos:
|
|
210
|
+
hex_color = colors.get(info.extension, "#888888")
|
|
211
|
+
bar_fill = round(info.count / max_count * bar_width) if max_count else 0
|
|
212
|
+
bar_fill = max(1, bar_fill)
|
|
213
|
+
|
|
214
|
+
bar_text = Text()
|
|
215
|
+
num_blocks = bar_fill // 2
|
|
216
|
+
_ = bar_text.append("\u2593" * num_blocks, style=Style(color=hex_color))
|
|
217
|
+
|
|
218
|
+
ftype, _desc = EXTENSION_INFO.get(info.extension, ("Unknown", ""))
|
|
219
|
+
|
|
220
|
+
row = [info.extension]
|
|
221
|
+
if _COL_FILETYPE in show_cols:
|
|
222
|
+
row.append(ftype)
|
|
223
|
+
if _COL_COUNT in show_cols:
|
|
224
|
+
row.append(str(info.count))
|
|
225
|
+
if _COL_PERCENTAGE in show_cols:
|
|
226
|
+
row.append(f"{info.percentage}%")
|
|
227
|
+
if _COL_DISTRIBUTION in show_cols:
|
|
228
|
+
row.append(bar_text)
|
|
229
|
+
|
|
230
|
+
table.add_row(*row)
|
|
231
|
+
|
|
232
|
+
return table
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Size table
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _render_size_table(
|
|
241
|
+
infos: Sequence[FileTypeInfo],
|
|
242
|
+
colors: dict[str, str],
|
|
243
|
+
bar_width: int = 40,
|
|
244
|
+
columns: ColumnSet | None = None,
|
|
245
|
+
) -> Table:
|
|
246
|
+
"""Create a rich ``Table`` showing size distribution per extension."""
|
|
247
|
+
show_cols = _resolve_columns(columns, _ALL_SIZE_COLUMNS)
|
|
248
|
+
|
|
249
|
+
table = Table(
|
|
250
|
+
title="File Type Distribution (by Size)",
|
|
251
|
+
title_style="bold",
|
|
252
|
+
expand=False,
|
|
253
|
+
padding=(0, 1),
|
|
254
|
+
show_header=True,
|
|
255
|
+
header_style="bold",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
table.add_column("Extension", style="bold", no_wrap=True)
|
|
259
|
+
if _COL_FILETYPE in show_cols:
|
|
260
|
+
table.add_column("File Type", no_wrap=True)
|
|
261
|
+
if _COL_COUNT in show_cols:
|
|
262
|
+
table.add_column("Count", justify="right")
|
|
263
|
+
if _COL_SIZE in show_cols:
|
|
264
|
+
table.add_column("Total Size", justify="right")
|
|
265
|
+
if _COL_SIZE_PCT in show_cols:
|
|
266
|
+
table.add_column("Size %", justify="right")
|
|
267
|
+
if _COL_DISTRIBUTION in show_cols:
|
|
268
|
+
table.add_column("Distribution", justify="left", min_width=bar_width + 2)
|
|
269
|
+
|
|
270
|
+
max_size = max(i.total_size for i in infos) if infos else 1
|
|
271
|
+
|
|
272
|
+
for info in infos:
|
|
273
|
+
hex_color = colors.get(info.extension, "#888888")
|
|
274
|
+
bar_fill = round(info.total_size / max_size * bar_width) if max_size else 0
|
|
275
|
+
bar_fill = max(1, bar_fill)
|
|
276
|
+
|
|
277
|
+
bar_text = Text()
|
|
278
|
+
num_blocks = bar_fill // 2
|
|
279
|
+
_ = bar_text.append("\u2593" * num_blocks, style=Style(color=hex_color))
|
|
280
|
+
|
|
281
|
+
ftype, _desc = EXTENSION_INFO.get(info.extension, ("Unknown", ""))
|
|
282
|
+
|
|
283
|
+
row = [info.extension]
|
|
284
|
+
if _COL_FILETYPE in show_cols:
|
|
285
|
+
row.append(ftype)
|
|
286
|
+
if _COL_COUNT in show_cols:
|
|
287
|
+
row.append(str(info.count))
|
|
288
|
+
if _COL_SIZE in show_cols:
|
|
289
|
+
row.append(_format_size(info.total_size))
|
|
290
|
+
if _COL_SIZE_PCT in show_cols:
|
|
291
|
+
row.append(f"{info.size_percentage}%")
|
|
292
|
+
if _COL_DISTRIBUTION in show_cols:
|
|
293
|
+
row.append(bar_text)
|
|
294
|
+
|
|
295
|
+
table.add_row(*row)
|
|
296
|
+
|
|
297
|
+
return table
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
# Legend
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _render_legend(
|
|
306
|
+
infos: Sequence[FileTypeInfo],
|
|
307
|
+
colors: dict[str, str],
|
|
308
|
+
) -> Text:
|
|
309
|
+
"""Build a compact colour legend."""
|
|
310
|
+
legend = Text()
|
|
311
|
+
for info in infos:
|
|
312
|
+
hex_color = colors.get(info.extension, "#888888")
|
|
313
|
+
_ = legend.append(" ")
|
|
314
|
+
_ = legend.append(" ", style=Style(bgcolor=hex_color))
|
|
315
|
+
_ = legend.append(f" {info.extension} ", style="dim")
|
|
316
|
+
return legend
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
# Top-level render entry points
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def normalize_extension(ext: str) -> str:
|
|
325
|
+
"""Ensure an extension string has a leading dot, lowercased.
|
|
326
|
+
|
|
327
|
+
Accepts ``".py"``, ``"py"``, ``".PY"``, ``"PY"``.
|
|
328
|
+
Returns the lowercased form with a leading dot.
|
|
329
|
+
"""
|
|
330
|
+
ext = ext.strip().lower()
|
|
331
|
+
if not ext.startswith("."):
|
|
332
|
+
ext = f".{ext}"
|
|
333
|
+
return ext
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def render_explain(
|
|
337
|
+
extension: str,
|
|
338
|
+
*,
|
|
339
|
+
console: Console | None = None,
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Look up a single extension and display its file type and description."""
|
|
342
|
+
if console is None:
|
|
343
|
+
console = Console()
|
|
344
|
+
|
|
345
|
+
ext = normalize_extension(extension)
|
|
346
|
+
entry = EXTENSION_INFO.get(ext)
|
|
347
|
+
|
|
348
|
+
if entry is None:
|
|
349
|
+
console.print(
|
|
350
|
+
f"[bold yellow]No information found for extension '{ext}'.[/bold yellow]"
|
|
351
|
+
)
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
ftype, desc = entry
|
|
355
|
+
|
|
356
|
+
table = Table(
|
|
357
|
+
title=f"Extension: {ext}",
|
|
358
|
+
title_style="bold",
|
|
359
|
+
expand=False,
|
|
360
|
+
padding=(0, 1),
|
|
361
|
+
show_header=True,
|
|
362
|
+
header_style="bold",
|
|
363
|
+
)
|
|
364
|
+
table.add_column("Extension", style="bold", no_wrap=True)
|
|
365
|
+
table.add_column("File Type", no_wrap=True)
|
|
366
|
+
table.add_column("Description")
|
|
367
|
+
|
|
368
|
+
table.add_row(ext, ftype, desc)
|
|
369
|
+
|
|
370
|
+
console.print(table)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def render_chart(
|
|
374
|
+
infos: Sequence[FileTypeInfo],
|
|
375
|
+
root_label: str = "",
|
|
376
|
+
*,
|
|
377
|
+
console: Console | None = None,
|
|
378
|
+
columns: ColumnSet | None = None,
|
|
379
|
+
size_mode: bool = False,
|
|
380
|
+
) -> None:
|
|
381
|
+
"""Render the full file-type distribution chart on *console*.
|
|
382
|
+
|
|
383
|
+
Parameters
|
|
384
|
+
----------
|
|
385
|
+
infos
|
|
386
|
+
Sorted extension statistics.
|
|
387
|
+
root_label
|
|
388
|
+
Display path for the header.
|
|
389
|
+
console
|
|
390
|
+
Rich Console to print to.
|
|
391
|
+
columns
|
|
392
|
+
Which columns to show (``None`` = all default columns).
|
|
393
|
+
size_mode
|
|
394
|
+
If ``True``, render the size-distribution view instead of count.
|
|
395
|
+
"""
|
|
396
|
+
if console is None:
|
|
397
|
+
console = Console()
|
|
398
|
+
|
|
399
|
+
if not infos:
|
|
400
|
+
console.print("[bold]No files found in the scanned directory.[/bold]")
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
term_width = shutil.get_terminal_size((80, 24)).columns
|
|
404
|
+
stacked_width = min(term_width - 4, 80)
|
|
405
|
+
table_bar_width = min(term_width - 36, 40)
|
|
406
|
+
|
|
407
|
+
colors = assign_colors(infos)
|
|
408
|
+
|
|
409
|
+
# --- Heading ---
|
|
410
|
+
mode_label = "File Size Distribution" if size_mode else "File Type Distribution"
|
|
411
|
+
heading = Text()
|
|
412
|
+
_ = heading.append(mode_label, style="bold underline")
|
|
413
|
+
if root_label:
|
|
414
|
+
_ = heading.append(f" -- {root_label}", style="italic dim")
|
|
415
|
+
console.print(Padding(heading, (0, 0, 1, 0)))
|
|
416
|
+
|
|
417
|
+
# --- Stacked bar ---
|
|
418
|
+
stacked_bar = _render_stacked_bar(
|
|
419
|
+
infos, colors, bar_width=stacked_width, bar_height=3, use_size=size_mode
|
|
420
|
+
)
|
|
421
|
+
console.print(Padding(stacked_bar, (0, 2, 1, 2)))
|
|
422
|
+
|
|
423
|
+
# --- Legend ---
|
|
424
|
+
legend = _render_legend(infos, colors)
|
|
425
|
+
console.print(Padding(legend, (0, 0, 1, 0)))
|
|
426
|
+
|
|
427
|
+
# --- Table ---
|
|
428
|
+
if size_mode:
|
|
429
|
+
table = _render_size_table(
|
|
430
|
+
infos, colors, bar_width=table_bar_width, columns=columns
|
|
431
|
+
)
|
|
432
|
+
total_sz = sum(i.total_size for i in infos)
|
|
433
|
+
console.print(table)
|
|
434
|
+
console.print(
|
|
435
|
+
Padding(
|
|
436
|
+
f"[dim]{_format_size(total_sz)} across {len(infos)} distinct types[/dim]",
|
|
437
|
+
(1, 0, 0, 0),
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
else:
|
|
441
|
+
table = _render_table(infos, colors, bar_width=table_bar_width, columns=columns)
|
|
442
|
+
total_files = sum(i.count for i in infos)
|
|
443
|
+
console.print(table)
|
|
444
|
+
console.print(
|
|
445
|
+
Padding(
|
|
446
|
+
f"[dim]{total_files} files across {len(infos)} distinct types[/dim]",
|
|
447
|
+
(1, 0, 0, 0),
|
|
448
|
+
)
|
|
449
|
+
)
|
startype23/cli.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""CLI entry-point for StarType23."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .analyzer import scan_directory
|
|
8
|
+
from .charts import render_chart, render_explain
|
|
9
|
+
|
|
10
|
+
# Sentinel for "no column flags given".
|
|
11
|
+
_NONE = "__none__"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command()
|
|
15
|
+
@click.option(
|
|
16
|
+
"--path",
|
|
17
|
+
default=".",
|
|
18
|
+
show_default=True,
|
|
19
|
+
help="Directory to scan for file types.",
|
|
20
|
+
)
|
|
21
|
+
@click.option(
|
|
22
|
+
"--exclude",
|
|
23
|
+
"-x",
|
|
24
|
+
multiple=True,
|
|
25
|
+
help="Additional directory names to exclude (repeatable).",
|
|
26
|
+
)
|
|
27
|
+
@click.option(
|
|
28
|
+
"--include-hidden/--no-include-hidden",
|
|
29
|
+
default=False,
|
|
30
|
+
show_default=True,
|
|
31
|
+
help="Include hidden files and directories in the scan.",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--explain",
|
|
35
|
+
"-e",
|
|
36
|
+
metavar="EXTENSION",
|
|
37
|
+
help="Show file type and description for a given extension.",
|
|
38
|
+
)
|
|
39
|
+
@click.option(
|
|
40
|
+
"--size",
|
|
41
|
+
"-s",
|
|
42
|
+
is_flag=True,
|
|
43
|
+
default=False,
|
|
44
|
+
help="Show size distribution instead of file count distribution.",
|
|
45
|
+
)
|
|
46
|
+
@click.option(
|
|
47
|
+
"--filetype",
|
|
48
|
+
"-ft",
|
|
49
|
+
"col_filetype",
|
|
50
|
+
flag_value="filetype",
|
|
51
|
+
default=_NONE,
|
|
52
|
+
help="Show the File Type column (use alone to show only selected columns).",
|
|
53
|
+
)
|
|
54
|
+
@click.option(
|
|
55
|
+
"--count",
|
|
56
|
+
"-c",
|
|
57
|
+
"col_count",
|
|
58
|
+
flag_value="count",
|
|
59
|
+
default=_NONE,
|
|
60
|
+
help="Show the Count column (use alone to show only selected columns).",
|
|
61
|
+
)
|
|
62
|
+
@click.option(
|
|
63
|
+
"--percentage",
|
|
64
|
+
"-p",
|
|
65
|
+
"col_percentage",
|
|
66
|
+
flag_value="percentage",
|
|
67
|
+
default=_NONE,
|
|
68
|
+
help="Show the Percentage column (use alone to show only selected columns).",
|
|
69
|
+
)
|
|
70
|
+
@click.option(
|
|
71
|
+
"--distribution",
|
|
72
|
+
"-d",
|
|
73
|
+
"col_distribution",
|
|
74
|
+
flag_value="distribution",
|
|
75
|
+
default=_NONE,
|
|
76
|
+
help="Show the Distribution column (use alone to show only selected columns).",
|
|
77
|
+
)
|
|
78
|
+
def main(
|
|
79
|
+
path: str = ".",
|
|
80
|
+
exclude: tuple[str, ...] | None = None,
|
|
81
|
+
include_hidden: bool = False,
|
|
82
|
+
explain: str | None = None,
|
|
83
|
+
size: bool = False,
|
|
84
|
+
col_filetype: str | None = _NONE,
|
|
85
|
+
col_count: str | None = _NONE,
|
|
86
|
+
col_percentage: str | None = _NONE,
|
|
87
|
+
col_distribution: str | None = _NONE,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Analyze file types in a directory and display a colourful chart.
|
|
90
|
+
|
|
91
|
+
Walks the directory tree, counts files by extension, and renders a
|
|
92
|
+
terminal-based chart with a stacked proportion bar and a detailed table.
|
|
93
|
+
"""
|
|
94
|
+
if explain is not None:
|
|
95
|
+
render_explain(explain)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
target = Path(path).resolve()
|
|
99
|
+
|
|
100
|
+
exclude_set: set[str] = set(exclude) if exclude else set()
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
infos = scan_directory(
|
|
104
|
+
path=str(target),
|
|
105
|
+
exclude_dirs=exclude_set if exclude_set else None,
|
|
106
|
+
include_hidden=include_hidden,
|
|
107
|
+
)
|
|
108
|
+
except NotADirectoryError as exc:
|
|
109
|
+
click.echo(f"Error: {exc}", err=True)
|
|
110
|
+
raise SystemExit(1)
|
|
111
|
+
|
|
112
|
+
# Build column filter set from the individual flags.
|
|
113
|
+
col_values = [
|
|
114
|
+
v
|
|
115
|
+
for v in (col_filetype, col_count, col_percentage, col_distribution)
|
|
116
|
+
if v != _NONE
|
|
117
|
+
]
|
|
118
|
+
columns: set[str] | None = set(col_values) if col_values else None
|
|
119
|
+
|
|
120
|
+
render_chart(infos, root_label=str(target), columns=columns, size_mode=size)
|