| | """ |
| | Shared tree rendering utilities for filesystem-like output. |
| | |
| | Provides functions to build and render tree structures with line connectors |
| | (├──, └──, │) for visual hierarchy display. |
| | """ |
| |
|
| | from __future__ import annotations |
| |
|
| | import os |
| | from datetime import datetime |
| | from typing import Callable, Optional |
| |
|
| |
|
| | def build_tree(entries: list[tuple[str, dict]]) -> dict: |
| | """ |
| | Build a nested tree structure from flat path entries. |
| | |
| | Args: |
| | entries: List of (path, metadata) tuples where path uses forward slashes. |
| | Paths ending with '/' are treated as directories. |
| | |
| | Returns: |
| | Nested dict with "__files__" key for files at each level. |
| | |
| | Example: |
| | entries = [ |
| | ("scripts/utils.py", {"size": 1234}), |
| | ("docs/readme.md", {"size": 500}), |
| | ] |
| | tree = build_tree(entries) |
| | """ |
| | root: dict = {"__files__": []} |
| | |
| | for path, metadata in entries: |
| | parts = path.rstrip("/").split("/") |
| | is_dir = path.endswith("/") |
| | |
| | node = root |
| | for i, part in enumerate(parts[:-1]): |
| | if part not in node: |
| | node[part] = {"__files__": []} |
| | node = node[part] |
| | |
| | final = parts[-1] |
| | if is_dir: |
| | |
| | if final not in node: |
| | node[final] = {"__files__": []} |
| | |
| | if metadata: |
| | node[final]["__meta__"] = metadata |
| | else: |
| | |
| | node["__files__"].append((final, metadata)) |
| | |
| | return root |
| |
|
| |
|
| | def render_tree( |
| | node: dict, |
| | prefix: str = "", |
| | format_entry: Optional[Callable[[str, dict, bool], str]] = None, |
| | ) -> list[str]: |
| | """ |
| | Render a tree with line connectors. |
| | |
| | Args: |
| | node: Nested dict from build_tree() |
| | prefix: Current line prefix for indentation |
| | format_entry: Optional callback to format each entry. |
| | Signature: (name, metadata, is_dir) -> str |
| | If None, uses default formatting. |
| | |
| | Returns: |
| | List of formatted lines. |
| | """ |
| | result = [] |
| | |
| | |
| | def default_format(name: str, meta: dict, is_dir: bool) -> str: |
| | if is_dir: |
| | return f"{name}/" |
| | size = meta.get("size") |
| | if size is not None: |
| | return f"{name} ({_fmt_size(size)})" |
| | return name |
| | |
| | fmt = format_entry or default_format |
| | |
| | |
| | entries = [] |
| | subdirs = sorted(k for k in node.keys() if k not in ("__files__", "__meta__")) |
| | files_here = sorted(node.get("__files__", []), key=lambda x: x[0]) |
| | |
| | for dirname in subdirs: |
| | dir_meta = node[dirname].get("__meta__", {}) |
| | entries.append(("dir", dirname, node[dirname], dir_meta)) |
| | for fname, fmeta in files_here: |
| | entries.append(("file", fname, None, fmeta)) |
| | |
| | for i, entry in enumerate(entries): |
| | is_last = (i == len(entries) - 1) |
| | connector = "└── " if is_last else "├── " |
| | child_prefix = prefix + (" " if is_last else "│ ") |
| | |
| | etype, name, subtree, meta = entry |
| | |
| | if etype == "dir": |
| | result.append(f"{prefix}{connector}{fmt(name, meta, True)}") |
| | result.extend(render_tree(subtree, child_prefix, format_entry)) |
| | else: |
| | result.append(f"{prefix}{connector}{fmt(name, meta, False)}") |
| | |
| | return result |
| |
|
| |
|
| | def _fmt_size(num_bytes: int) -> str: |
| | """Format byte size as human-readable string.""" |
| | units = ["B", "KB", "MB", "GB"] |
| | size = float(num_bytes) |
| | for unit in units: |
| | if size < 1024.0: |
| | return f"{size:.1f} {unit}" |
| | size /= 1024.0 |
| | return f"{size:.1f} TB" |
| |
|
| |
|
| | def walk_and_build_tree( |
| | abs_path: str, |
| | *, |
| | show_hidden: bool = False, |
| | recursive: bool = False, |
| | max_entries: int = 100, |
| | ) -> tuple[dict, int, bool]: |
| | """ |
| | Walk a directory and build a tree structure. |
| | |
| | Args: |
| | abs_path: Absolute path to directory |
| | show_hidden: Include hidden files/dirs (starting with '.') |
| | recursive: Recurse into subdirectories |
| | max_entries: Maximum entries before truncation |
| | |
| | Returns: |
| | (tree, total_entries, truncated) |
| | """ |
| | entries: list[tuple[str, dict]] = [] |
| | total = 0 |
| | truncated = False |
| | |
| | for root, dirs, files in os.walk(abs_path): |
| | |
| | if not show_hidden: |
| | dirs[:] = [d for d in dirs if not d.startswith('.')] |
| | files = [f for f in files if not f.startswith('.')] |
| | |
| | dirs.sort() |
| | files.sort() |
| | |
| | |
| | try: |
| | rel_root = os.path.relpath(root, abs_path) |
| | except Exception: |
| | rel_root = "" |
| | prefix = "" if rel_root == "." else rel_root.replace("\\", "/") + "/" |
| | |
| | |
| | for d in dirs: |
| | p = os.path.join(root, d) |
| | try: |
| | mtime = datetime.fromtimestamp(os.path.getmtime(p)).strftime("%Y-%m-%d %H:%M") |
| | except Exception: |
| | mtime = "?" |
| | entries.append((f"{prefix}{d}/", {"mtime": mtime})) |
| | total += 1 |
| | if total >= max_entries: |
| | truncated = True |
| | break |
| | |
| | if truncated: |
| | break |
| | |
| | |
| | for f in files: |
| | p = os.path.join(root, f) |
| | try: |
| | size = os.path.getsize(p) |
| | mtime = datetime.fromtimestamp(os.path.getmtime(p)).strftime("%Y-%m-%d %H:%M") |
| | except Exception: |
| | size, mtime = 0, "?" |
| | entries.append((f"{prefix}{f}", {"size": size, "mtime": mtime})) |
| | total += 1 |
| | if total >= max_entries: |
| | truncated = True |
| | break |
| | |
| | if truncated: |
| | break |
| | |
| | if not recursive: |
| | break |
| | |
| | return build_tree(entries), total, truncated |
| |
|
| |
|
| | def format_dir_listing( |
| | abs_path: str, |
| | display_path: str, |
| | *, |
| | show_hidden: bool = False, |
| | recursive: bool = False, |
| | max_entries: int = 100, |
| | fmt_size_fn: Optional[Callable[[int], str]] = None, |
| | ) -> str: |
| | """ |
| | Format a directory listing as a visual tree. |
| | |
| | Args: |
| | abs_path: Absolute path to directory |
| | display_path: User-friendly path to show in header |
| | show_hidden: Include hidden files/dirs |
| | recursive: Recurse into subdirectories |
| | max_entries: Maximum entries before truncation |
| | fmt_size_fn: Optional custom size formatter (defaults to _fmt_size) |
| | |
| | Returns: |
| | Formatted string with tree output. |
| | """ |
| | fmt_size = fmt_size_fn or _fmt_size |
| | |
| | tree, total, truncated = walk_and_build_tree( |
| | abs_path, |
| | show_hidden=show_hidden, |
| | recursive=recursive, |
| | max_entries=max_entries, |
| | ) |
| | |
| | |
| | def format_entry(name: str, meta: dict, is_dir: bool) -> str: |
| | mtime = meta.get("mtime", "") |
| | if is_dir: |
| | return f"{name}/ ({mtime})" |
| | size = meta.get("size", 0) |
| | return f"{name} ({fmt_size(size)}, {mtime})" |
| | |
| | tree_lines = render_tree(tree, " ", format_entry) |
| | |
| | header = f"Listing of {display_path}\nRoot: /\nEntries: {total}" |
| | if truncated: |
| | header += f"\n… Truncated at {max_entries} entries." |
| | |
| | lines = [header, "", "└── /"] |
| | lines.extend(tree_lines) |
| | |
| | return "\n".join(lines).strip() |
| |
|
| |
|
| | __all__ = [ |
| | "build_tree", |
| | "render_tree", |
| | "_fmt_size", |
| | "walk_and_build_tree", |
| | "format_dir_listing", |
| | ] |
| |
|