Und wieder ein neuer Dateinamenumbenner (Updated)

Stellt hier eure Projekte vor.
Internetseiten, Skripte, und alles andere bzgl. Python.
Antworten
preklov
User
Beiträge: 6
Registriert: Mittwoch 30. Januar 2013, 16:11

Bei der Arbeit, meine CDs und Hörbücher für meinen heimatlichen Medienserver zu digitalisieren und für Sonos verfügbar zu machen, war es immer wieder nötig (und aus verschiedenen Gründen auch nur wünschenswert), die Dateinamen im Batch zu ändern (z.B. verträgt Sonos keine Ruf- und Fragezeichen in Dateinamen!).

Wahrscheinlich gibt es dafür eine Reihe von Programmen. Für Linux habe ich allerdings nichts gefunden, was meinen Wünschen entsprach, die da wären:

- Einfügen und Entfernen von Text oder fortlaufenden Zählungen an festgelegten Stellen,
- Ersetzen von Substrings im ganzen Dateinamen,
- Transliteration von Unicode nach ASCII (inkl. Ersetzung von !,?,´,`),
- Kontrolle vor dem eigentlichen Umbenennen,
- Verhinderung von leeren oder dubletten Dateinamen.

Die Änderungen sollen sich nur auf die eigentlichen Dateinamen, nicht auf die Erweiterungen beziehen (und auch nicht auf Verzeichnisnamen). Eine Ausweitung auf den gesamten Verzeichnisbaum halte ich momentan für zu kompliziert (wie soll z.B. die fortlaufende Zählung adäquat behandelt werden? u.ä.).

Daraus erwuchs ein Python-Skript, in dem ich interessehalber mal die Internationalisierung ausprobiert habe. In diesem Zusammenhang habe ich auch gleich eine Übersetzung einer Reihe von argparse-Ausgaben mit angefasst.

Die Transliterationstabelle berücksichtigt nur die in Mitteleuropa gängigen lateinischen Sonderzeichen. Bei manchen Sprachen, z.B. den baltischen, habe ich nicht gewusst, wie die Zeichen übersetzt werden sollten, daher habe ich sie nicht berücksichtigt, sondern stattdessen einen entsprechenden Hinweis eingebaut.

Habe ich das Rad neu erfunden? Oder ist es viereckig?
Meine Arbeitsumgebung: opensuse 12.1, Python 3.2

Das Skript ist nach den Anregungen und Korrekturvorschlägen von BlackJack überarbeitet.
Ich bitte um Prüfung der aktuellen Fassung:

http://www.python-forum.de/pastebin.php?mode=view&s=330

Die dazu gehörenden Übersetzungsdateien (.po) kann ich bei Interesse posten.

Schöne Grüße
Volker
Zuletzt geändert von preklov am Sonntag 17. März 2013, 14:11, insgesamt 5-mal geändert.
lunar

@preklov Die meisten Programme zum Rippen von Audio-CDs haben doch die Möglichkeit, die Dateien von vorne herein richtig zu benennen. Warum machst Du davon keinen Gebrauch?
preklov
User
Beiträge: 6
Registriert: Mittwoch 30. Januar 2013, 16:11

Ganz einfach: weil die enthaltenen Metadaten entweder völlig unzureichend oder - schlimmer noch - fehlerhaft und häufig in den an sich zusammengehörigen CDs sehr unterschiedlich strukturiert sind. Und die Dateinamen können halt nicht besser sein als die zugrunde liegenden Metadaten.

Übrigens: ich habe gerade festgestellt, dass ich die Docstrings in den Class-Definitionen TestNumbering bzw. SetStart nach Codeänderungen nicht aktualisiert habe. Es ist korrigiert zu:

Code: Alles auswählen

class TestNumbering(argparse.Action):
    def __call__(self, parser, namespace, values, option_string=None):
        'Verify, if argument -s contains an integer number.'
 
bzw.

Code: Alles auswählen

class SetStart(argparse.Action):
    def __call__(self, parser, namespace, values, option_string=None):
        'Verify, if argument -p is an integer or -0.'
 
Gruß
Volker
lunar

@preklov Wieso korrigierst Du die Metadaten der Tracks dann nicht einfach vor dem Rippen?
BlackJack

@preklov: `os.path` braucht man nicht importieren, das `os`-Modul importiert das passende `path`-Modul wenn es das erste mal importiert wird.

Bei `TestNumbering` ist der reguläre Ausdruck ziemlich aufwändig. Zum Teil weil die falsche Funktion verwendet wird: `match()` statt `search()`. Ein ``re.search(r'[0-9]', values)`` würde reichen. Oder ohne `re`: ``any(c.isdecimal() for c in values)``.

Warum bindest Du in den beiden `__init__`-Methoden eigentlich `values` an einen einbuchstabigen Namen? Das macht nicht wirklich Sinn.

Die Verwendung des Begriffs Unicode finde ich etwas problematisch, denn zumindest unter Linux gibt es keine Unicode-Dateinamen — das sind beliebige Bytefolgen wobei '/' und Nullbytes generell eine bestimmte Bedeutung haben und ansonsten noch von den Dateisystemen Einschränkungen gemacht werden können. An der Stelle gruselts mich gerade was Python 3 mit den Kommandozeilenargumenten macht. Das sind auch Bytes und Python 3 scheint die einfach in irgendeiner geratenen Kodierung zu dekodieren. Damit wäre es zum Beispiel nicht möglich Dateinamen an ein Python-Programm zu übergeben die nicht dieser geratenen Kodierung entsprechen, aber durchaus gültige Dateinamen sind. WTF!?

Beim `SetStart` ist die Annahme mit der 1000 vielleicht etwas gefährlich. Das könnte potentiell durchaus ein gültiger Wert sein.

``\`` zur Zeilenfortsetzung versuche ich zu vermeiden. Danach darf kein Zeichen mehr kommen, auch kein „whitespace”. Das kann beispielsweise zu Problemen führen wenn man eine Datei mit Windows-Zeilenenden versucht unter Unix/Linux auszuführen. Dann bekommt man einen Syntaxfehler, den man in den meisten Editoren überhaupt nicht nachvollziehen kann wenn man nicht schon vorher weiss wo das Problem liegt. Man kann die fast immer vermeiden wenn man zusätzliche Klammern verwendet. Manchmal auch durch umformulieren der Bedingung. Das mindestens eine Option gebraucht wird kann man Beispielsweise mit `any()` formulieren:

Code: Alles auswählen

    if not any([args.text, args.cut, args.ascii, args.sernum, args.substold]):
An zwei Stellen verwendest Du das ``\`` auch unnötigerweise weil an den Stellen noch Klammern ”offen” sind, der Compiler also weis, dass die Zeilen noch nicht zuende sind.

Der Rückgabecode, der per `sys.exit()` an das aufrufende Programm zurück gegeben wird, sollte nicht 0 sein wenn es ein Problem gab. Denn 0 bedeutet per Konvention, dass alles in Ordnung war und das Programm fehlerfrei durchgelaufen ist.

Der Test bei `args.ascii` ob die anderen Argumente nur ASCII-Zeichen enthalten muss IMHO nicht sein. Wenn die Transliteration nach den ganzen anderen Operationen stattfindet, würden diese Zeichen ja auch ersetzt.

Bei den Argumenten sollte `path` eigentlich `paths` heissen und der Hilfetext, dass hier Wildcards enthalten sein können ist falsch. Das Programm kann mit Wildcards doch überhaupt nichts anfangen.

Für das Übersetzen von Unicode nach ASCII könntest Du mal dieses Modul anschauen: Unidecode.

In `get_names()` könnte man `collections.defaultdict` gut verwenden. Und man sollte als Werte vielleicht Mengen (`set`) statt Listen verwenden, sonst kann das bei vielen Dateien durchaus schon ziemlich zäh werden. Beim drüberfliegen über den restlichen Quelltext scheint das generell ein Muster zu sein, dass Du Listen verwendest wo man von den Operationen eigentlich Mengen bräuchte.

Insgesamt hat es eine Weile gedauert bis ich die Funktion verstanden habe, weil der DocString nicht wirklich passt. Die ausgewählten Namen von den verbleibenden Namen trennen, da fragt man sich welche von den Namen in `fileList` die ausgewählten sind, und kommt nicht so schnell darauf, dass das *alle* sind und das die „verbleibenden” innerhalb der Funktion noch aus dem Dateisystem gelesen werden. Der Name `restNames` hilft auch überhaupt nicht, denn `rest` ist hier wohl deutsch, hat aber auch im Englischen eine Bedeutung die hier so gar nicht passt. `restNames` würde ich auch nicht am Anfang an einen Namen binden, sondern erst dann, wenn man auch wirklich anfängt damit zu arbeiten. Das verringert die Gefahr von nicht benötigten Quellextzeilen. Wenn man später nämlich feststellt, dass man einen Namen irgendwo weiter unten in einer Funktion *direkt* an den endgültigen Wert binden kann, vergisst man gerne mal die dann unnötige „Initialisierung” weiter oben. Die Funktion könnte dann so aussehen:

Code: Alles auswählen

def get_names(filenames):
    """Get the filenames of the other files in the directories of given paths.
    
    This is required to check for duplicate file names with modified names
    in a directory. So all names are included, files as well as directories.
    """
    dir2selected_filenames = defaultdict(set)
    for fileName in filenames:
        dirname, name = os.path.split(fileName)
        dir2selected_filenames['.' if dirname == '' else dirname].add(name)

    dir2other_filenames = dict(
        (dirname, set(os.listdir(dir)) - names)
        for dirname, names in dir2selected_filenames.items()
    )
    return (dir2selected_filenames, dir2other_filenames)
Den Ansatz finde ich aber aus zwei Gründen nicht gut. Zum einen ganz grundsätzlich weil so ein Test zu dem die Daten dann verwendet werden von der Wirklichkeit überholt werden kann, denn Verzeichnisinhalte sind ja nicht zwingend statisch. Zum anderen haben wir hier zwei parallele Datenstrukturen die herum gereicht werden. Da fängt OOP an sinnvoll zu werden. Oder zumindest eine andere Strukturierung der Daten.

Aber wie gesagt, finde ich diesen Prüfansatz schon nicht gut. Dass man innerhalb der Namen prüft die umbenannt werden sollen ist okay, aber mit den anderen sollte man IMHO entweder gar nicht, oder aber erst unmittelbar vor dem Umbenennen etwas anstellen. Oder, so wie das viele Werkzeuge dieser Art tun, eine Option bieten, die vor dem umbenennen versucht den neuen Namen in etwas mit einer Endung wie '~' oder '.bak' umzubenennen, um Datenverluste zu vermeiden. Letztendlich ist umbenennen immer gefährlich.

Man muss nicht um jeden Preis das verketten von Zeichenketten mit ``+`` vermeiden. In `modify_name()` sind zwei Codestellen die einfacher werden wenn man ``+`` statt `format()` oder gar `join()` mit einer Liste mit zwei Elementen verwendet. Beim Rückgabewert ist `re` ein bisschen überdimensioniert. Die letzten beiden Zeilen könnte man durch das wesentlich simplere ``return baseName.rstrip(' .') + ext`` ersetzen.

Der Kommentar dort ist übrigens eine Information die für *Benutzer* vielleicht interessant wäre. Es können ja durchaus Namen vorkommen die so aussehen: „Something Wonderful 2nd Pt..ogg” und da wäre ich überascht wenn mir beim Nummerieren plötzlich der Punkt von 'Pt.' „geklaut” wird.

In `show_diffs()` sieht man wieder lauter parallele Datenstrukturen. Ausserdem fällt dort auf, dass Du immer Teilübersetzungen zusammensetzt. Das sollte man nicht machen, denn es kann durchaus sein, dass die damit von Dir vorgegebene Reihenfolge nicht in jeder Sprache zu grammatikalisch richtigen Übersetzungen führt. Es kann zum Beispiel sein, dass bei Anzahlen 'x Dinge' in einer anderen Sprache erst das Nomen und *dann* die Anzahl kommt die sich auf das Nomen bezieht. Für Variation bei verschiedenen Anzahlen gibt es ausserdem spezielle Unterstützung in `gettext`, denn das man nur Einzahl bei x=1 und Mehrzahl unterscheidet ist auch nicht in jeder Sprache gegeben. Es gibt auch welche wo keine, ein, zwei, drei, und viele Dinge sich grammatikalisch unterscheiden. Oder welche bei denen sich die Grammatik ändert wenn die Anzahl mit bestimmten Ziffern endet und so weiter. Der Abschnitt Additional functions for plural forms aus dem „GNU gettext”-Handbuch hat Beispiele. Und die Sprachen sind auch nicht alle „exotisch”, es sei denn man sieht unsere polnischen Nachbarn schon als exotisch an. :-)

Wieder parallele Datenstrukturen in `process_names()` und hier wären Mengen statt Listen wieder praktisch.

`msg1`, `msg2`, und `msg3` sind nichtssagende Namen und wie gesagt: Man sollte keine Teilübersetzungen machen.

`count` und das Zusammensetzen von einem fortlaufenden Zähler mit Prä- und Postfix ist IMHO zu undurchsichtig umgesetzt. Das könnte man schön in eine Generatorfunktion kaspeln, die fortlaufende Zeichenketten mit Nummern generiert.

Die Meldung mit `msg2` ist nicht zufriedenstellend. Für jeden Dateinamen den das betrifft, wird eine Meldung ausgegeben, dass mindestens ein Dateiname im Verzeichnis zu kurz wird. An der Stelle möchte man dann doch als Benutzer gerne wissen *welchen* Namen das betrifft. Die Information existiert an der Stelle doch sogar, man müsste sie nur ausgeben.

``continue`` mag ich persönlich nicht so gerne, weil man da in einer tieferen Ebene einen Sprung an den Schleifenanfang ”versteckt”.

Was der Name `nocorr` in `rename_files()` bedeuten soll, habe ich auch nach längerem Überlegen nicht herausgefunden. Mir ist die Bedeutung des Objekts klar, aber die hätte ich nicht vom Namen ablesen können. Und wieder parallele Datenstrukturen.

Den Fehler hätte man vielleicht auch Speichern sollen, damit der Benutzer erfahren kann warum das umbenennen nicht funktioniert hat.

`asciiErrorNames` in der `main()`-Funktion wird als Argument an andere Funktionen übergeben um dort gefüllt zu werden. Da hätte man vielleicht besser mit Rückgabewerten gearbeitet, dann wäre es deutlicher was da passiert.

Die „list comprehension” als ``if``-Bedingung ist komisch. Ein ``if any(newNames.values()):`` ist kürzer, verständlicher, und effizienter wohl auch.

Man kann das Programm gar nicht ohne echten Benutzer laufen lassen, also zum Beispiel von einem Cronjob aus. Die Nachfrage würde ich durch eine Kommandozeilenoption ersetzen die man angeben muss wenn tatsächlich umbenannt werden soll. Man kann auch zusätzlich eine Option für einen interaktiven Modus hinzufügen, bei dem dann per `input()` nachgefragt wird.
preklov
User
Beiträge: 6
Registriert: Mittwoch 30. Januar 2013, 16:11

@lunar: Die Dateien sind in familiärer Arbeitsteilung auf die Schnelle erstellt worden, damit der Medienserver gefüttert werden konnte. Dazu kamen noch Amazon- und iTunes-Downloads sowie Vinyl-Digitalisate und Hörspielmitschnitte. Die Daten wären über Monate nicht verfügbar, wenn jeder Track einzeln hätte bearbeitet werden müssen. Jetzt kann ich in Ruhe das Chaos in die Ordnung zwingen, die mir vorschwebt. Ich mag es halt, wenn nicht nur die Metadaten in Ordnung sind, sondern auch die Dateinamen dem jeweiligen Inhalt entsprechen. Mit Grip bzw. K3b und Audacity haben wir vielleicht auch nicht die benutzungsfreundlichsten Programme gewählt. Wie auch immer, das Skript hilft mir auch bei meinen Fotos.

@BlackJack: Herzlichen Dank für die detaillierten Anmerkungen. Ich hatte gezögert, das Skript zur Ansicht zu stellen, weil ich doch die Besorgnis hatte, die Fachleute könnten mich mit Insider-Chinesisch überfordern und mutlos machen. Immerhin habe ich mich ja auch für meinen Kenntnisstand mit argparse und Internationalisierung ziemlich weit vorgewagt. Dass ich solch eine konstruktive Kritik erfahren würde, hatte ich eigentlich nicht erwartet. Ich bin begeistert.
`os.path` braucht man nicht importieren, das `os`-Modul importiert das passende `path`-Modul wenn es das erste mal importiert wird.
Interessant. Schön.
Bei `TestNumbering` ... ohne `re`: ``any(c.isdecimal() for c in values)``.
Klar.
Warum bindest Du in den beiden `__init__`-Methoden eigentlich `values` an einen einbuchstabigen Namen? Das macht nicht wirklich Sinn.
Stimmt natürlich. War aus einem viel zu wirren Code übrig geblieben.
Die Verwendung des Begriffs Unicode finde ich etwas problematisch ...
Ich werde "UTF-8" verwenden. OK? Ich weiß wohl, dass die Portierung auf andere Systeme problematisch sein kann, aber in die Konsequenzen muss ich mich erst einlesen.
Beim `SetStart` ist die Annahme mit der 1000 vielleicht etwas gefährlich.
Bei meinen Daten zwar nicht, aber man weiß ja nie. Habe ich auf 10000 heraufgesetzt.
``\`` zur Zeilenfortsetzung versuche ich zu vermeiden.
Das war sehr interessant. Werde ich beherzigen.

Der Hinweis auf die von mir vermiedene any-Methode hat mir wieder einmal gezeigt, wie leicht es ist, einmal Gelesenes komplett wieder zu vergessen :-/
Der Rückgabecode per `sys.exit()` ... sollte nicht 0 sein wenn es ein Problem gab.
OK.
Der Test bei `args.ascii` ob die anderen Argumente nur ASCII-Zeichen enthalten muss IMHO nicht sein. Wenn die Transliteration nach den ganzen anderen Operationen stattfindet, würden diese Zeichen ja auch ersetzt.
Technisch gesehen, stimmt das zwar, aber der Nutzer hat sich hier ganz offensichtlich vertan. Das Skript kann nicht entscheiden, ob nun auf ASCII reduziert werden soll oder nicht. Der Nutzer soll neu nachdenken.
Bei den Argumenten sollte `path` eigentlich `paths` heissen und der Hilfetext, dass hier Wildcards enthalten sein können ist falsch. Das Programm kann mit Wildcards doch überhaupt nichts anfangen.
Nein, path ist meines Erachtens korrekt. Es geht doch um den Hilfetext, der von argparse ausgegeben wird. Und der beginnt mit "Usage: ... path [path ...]". Im weiteren Verlauf wird eine Beschreibung der einzelnen Option gegeben. Den Hinweis auf die Jokerzeichen habe ich als Hilfestellung für die Kommandozeilenangabe in Bash gemeint, doch nach weiterem Nachdenken scheint er mir überflüssig.
Für das Übersetzen von Unicode nach ASCII könntest Du mal dieses Modul anschauen: Unidecode.
Sehr interessant. Werde ich mir ansehen.
In `get_names()` könnte man `collections.defaultdict` gut verwenden. Und man sollte als Werte vielleicht Mengen (`set`) statt Listen verwenden ...
Defaultdict kannte ich noch gar nicht. Man vermeidet damit wohl die Konstruktion mit dem KeyError. Oder gibt es noch weitere Vorteile? Mengen statt Listen: werde ich ausprobieren.

Danke auch für die (sehr viel klarere) get_names-Funktion. Die Anregung, die Dateien vor dem Umbenennen zu sichern, werde ich prüfen. Ein bak-Verzeichnis würde mir wohl gefallen. Andererseits habe ich tatsächlich übersehen, dass zwischen der Dublettenprüfung und dem Umbenennen die Denkpause des Nutzers liegt, die ja durchaus Stunden dauern kann. Diese Änderung ist absolut notwendig.
Man muss nicht um jeden Preis das verketten von Zeichenketten mit ``+`` vermeiden.
Tja, da bin ich wohl einer Expertenmeinung aufgesessen oder ich habe sie vielleicht zu absolut aufgefasst.

Was Leerzeichen oder Punkt am Ende des eigentlichen Dateinamens betrifft, so habe ich die Entfernung derselben erst eingefügt, als ich in Wikipedia gelesen habe, dass Leerzeichen unter Windows nicht erlaubt sind. Da habe ich die unschönen Punkte gleich mit entsorgt. Ich sehe deinen Einwand ein und werde mich auf Entfernung von Leerzeichen beschränken.

Dein Hinweis auf ngettext war sehr erhellend. Dass es unterschiedliche Pluralformen bei Zählungen gibt, war mir schon bewusst, aber ich war erst einmal schon sehr zufrieden damit, dass ich gettext ans Laufen bekommen hatte. Nun wird alles noch schöner. Übrigens ist Polnisch wahrlich nicht exotisch, so wenig wie Sorbisch. Als ich im Zusammenhang mit der UTF-8-Umsetzung Probleme mit Zeichen in den baltischen Sprachen erwähnte, dann nur deswegen, weil ich nicht die üblichen Regeln kenne, und nicht, weil ich sie für exotisch hielte.
`count` und das Zusammensetzen von einem fortlaufenden Zähler mit Prä- und Postfix ist IMHO zu undurchsichtig umgesetzt. Das könnte man schön in eine Generatorfunktion kaspeln, die fortlaufende Zeichenketten mit Nummern generiert.
Das hatte ich schon ausprobiert. Mir erschien der Algorithmus aber so simpel, dass ich wieder auf die drei Zeilen in process_names zurückgekommen bin.
Die Meldung mit `msg2` ist nicht zufriedenstellend. Für jeden Dateinamen den das betrifft, wird eine Meldung ausgegeben, dass mindestens ein Dateiname im Verzeichnis zu kurz wird. An der Stelle möchte man dann doch als Benutzer gerne wissen *welchen* Namen das betrifft.
Die Meldung wird nur beim ersten Auftreten ausgegeben. Dann bricht die Bearbeitung in diesem Verzeichnis ab. Der Nutzer soll genauer hinschauen. Allerdings wäre eine Angabe des ursprünglichen Dateinamens hilfreich.
... der Name `nocorr` in `rename_files()` ...
OK, der Name ist nicht gut gewählt, auf Anhieb fällt mir momentan kein guter ein. Ist überhaupt immer wieder eine Qual, gute Variablennamen zu finden.
Man kann das Programm gar nicht ohne echten Benutzer laufen lassen ...
Stimmt. In dieser Form will ich das auch gar nicht. Schon allein die Gegenüberstellung der originalen mit den veränderten Dateinamen zielt direkt darauf, dass die optische Kontrolle das A und O ist. Wie oft habe ich damit mögliche Alternativen getestet! Für einen Cronjob bräuchte man sicher erhebliche Kontrollmechanismen der verwendeten Dateinamensstrukturen. Das wäre ein ganz anderes Skript.

Schöne Grüße
Volker
BlackJack

@preklov: Beim Begriff „Unicode” hast Du ja eigentlich Recht — an der Stelle liefert Python 3 ja tatsächlich Unicode-Objekte. Das Problem sehe ich da eher bei Python (3).

Ob nun 1000 oder 10000 — es bleibt eine magische Zahl die willkührlich eine andere Bedeutung hat als die anderen Zahlen, die an der Stelle zugewiesen werden könnten. So etwas ist immer ein wenig unschön.

Im Code ist `path` aber falsch. Für die Anzeige bei ``--help`` wird das doch auch gar nicht verwendet, sondern das was bei `metavar` angegeben wurde. Da kann man ja `path` nehmen, beziehungsweise übersetzt Du das ja sogar.

Was Zeichenketten und ``+`` angeht: Das sollte man nicht in Schleifen wiederholt anwenden um damit eine immer länger werdende Zeichenkette zusammenzusetzen. Da Zeichenketten unveränderbar sind, muss da jedes mal eine neue Zeichenkette erstellt werden in die die alten beiden hineinkopiert werden. Damit handelt man sich schnell quadratische Laufzeit in Abhängigkeit von der Länge der Zeichenkette ein.
preklov
User
Beiträge: 6
Registriert: Mittwoch 30. Januar 2013, 16:11

Mittlerweile habe ich das Skript gemäß den Anregungen und Fehlerhinweisen von BlackJack überarbeitet (s. erstes Posting) und dabei eine Reihe von Erkenntnissen gewonnen, die ich wohl hoffentlich nicht vergessen werden. Sogar der Zählungsgenerator ist wieder zu Ehren gekommen.

@Blackjack, noch einmal großen Dank. Bestimmt ist noch nicht alles so, wie es sein könnte.

Schöne Grüße
Volker
Antworten