Source code for scaffold_kit.tree

"""Generates a hierarchical tree representation of a directory.

This module scans a specified directory, builds a hierarchical structure of its
contents, and renders a text-based tree diagram. It uses an `IgnoreParser` to
exclude files and directories based on patterns found in a specified ignore
file (e.g., '.gitignore'). The generated tree is written to a file and also
printed to the console.

Usage:
    To run this script, navigate to your project's root directory and execute it
    as a module:

    # Generate full project tree:
    $ uv run python -m scaffold_kit.tree

    # Generate partial tree from subdirectory:
    $ uv run python -m scaffold_kit.tree my_project/data
"""

from __future__ import annotations

import os
import sys
import glob
import argparse

from pathlib import Path
from typing import Callable, Optional

from scaffold_kit.config import (
    IGNORE_FILE,
    TREE_DIRECTORY,
    TREE_FILE,
)
from scaffold_kit.utils.ignore_parser import IgnoreParser
from scaffold_kit.utils.string_utils import slugify


[docs] def build_tree_structure(paths: list[str], ignore_matches: Callable) -> dict: """Builds a hierarchical tree structure from a list of file paths. Args: paths: A list of file/directory paths. ignore_matches: A function to check if a path should be ignored. Returns: A nested dictionary representing the directory structure. """ tree = {} for path in paths: if ignore_matches(path): continue parts = Path(path).parts current = tree # Build the nested structure by iterating through path parts. for _, part in enumerate(parts): if part not in current: current[part] = {} current = current[part] return tree
[docs] def sort_tree_items(items: list, current_path: str = "") -> list: """Sorts items with directories first, then files, both alphabetically. Args: items: A list of `(name, subtree)` tuples. current_path: The current path for checking if items are directories. Returns: A sorted list of `(name, subtree)` tuples. """ directories = [] files = [] # 1. Separate directories and files. for name, subtree in items: # Construct the full path to check if it's a directory. full_path = os.path.join(current_path, name) if current_path else name if os.path.isdir(full_path): directories.append((name, subtree)) else: files.append((name, subtree)) # 2. Sort each category alphabetically. directories.sort(key=lambda x: x[0].lower()) files.sort(key=lambda x: x[0].lower()) return directories + files
[docs] def render_tree(tree: dict, prefix: str = "", current_path: str = "") -> list: """Recursively renders the tree structure with proper tree characters. Args: tree: The nested dictionary representing the directory structure. prefix: The current line prefix for indentation. current_path: The full path to the current directory being processed. Returns: A list of formatted lines representing the tree structure. """ lines = [] items = list(tree.items()) sorted_items = sort_tree_items(items, current_path) for i, (name, subtree) in enumerate(sorted_items): is_last_item = i == len(sorted_items) - 1 # Choose the appropriate tree character based on position. if is_last_item: current_prefix = prefix + "└── " next_prefix = prefix + " " else: current_prefix = prefix + "├── " next_prefix = prefix + "│ " # Construct the full path to check if it's a directory. full_path = os.path.join(current_path, name) if current_path else name # Add directory indicator for directories. display_name = name + "/" if os.path.isdir(full_path) else name lines.append(current_prefix + display_name) # Recursively render subdirectories. if subtree: lines.extend(render_tree(subtree, next_prefix, full_path)) return lines
# pylint: disable=too-many-locals
[docs] def generate_tree( root_dir: str = ".", ignore_file: str = ".gitignore", output_file: str = "directory-tree.txt", output_dir: Optional[str] = None, ): """Generates a directory tree of files in the specified directory. The function scans a directory, applies ignore patterns, and creates a text-based tree representation that is saved to a file and printed to the console. Args: root_dir: The root directory to scan (default: current directory). ignore_file: The name of the file containing ignore patterns. output_file: The name of the output file for the tree. output_dir: The directory where the output file will be saved. (default: current directory). Raises: SystemExit: If the specified `root_dir` does not exist. """ # 1. Validate that the root directory exists. if not os.path.isdir(root_dir): print(f"Error: Directory '{root_dir}' does not exist.") sys.exit(1) # 2. Generate the output filename, sanitizing for partial trees. if root_dir == ".": display_root = "." else: # Sanitize path for filename (replace slashes with dashes). sanitized_path = root_dir.replace("/", "-").replace("\\", "-") output_file_base = Path(output_file).stem output_file = f"{output_file_base}-{sanitized_path}.txt" # Use just the last directory name for display. display_root = Path(root_dir).name + "/" # 3. Always read ignore file from the original project root. parser = IgnoreParser.from_file(ignore_file) # 4. Change to the target directory for scanning. original_cwd = os.getcwd() os.chdir(root_dir) try: # 5. Get all paths using glob, and add directories explicitly. # pylint: disable=unexpected-keyword-arg all_paths = glob.glob("**/*", recursive=True, include_hidden=True) all_dirs = set() for path in all_paths: p = Path(path) # Add all parent directories. for parent in p.parents: if parent != Path("."): all_dirs.add(str(parent)) # Combine files and directories. all_items = list(set(all_paths + list(all_dirs))) # 6. Filter paths using the ignore rules. if root_dir != ".": # For partial trees, use full paths for ignore checking. full_paths_for_ignore = [ os.path.join(original_cwd, root_dir, p) for p in all_items ] filtered_relative_paths = [ rel_path for rel_path, full_path in zip(all_items, full_paths_for_ignore) if not parser.matches(full_path) ] else: # For full tree, paths are already correct for ignore checking. filtered_relative_paths = [ p for p in all_items if not parser.matches(p) ] sorted_paths = sorted(filtered_relative_paths) # 7. Build and render the tree structure. tree = build_tree_structure( sorted_paths, lambda x: False ) # No additional filtering needed lines = [display_root] if tree: lines.extend(render_tree(tree)) content = "\n".join(lines) finally: # 8. Always return to the original directory. os.chdir(original_cwd) # 9. Write the output file in the original or set directory. if not os.path.exists(output_dir): os.makedirs(output_dir) print(f"Directory '{output_dir}' created successfully!") # Sanitize and assemble the final output file path. output_filename = ( f"{slugify(Path(output_file).stem)}{Path(output_file).suffix}" ) output_path = Path(output_dir) / output_filename print(f"Writing {output_path}...") with open(output_path, "w", encoding="utf-8") as f: f.write(content) print(content) print(f"\nSuccessfully wrote directory tree to {output_path}")
[docs] def main(): """Main entry point to run the directory tree generation process. Parses command-line arguments and runs the tree generation process. """ # 1. Create the argument parser. parser = argparse.ArgumentParser( description="Generate a directory tree of the project structure" ) # 2. Add the positional argument for the root directory. parser.add_argument( "root_dir", nargs="?", default=".", help="Root directory to scan (default: current directory)", ) # 3. Add the optional argument for the ignore file. parser.add_argument( "--ignore-file", default=IGNORE_FILE, help=f"Ignore file to use (default: {IGNORE_FILE})", ) args = parser.parse_args() generate_tree( args.root_dir, args.ignore_file, output_file=TREE_FILE, output_dir=TREE_DIRECTORY, )
if __name__ == "__main__": main()