Files
customerTicket/project_tree.py
T
2026-09-10 11:34:19 +03:30

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())