Das Problem: Ordner oder Laufwerke katalogisieren
Vor kurzem wurde mir von Kollegen im Projekt die Frage gestellt, ob man mit Python nicht den Inhalt von Laufwerken katalogisieren könne. Natürlich geht das, und der Aufwand hierfür ist so überschaubar, dass ich hier das Beispiel nutzen möchte, um die wichtigsten Best-Practice-Empfehlungen für das Arbeiten mit Laufwerkspfaden zu erläutern.
Hürde 1: Wie gebe ich den Pfad richtig an?
Nehmen wir an, wir wollen einen speziellen Pfad genauer katalogisieren. Ich wähle als einigermaßen reproduzierbares Beispiel ein User-Verzeichnis auf einem Windows-10-System:
path_dir: str = "C:\Users\sselt\Documents\blog_demo"
Die Variablenzuweisung wird bei Ausführung sofort mit einem Fehler quittiert:
SyntaxError: (unicode error) 'unicodeescape' codec can't decode bytes in position 2-3: truncated \UXXXXXXXX escape
Der Interpreter kommt nicht mit der Zeichenfolge \U klar, da Unicode-Zeichen mit ähnlicher Folge eingeleitet werden. Die Situation haben wir dem Problem zu verdanken, dass Windows-Systeme als Pfadtrenner „\“ und Linux-Systeme „/“ verwenden. Dummerweise ist der Windows-Trenner gleichzeitig die Einleitung für diverse Sonderzeichen oder Escapes in der Unicode-Kodierung, und schon haben wir das Durcheinander. Da sich die Systeme genauso wenig in absehbarer Zeit angleichen werden wie Dezimaltrennzeichen verschiedener Länder, müssen wir hier zu einer von drei Lösungen greifen.
Lösung 1, die hässliche Variante:
Man vermeidet Windows-Pfadtrenner komplett und schreibt den Pfad von Anfang an mit Linux-Trennern:
path_dir: str = "C:/Users/sselt/Documents/blog_demo"
Der Interpreter evaluiert den Pfad dann korrekt, als wäre es von Anfang an ein Linux-System.
Lösung 2, die noch hässlichere Variante:
Man verwendet Escape-Sequenzen.
path_dir: str = "C:\\Users\sselt\Documents\\blog_demo"
Neben der Unleserlichkeit stört mich daran, dass man nicht bei jeder Buchstaben-Trenner-Kombination escapen muss. Hier halt nur vor dem „U“ und dem „b“.
Lösung 3, die elegante:
Man verwendet Raw-Strings und setzt „r“ als Prefix vor den String, um zu signalisieren, dass Sonderzeichen nicht evaluiert werden sollen.
path_dir: str = r"C:\Users\sselt\Documents\blog_demo"
Hürde 2: Scannen der Files
Zurück zur Aufgabe: Wir wollen zunächst alle Elemente eines Ordners auflisten. Den Pfad haben wir bereits.
Mit dem einfachen Befehl os.listdir erhalten wir damit die Auflistung als Liste von Strings, und zwar nur die Dateinamen ohne Pfad.
Ich verwende hier und in allen übrigen Beispielen Type Hinting als zusätzliche Dokumentation des Codes. Diese Schreibweisen sind erst ab Python 3.5 verfügbar.
import os
from typing import List
path_dir: str = r"C:\Users\sselt\Documents\blog_demo"
content_dir: List[str] = os.listdir(path_dir)
Die Dateiauflistung ist erstmal fein, mich interessieren aber hier noch die Statistiken der Dateien. Hierfür gibt es os.stat.
Hürde 3: Verketten von Pfaden
Um den Dateipfad zu übergeben, müssen wir erst Dateinamen und Pfad kombinieren. Hierzu habe ich in freier Wildbahn schon oft folgende Konstrukte gesehen und selbst auch in meiner Anfängerzeit so eingesetzt. Zum Beispiel:
path_file: str = path_dir + "/" + filename
path_file: str = path_dir + "\\" + filename
path_file: str = "{}/{}".format(path_dir, filename)
path_file: str = f"{path_dir}/{filename}"
A und B sind hässlich, weil sie Strings mit „+“ verketten. Dazu gibt es in Python keinen Grund.
B ist dabei besonders hässlich, weil man unter Windows ein doppeltes Trennzeichen braucht, sonst wird es als Escape-Sequenz für die schließenden Anführungszeichen gewertet.
C und D sind etwas schöner, da sie String-Formatierungen verwenden. Sie lösen aber noch nicht das Problem der Systemabhängigkeit. Wenn ich unter Windows das Ergebnis ausgebe, erhalte ich nämlich einen funktionierenden, aber inkonsistenten Pfad mit meinem Mix aus Trennern:
filename = "some_file"
print("{}/{}".format(path_dir, filename))
...: 'C:\\Users\\sselt\\Documents\\blog_demo/some_file'
Betriebssystemunabhängige Lösung
Hierfür gibt es eine Lösung seitens Python, nämlich os.sep bzw. os.path.sep. Beide geben die Pfadtrenner des jeweiligen Systems zurück. Sie sind in ihrer Funktion identisch, die zweite explizitere Schreibweise macht jedoch unmittelbar klar, um welchen Separator es sich handelt.
Also könnte man schreiben:
path_file = "{}{}{}".format(path_dir, os.sep, filename)
Das erzeugt ein besseres Ergebnis, allerdings zu Kosten eines unübersichtlicheren Codes, wenn man mehrere Pfadabschnitte kombinieren würde.
Es hat sich daher als Konvention eingebürgert, die Pfadelemente über die Stringverkettung zu kombinieren. Das ist noch kürzer und generischer:
path_file = os.sep.join([path_dir, filename])
Ein erster Gesamtansatz
Wenden wir das auf unser Verzeichnis an:
for filename in os.listdir(path_dir):
path_file = os.sep.join([path_dir, filename])
print(os.stat(path_file))
Unter anderem erhalten wir als Ergebnis (nicht dargestellt) st_atime, die Zeit des letzten Zugriffes (access time), st_mtime für die letzte Veränderung (modification time), st_ctime für den Zeitpunkt der Erstellung (creation time). Zusätzlich enthält st_size die Größe des Files in Bytes. Mich interessiert im Moment nur die Größe und das letzte Veränderungsdatum. Ich wähle ein einfaches Listenformat für die Speicherung.
import os
from typing import List, Tuple
filesurvey: List[Tuple] = []
content_dir: List[str] = os.listdir(path_dir)
for filename in content_dir:
path_file = os.sep.join([path_dir, filename])
stats = os.stat(path_file)
filesurvey.append((path_dir, filename, stats.st_mtime, stats.st_size))
Finale Funktion mit Rekursion
Das Ergebnis daraus ist auf den ersten Blick zufriedenstellend. Es ergeben sich jedoch zwei neue Probleme. Listdir unterscheidet nicht zwischen Dateien und Ordnern. Listdir geht auch nur von der Ebene eines Ordners aus und bearbeitet nicht die Unterordner.
Wir benötigen also eine rekursive Funktion, die zwischen Ordner und Datei unterscheidet. os.path.isdir prüft für uns, ob sich hinter einem Pfad ein Ordner verbirgt.
def collect_fileinfos(path_directory: str, filesurvey: List[Tuple]):
content_dir: List[str] = os.listdir(path_directory)
for filename in content_dir:
path_file = os.sep.join([path_directory, filename])
if os.path.isdir(path_file):
collect_fileinfos(path_file, filesurvey)
else:
stats = os.stat(path_file)
filesurvey.append((path_directory, filename, stats.st_mtime, stats.st_size))
filesurvey: List[Tuple] = []
collect_fileinfos(path_dir, filesurvey)
Nutzbarmachen der Ergebnisse als Dataframe
Fertig! In einer Funktion von weniger als zehn Zeilen ist das Problem gelöst. Da ich das Ergebnis filesurvey als Liste von Tupeln geplant habe, kann ich das Ergebnis problemlos auch in einen Pandas-Dataframe überführen und dort für Analysen nutzen, wie z.B. Speichersummen über Ordner hinweg.
import pandas as pd
df: pd.DataFrame = pd.DataFrame(filesurvey, columns=('path_directory', 'filename', 'st_mtime', 'st_size'))
... leider noch kein VERY Best Pratice
Ich weiß, der Blogeintrag versprach eigentlich, das Problem mit Best-Practice-Mitteln zu lösen. Vor einigen Jahren hätten meine Ausführungen tatsächlich den Titel auch verdient, aber Python entwickelt sich immer noch weiter und selbst bei solchen einfachen Use Cases werden noch Verbesserungen möglich. In einem zweiten Teil werde ich diesen Use Case nochmals aufgreifen und mit eleganteren Methoden lösen.
Lesen Sie hier den zweiten Teil des Blogbeitrags.