Anarchyphase-Website/file_graph_generator.py
2026-04-22 13:52:22 +02:00

369 lines
12 KiB
Python

"""
File Graph Generator
Creates a tree-like visualization of files with their content,
respecting .gitignore patterns with proper tree rendering.
"""
import argparse
import fnmatch
import logging
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterator, List, Optional
# ───────────────────────────── Logging Setup ──────────────────────────────
def setup_logging(level: int = logging.INFO) -> None:
"""Configure structured logging to stderr (stdout is reserved for output)."""
logging.basicConfig(
stream=sys.stderr,
level=level,
format="%(asctime)s | %(levelname)-8s | %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("file_graph")
# ─────────────────────────── Gitignore Engine ─────────────────────────────
@dataclass
class GitignoreRule:
"""A single parsed gitignore rule."""
pattern: str
is_negation: bool = False
is_dir_only: bool = False
is_anchored: bool = False
is_relative: bool = False # contains / (not at start)
class GitignoreParser:
"""Parse a .gitignore file into ordered rules."""
@staticmethod
def parse(path: Path) -> List[GitignoreRule]:
rules = []
if not path.exists():
return rules
try:
with path.open("r", encoding="utf-8", errors="ignore") as f:
for line in f:
line = line.rstrip("\n\r")
# Skip empty lines and comments
if not line or line.startswith("#"):
continue
is_negation = line.startswith("!")
if is_negation:
line = line[1:]
# Handle escaped ! and #
if line.startswith("\\!") or line.startswith("\\#"):
line = line[1:]
line = line.rstrip()
if not line:
continue
is_dir_only = line.endswith("/")
if is_dir_only:
line = line[:-1]
is_anchored = line.startswith("/")
if is_anchored:
line = line[1:]
is_relative = "/" in line
rules.append(
GitignoreRule(
pattern=line,
is_negation=is_negation,
is_dir_only=is_dir_only,
is_anchored=is_anchored,
is_relative=is_relative,
)
)
except OSError as e:
logger.warning(f"Could not read {path}: {e}")
return rules
class GitignoreChecker:
"""Check if paths should be ignored, respecting per-directory .gitignore files."""
def __init__(self, root: Path):
self.root = root.resolve()
self._cache: dict[Path, List[GitignoreRule]] = {}
self._skip_dirs = {
".git",
"__pycache__",
".pytest_cache",
".mypy_cache",
".tox",
".venv",
"venv",
}
self._skip_files = {
"file_graph_generator.py",
"file_graph_generator_v2.py",
"file_graph_generator_v3.py",
"file_tree.txt",
}
def _load(self, directory: Path) -> List[GitignoreRule]:
"""Load rules for a directory (cached)."""
directory = directory.resolve()
if directory not in self._cache:
gitignore_path = directory / ".gitignore"
if gitignore_path.exists():
logger.debug(f"Parsing gitignore: {gitignore_path}")
self._cache[directory] = GitignoreParser.parse(gitignore_path)
return self._cache[directory]
def _match_rule(
self, rule: GitignoreRule, name: str, rel_path: str, is_dir: bool
) -> bool:
"""Check if a single rule matches."""
if rule.is_dir_only and not is_dir:
return False
# Anchored pattern: match only at .gitignore level
if rule.is_anchored:
return fnmatch.fnmatch(rel_path, rule.pattern) or fnmatch.fnmatch(
rel_path, rule.pattern + "/"
)
# Relative path pattern (contains /): match against rel_path
if rule.is_relative:
return fnmatch.fnmatch(rel_path, rule.pattern) or fnmatch.fnmatch(
rel_path, rule.pattern + "/"
)
# Basename pattern: match name at any level
return fnmatch.fnmatch(name, rule.pattern)
def is_ignored(self, path: Path, is_dir: bool) -> bool:
"""Check if path should be ignored, walking up to root."""
name = path.name
# Fast-path: always skip common VCS/build dirs and script/output files
if is_dir and name in self._skip_dirs:
logger.debug(f"Skipping common dir: {name}")
return True
if not is_dir and name in self._skip_files:
logger.debug(f"Skipping script/output file: {name}")
return True
# Walk from current directory up to root
current = path.parent.resolve()
while True:
rules = self._load(current)
if rules:
rel_path = path.relative_to(current).as_posix()
ignored = False
for rule in rules:
if self._match_rule(rule, name, rel_path, is_dir):
if rule.is_negation:
ignored = False
else:
ignored = True
if ignored:
logger.debug(f"Ignored by gitignore: {path}")
return True
if current == self.root:
break
current = current.parent
return False
# ──────────────────────────── File Reading ────────────────────────────────
MAX_CONTENT_BYTES = 50 * 100 # ~50 lines avg
def is_binary(path: Path) -> bool:
"""Fast binary detection using first 8KB chunk."""
try:
with path.open("rb") as f:
chunk = f.read(8192)
if not chunk:
return False
if b"\x00" in chunk:
return True
if chunk[:2] in (b"\xff\xfe", b"\xfe\xff", b"\x1f\x8b"):
return True
return False
except OSError:
return True
def read_content(path: Path, max_lines: int = 50) -> Iterator[str]:
"""Yield content lines with minimal memory footprint."""
if is_binary(path):
yield "[binary file — content skipped]"
return
try:
with path.open("r", encoding="utf-8", errors="replace") as f:
for i, line in enumerate(f):
if i >= max_lines:
remaining = sum(1 for _ in f)
yield f"... ({remaining} more lines)"
return
yield line.rstrip("\n")
except OSError as e:
yield f"[Error reading file: {e}]"
# ─────────────────────────── Tree Generator ───────────────────────────────
def get_visible_children(path: Path, checker: GitignoreChecker) -> List[os.DirEntry]:
"""Get sorted, filtered children of a directory."""
try:
entries = list(os.scandir(path))
except PermissionError:
logger.warning(f"Permission denied: {path}")
return []
except OSError as e:
logger.error(f"Error scanning {path}: {e}")
return []
visible = []
for entry in entries:
# Skip .gitignore files entirely
if entry.name == ".gitignore":
continue
child_path = Path(entry.path)
is_dir = entry.is_dir()
if not checker.is_ignored(child_path, is_dir):
visible.append(entry)
# Sort: dirs first, then files, both alphabetically (case-insensitive)
visible.sort(key=lambda e: (not e.is_dir(), e.name.lower()))
return visible
def build_tree_lines(
path: Path,
checker: GitignoreChecker,
prefix: str = "",
max_lines: int = 50,
max_depth: Optional[int] = None,
current_depth: int = 0,
) -> Iterator[str]:
"""Recursively yield tree lines. Correctly handles is_last filtering."""
if path.is_dir():
if max_depth is not None and current_depth >= max_depth:
return
children = get_visible_children(path, checker)
count = len(children)
for i, entry in enumerate(children):
is_last = i == count - 1
connector = "└── " if is_last else "├── "
name = entry.name
if entry.is_dir():
yield prefix + connector + name + "/"
extension = " " if is_last else ""
yield from build_tree_lines(
Path(entry.path),
checker,
prefix + extension,
max_lines,
max_depth,
current_depth + 1,
)
else:
yield prefix + connector + name
content_prefix = prefix + (" " if is_last else "") + " "
for content_line in read_content(Path(entry.path), max_lines):
yield content_prefix + content_line
# ─────────────────────────────── Main ─────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="File tree generator with gitignore support",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s # Scan current directory
%(prog)s ./src -o tree.txt # Scan ./src, write to tree.txt
%(prog)s --max-depth 3 # Limit depth to 3 levels
%(prog)s --max-lines 20 # Show only 20 lines per file
%(prog)s -v # Verbose logging
%(prog)s --no-gitignore # Ignore all .gitignore files
""",
)
parser.add_argument(
"directory", nargs="?", default=".", help="Root directory to scan"
)
parser.add_argument("-o", "--output", default="file_tree.txt", help="Output file")
parser.add_argument(
"--max-depth", type=int, default=None, help="Maximum directory depth"
)
parser.add_argument(
"--max-lines", type=int, default=50, help="Max lines to show per file"
)
parser.add_argument(
"--no-gitignore", action="store_true", help="Disable .gitignore processing"
)
parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable debug logging"
)
args = parser.parse_args()
setup_logging(logging.DEBUG if args.verbose else logging.INFO)
root = Path(args.directory).resolve()
if not root.is_dir():
logger.error(f"Not a directory: {root}")
sys.exit(1)
checker = GitignoreChecker(root)
if args.no_gitignore:
# Disable by making all paths return empty rules
checker._cache = {d: [] for d in checker._cache}
# Also override _load to always return empty
checker._load = lambda d: []
logger.info("Gitignore processing disabled")
try:
with open(args.output, "w", encoding="utf-8") as f:
# Print root
f.write(root.name + "/\n")
# Print tree
for line in build_tree_lines(
root, checker, max_lines=args.max_lines, max_depth=args.max_depth
):
f.write(line + "\n")
logger.info(f"Output written to: {args.output}")
except OSError as e:
logger.error(f"Failed to write output: {e}")
sys.exit(1)
if __name__ == "__main__":
main()