264 lines
8.4 KiB
Python
264 lines
8.4 KiB
Python
"""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())
|