"""Create a safe, deterministic project tree snapshot. Usage: python project_tree.py [--path PATH] [--output FILE] [--depth N] """ from __future__ import annotations import argparse import os import sys from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import TextIO DEFAULT_DEPTH = 6 DEFAULT_OUTPUT = "PROJECT_TREE.txt" EXCLUDED_DIRECTORIES = frozenset( { ".git", "__pycache__", ".venv", "venv", "env", "node_modules", "dist", "build", "staticfiles", "media", ".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".coverage", "htmlcov", } ) EXCLUDED_FILE_NAMES = frozenset({".coverage"}) EXCLUDED_FILE_SUFFIXES = (".pyc", ".pyo", ".log", ".tmp", ".temp", ".cache") DJANGO_FILE_NAMES = frozenset( { "manage.py", "settings.py", "urls.py", "models.py", "views.py", "admin.py", "apps.py", "serializers.py", "forms.py", "signals.py", "tasks.py", "permissions.py", "middleware.py", } ) JAVASCRIPT_TYPESCRIPT_SUFFIXES = frozenset( {".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"} ) @dataclass class Counts: """Counters for entries included in the rendered snapshot.""" python_files: int = 0 django_files: int = 0 javascript_typescript_files: int = 0 html_files: int = 0 total_files: int = 0 total_directories: int = 0 def add_file(self, name: str) -> None: suffix = Path(name).suffix.casefold() folded_name = name.casefold() self.total_files += 1 if suffix == ".py": self.python_files += 1 if folded_name in DJANGO_FILE_NAMES: self.django_files += 1 if suffix in JAVASCRIPT_TYPESCRIPT_SUFFIXES: self.javascript_typescript_files += 1 if suffix in {".html", ".htm"}: self.html_files += 1 def warn(message: str) -> None: """Emit a concise warning without aborting the traversal.""" print(f"Warning: {message}", file=sys.stderr) def normalized_path(path: Path) -> str: """Return a normalized absolute path without requiring it to exist.""" return os.path.normcase(os.path.abspath(os.fspath(path))) def is_excluded_file(name: str) -> bool: folded_name = name.casefold() return folded_name in EXCLUDED_FILE_NAMES or folded_name.endswith( EXCLUDED_FILE_SUFFIXES ) def sorted_entries(directory: Path) -> tuple[list[os.DirEntry[str]], list[os.DirEntry[str]]]: """Scan one directory, pruning excluded directories before recursion.""" directories: list[os.DirEntry[str]] = [] files: list[os.DirEntry[str]] = [] try: with os.scandir(directory) as iterator: for entry in iterator: try: if entry.is_symlink(): warn(f"skipping symlink: {entry.path}") continue if entry.is_dir(follow_symlinks=False): if entry.name.casefold() not in EXCLUDED_DIRECTORIES: directories.append(entry) elif entry.is_file(follow_symlinks=False): if not is_excluded_file(entry.name): files.append(entry) except (OSError, ValueError) as error: warn(f"skipping inaccessible entry {entry.path!r}: {error}") except (OSError, ValueError) as error: warn(f"cannot scan directory {os.fspath(directory)!r}: {error}") sort_key = lambda entry: (entry.name.casefold(), entry.name) directories.sort(key=sort_key) files.sort(key=sort_key) return directories, files def write_tree( output: TextIO, root: Path, max_depth: int, output_path: Path, counts: Counts, ) -> None: """Write the tree depth-first while retaining only one directory level.""" output_identity = normalized_path(output_path) output.write(f"{root.name or os.fspath(root)}\n") def visit(directory: Path, depth: int, prefix: str) -> None: if depth >= max_depth: return directories, files = sorted_entries(directory) visible_files = [ entry for entry in files if normalized_path(Path(entry.path)) != output_identity ] entries: list[tuple[os.DirEntry[str], bool]] = [ (entry, True) for entry in directories ] + [(entry, False) for entry in visible_files] for index, (entry, is_directory) in enumerate(entries): is_last = index == len(entries) - 1 output.write(f"{prefix}{'└──' if is_last else '├──'} {entry.name}\n") if is_directory: counts.total_directories += 1 child_prefix = prefix + (" " if is_last else "│ ") visit(Path(entry.path), depth + 1, child_prefix) else: counts.add_file(entry.name) visit(root, 0, "") def format_header(root: Path, generated_at: datetime, counts: Counts) -> str: """Build a fixed-width header so it can be updated after streaming the tree.""" fields = ( ("Project Root", root.name or "."), ("Generated At", generated_at.isoformat(timespec="seconds")), ("Python Files", str(counts.python_files)), ("Django Files", str(counts.django_files)), ("JavaScript/TypeScript Files", str(counts.javascript_typescript_files)), ("HTML Files", str(counts.html_files)), ("Total Files", str(counts.total_files)), ("Total Directories", str(counts.total_directories)), ) return "".join(f"{label}: {value:<80}\n" for label, value in fields) + "\n" def generate_snapshot(root: Path, output_path: Path, max_depth: int) -> Counts: """Create the requested output file; all other filesystem access is read-only.""" if output_path.is_symlink(): raise ValueError(f"refusing to overwrite symlink output: {output_path}") counts = Counts() generated_at = datetime.now(timezone.utc) placeholder_header = format_header(root, generated_at, counts) if not output_path.parent.is_dir(): raise ValueError(f"output directory does not exist: {output_path.parent}") with output_path.open("w+", encoding="utf-8", newline="\n") as output: output.write(placeholder_header) write_tree(output, root, max_depth, output_path, counts) final_header = format_header(root, generated_at, counts) if len(final_header) != len(placeholder_header): raise RuntimeError("internal error: snapshot header size changed") output.seek(0) output.write(final_header) return counts def non_negative_integer(value: str) -> int: try: number = int(value) except ValueError as error: raise argparse.ArgumentTypeError("depth must be an integer") from error if number < 0: raise argparse.ArgumentTypeError("depth must be zero or greater") return number def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Create a deterministic, AI-friendly project tree snapshot." ) parser.add_argument("--path", type=Path, default=Path.cwd(), help="project root") parser.add_argument( "--output", type=Path, default=Path(DEFAULT_OUTPUT), help="snapshot output file" ) parser.add_argument( "--depth", type=non_negative_integer, default=DEFAULT_DEPTH, help=f"maximum tree depth (default: {DEFAULT_DEPTH})", ) return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv) root = args.path.expanduser() output_path = args.output.expanduser() try: if root.is_symlink(): raise ValueError(f"project root must not be a symlink: {root}") root = root.resolve(strict=True) if not root.is_dir(): raise ValueError(f"project root is not a directory: {root}") output_path = Path(os.path.abspath(os.fspath(output_path))) generate_snapshot(root, output_path, args.depth) except (OSError, ValueError, RuntimeError) as error: print(f"Error: {error}", file=sys.stderr) return 1 print(f"Project tree written to {output_path}") return 0 if __name__ == "__main__": raise SystemExit(main())