treeScannerASCII/scanner.py
2025-05-02 09:46:13 +02:00

234 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""
© 2025 Adam Skotarczak (adam@skotarczak.net) v0.3.1 (01.05.2025)
A standalone directory scanner that can also be integrated as a module.
In its current version, simply place the file into any directory and execute it with python scanner.py.
The script generates a tree.txt file containing the complete directory structure in ASCII format.
I developed this tool to conveniently document directory trees, particularly for use in Markdown READMEs.
usage: scanner.py [-h] [-n MAX_FILES_PER_DIR] [-d MAX_DEPTH] [--no-align-comments] [-l {de,en}] [--ignore IGNORE]
[-o OUTPUT]
[root_path]
Generiert eine ASCII-Baumstruktur eines Verzeichnisses.
positional arguments:
root_path Pfad des Stammverzeichnisses (default: aktuelles Verzeichnis).
options:
-h, --help show this help message and exit
-n, --max-files-per-dir MAX_FILES_PER_DIR
Maximale Anzahl Dateien pro Verzeichnis (default: 2).
-d, --max-depth MAX_DEPTH
Maximale Rekursionstiefe; unbegrenzt, wenn nicht gesetzt.
--no-align-comments Deaktiviert das Ausrichten der Kommentare am Zeilenende.
-l, --language {de,en}
Sprache der Programmausgabe (de oder en).
--ignore, -x IGNORE Verzeichnisse, die ignoriert werden sollen (z.B. .git, __pycache__). Mehrfach verwendbar.
-o, --output OUTPUT Pfad und Name der Ausgabedatei (Standard: tree.txt)
Original File: <https://github.com/realAscot/treeScannerASCII/blob/main/scanner.py>
"""
import os
import argparse
import time
from typing import Optional, List
class TreeScannerConfig:
"""Konfigurationsklasse für den TreeScanner.
Attributes:
root_path (str): Pfad des Stammverzeichnisses.
folder_icon (str): Icon für Ordner.
file_icon (str): Icon für Dateien und Platzhalter.
max_files_per_dir (int): Maximale Anzahl Dateien pro Verzeichnis.
max_depth (Optional[int]): Maximale Rekursionstiefe.
align_comments (bool): Kommentare am Zeilenende ausrichten.
language (str): Sprache der Programmausgabe (de oder en).
output_file (str): Pfad und Name der Ausgabedatei.
ignored_dirs (Optional[List[str]]): Liste von Verzeichnissen, die ignoriert werden sollen.
"""
def __init__(
self,
root_path: str = ".",
folder_icon: str = "\U0001F4C1",
file_icon: str = "\U0001F4C4",
max_files_per_dir: int = 100,
max_depth: Optional[int] = None,
align_comments: bool = True,
language: str = "de",
output_file: str = "tree.txt",
ignored_dirs: Optional[List[str]] = None
):
self.root_path = root_path
self.folder_icon = folder_icon
self.file_icon = file_icon
self.max_files_per_dir = max_files_per_dir
self.max_depth = max_depth
self.align_comments = align_comments
self.language = language
self.output_file = output_file
self.ignored_dirs = ignored_dirs or []
class TreeScanner:
"""Klasse zum Scannen von Verzeichnissen und Erzeugen einer ASCII-Baumstruktur."""
def __init__(self, config: TreeScannerConfig):
"""Initialisiert den TreeScanner.
Args:
config (TreeScannerConfig): Konfiguration für den Scanner.
"""
self.last_output = time.time()
self.config = config
self.folder_count = 0
self.file_count = 0
self.messages = {
"de": {
"summary": "Es wurden {folders} Verzeichnisse und {files} Dateien gescannt. Datei {file} geschrieben."
},
"en": {
"summary": "Scanned {folders} folders and {files} files. File {file} written."
}
}
def scan_directory(self, path: str, depth: int = 0, prefix: str = "") -> List[str]:
"""Scannt ein Verzeichnis und gibt eine Liste von ASCII-Zeilen zurück.
Args:
path (str): Pfad des zu scannenden Verzeichnisses.
depth (int): Aktuelle Rekursionstiefe.
prefix (str): Präfix für Einrückung und Connectoren.
Returns:
List[str]: Zeilen der Baumstruktur.
"""
lines: List[str] = []
try:
entries = sorted(os.listdir(path))
except PermissionError:
return [f"{prefix}└── [Zugriff verweigert] {path}"]
folders = [e for e in entries if os.path.isdir(os.path.join(path, e)) and e not in self.config.ignored_dirs]
files = [e for e in entries if os.path.isfile(os.path.join(path, e))]
for idx, folder in enumerate(folders):
self.folder_count += 1
folder_path = os.path.join(path, folder)
connector = "├── " if idx < len(folders) - 1 or files else "└── "
lines.append(f"{prefix}{connector}{self.config.folder_icon} {folder}")
if self.config.max_depth is None or depth < self.config.max_depth:
extension = "" if idx < len(folders) - 1 or files else " "
lines.extend(self.scan_directory(folder_path, depth + 1, prefix + extension))
visible_files = files[: self.config.max_files_per_dir]
remaining = len(files) - len(visible_files)
combined = visible_files.copy()
if remaining > 0:
combined.append(f"<und {remaining} weitere Dateien>")
for idx, name in enumerate(combined):
if not name.startswith("<und "):
self.file_count += 1
if time.time() - self.last_output >= 5:
print(f"[Info] {self.folder_count + self.file_count} Einträge gescannt...", flush=True)
self.last_output = time.time()
connector = "├── " if idx < len(combined) - 1 else "└── "
lines.append(f"{prefix}{connector}{self.config.file_icon} {name}")
return lines
def align_lines_with_comments(self, lines: List[str]) -> List[str]:
"""Richtet alle Zeilen so aus, dass Kommentare am gleichen Spaltenindex stehen.
Args:
lines (List[str]): Liste der Baumstruktur-Zeilen ohne Kommentare.
Returns:
List[str]: Zeilen mit ausgerichteten Kommentar-Platzhaltern (#).
"""
max_length = max(len(line.rstrip()) for line in lines)
aligned: List[str] = []
for line in lines:
text = line.rstrip()
padding = " " * (max_length - len(text) + 2)
aligned.append(text + padding + "# ")
return aligned
def generate_tree(self) -> str:
"""Generiert den vollständigen Verzeichnisbaum als String.
Returns:
str: Mehrzeiliger String mit Ordner-Icon, Ordner- und Dateienstruktur.
"""
root_name = os.path.basename(os.path.abspath(self.config.root_path)) or self.config.root_path
lines = [f"{self.config.folder_icon} {root_name}/"]
lines += self.scan_directory(self.config.root_path)
if self.config.align_comments:
lines = self.align_lines_with_comments(lines)
return "\n".join(lines)
def print_summary(self, output_file: str) -> None:
"""Gibt eine Zusammenfassung des Scanlaufs aus.
Args:
output_file (str): Name der geschriebenen Ausgabedatei.
"""
message_template = self.messages.get(self.config.language, self.messages["de"])
print(message_template["summary"].format(folders=self.folder_count, files=self.file_count, file=output_file))
def main():
"""Standalone-Ausführung mit CLI-Parameter-Unterstützung."""
parser = argparse.ArgumentParser(description="Generiert eine ASCII-Baumstruktur eines Verzeichnisses.")
parser.add_argument("root_path", nargs="?", default=".", help="Pfad des Stammverzeichnisses (default: aktuelles Verzeichnis).")
parser.add_argument("-n", "--max-files-per-dir", type=int, default=2, help="Maximale Anzahl Dateien pro Verzeichnis (default: 2).")
parser.add_argument("-d", "--max-depth", type=int, help="Maximale Rekursionstiefe; unbegrenzt, wenn nicht gesetzt.")
parser.add_argument("--no-align-comments", action="store_false", dest="align_comments", help="Deaktiviert das Ausrichten der Kommentare am Zeilenende.")
parser.add_argument("-l", "--language", type=str, default="de", choices=["de", "en"], help="Sprache der Programmausgabe (de oder en).")
parser.add_argument(
"--ignore", "-x",
action="append",
help="Verzeichnisse, die ignoriert werden sollen (z.B. .git, __pycache__). Mehrfach verwendbar."
)
parser.add_argument("-o", "--output", type=str, help="Pfad und Name der Ausgabedatei (Standard: tree.txt)")
args = parser.parse_args()
output_file = args.output if args.output else "tree.txt"
ignored_dirs = args.ignore if args.ignore else []
# Pfad validieren
if not os.path.isdir(args.root_path):
print(f"Fehler: Der angegebene Pfad '{args.root_path}' ist kein gültiges Verzeichnis oder anderer falscher Parameter.")
return
output_dir = os.path.dirname(output_file)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
config = TreeScannerConfig(
root_path=args.root_path,
max_files_per_dir=args.max_files_per_dir,
max_depth=args.max_depth,
align_comments=args.align_comments,
language=args.language,
output_file=output_file,
ignored_dirs=ignored_dirs
)
scanner = TreeScanner(config)
tree_output = scanner.generate_tree()
with open(config.output_file, "w", encoding="utf-8") as f:
f.write(tree_output + "\n")
scanner.print_summary(config.output_file)
if __name__ == "__main__":
main()