graphify add
This commit is contained in:
1 parent
f19d8ee6a7
commit
91768be145
20 files changed
+21700
-1
No files matched your search
+263
@@ -0,0 +1,263 @@
|
||||
"""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())
|
||||
Reference in new issue
Block a user