[Add] Team section & teams post
This commit is contained in:
parent
723e3762ec
commit
c2d03efc57
15 changed files with 1063 additions and 53 deletions
369
file_graph_generator.py
Normal file
369
file_graph_generator.py
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
"""
|
||||
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()
|
||||
Loading…
Reference in a new issue