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 ADDED
@@ -0,0 +1,3 @@
1
+ """StarType23 -- A CLI tool for colourful file type distribution charts."""
2
+
3
+ __version__ = "1.0.0"
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)