15. October 2018

Eine praktische Aufgabe lösen mit sed, dem Stream Editor in Linux

Filed under: computer,freie software,Linux — zettberlin @ 07:14

Der StreamEDitor sed ist ein programmierbarer Texteditor. Sein Vorteil gegenüber normalen, interaktiven Editoren wie VIM oder einem beliebigen grafischen Texteditor ist, dass man für sed Vorgänge programmieren kann, die man nicht per Hand ausführen möchte oder kann. Meistens geht es dabei natürlich um das Suchen und Ersetzen von Textbestandteilen.

Ich möchte an dieser Stelle eine praktische Arbeit erklären, die ich vor kurzem mit sed gelöst habe:

Das Problem: ein fehlerhafter CSV Export einer Excel Datei

Die folgenden 4 Zeilen CSV sind ein Ausschnitt aus einem Dokument, das insgesamt 500 derartige Zeilen enthält.

"CS",GMA_BF_F1,"F1",,,,,,,,"0,05","0,1",-,-,-,-,,,,,,,,,,,,,,,,,,,,,2018,
"CS",GMA_BF_F2,"F 2",,,,,,,,"0,07","0,11",-,-,-,-,,,,,,,,,,,,,,,,,,,,,2016,
"CS",GMA_BF_CDD,"Bo F",,,,,"28,4","39,6","6,5","0,59","0,63",-,-,-,-,,,,,,,,,,,,,,,,,,,,,2018,
"CS",GMA_BF_CDD_F1,"F 1",,,,,,,,"0,57","0,59",-,-,-,-,,,,,,,,,,,,,,,,,,,,,2017,

Wie man sieht, handelt es sich um gleichartige Zeilen, die eine Art ID im zweiten Feld und eine Jahreszahl im vorletzten Feld enthalten. Die Daten dazwischen sind Messwerte, die in Fließkommazahlen angegeben werden.

Hier das Problem: die Spezialisten, die die Werte eingeben, sind frankophon. Das bedeutet: sie verwenden das Dezimalkomma und nicht, wie Angelsachsen, einen Dezimalpunkt.

Für einen Menschen ist das kein Problem: 28,4 und 28.4 ist für einen Menschen ohne weiteres Achtungzwanzig Komma Vier. Roboter sind allerdings doof, für sie sind Punkt und Komma genauso unterschiedlich wie X und ?. Das müssen sie auch sein, sonst würden wir die Maschinen kaum beherrschen können.

Ganz praktisch wirkt sich der Unterschied so aus:

  • Wenn die Zeilen eingelesen werden, um sie als einzelne Felder in eine Datenbank zu importieren, verwendet der Mechanismus, der die Zeile in Felder zerhackt, das Komma als Trennzeichen(sog. Delimiter). In menschlicher Sprache ausgedrückt:
      Lese diese Datei Zeile für Zeile, am Ende der Zeile steht das Zeichen für den Zeilenumbruch.
      Lege für jedes Zeichen in der Zeile einen durchnummerierten Speicher an, bis Du ein Komma siehst
      Dann lege einen weiteren Speicher an, bis zum Ende der Zeile.
     

    Und für den Computer in die Programmiersprache PHP übersetzt:

      $speicher=array();
      foreach ($zeilen as $zeile){
       $speicher[] = explode(",",$zeile);
      }
     
  • Das Ergebnis ist ein geordneter Datensatz, der Array genannt wird, der enthält 500 Einträge. In jedem dieser Einträge steht wiederum ein Array (also ein geordneter Datensatz als Eintrag in einem geordneten Datensatz). Und dieses enthält für jeden Abschnitt einer Zeile, der links von einem Komma steht, einen nummerierten Eintrag.

    Schau Dir die Zeilen oben an….
    Wo ist das Problem?
    Natürlich! Die Kommata in den französischen Fließkommazahlen!

    Wenn der Algorithmus von explode() einfach immer einen neuen Eintrag anlegt, wenn er ein Komma sieht, haben wir schon in der ersten Zeile einen Eintrag mit: “0 und gleich danach einen mit 05″. Korrekt wäre natürlich nur ein Eintrag mit 0,05. Nun könnte man sagen: “Aber pass auf, wenn ein Anführungszeichen gleich rechts von einem Komma steht, nimm das nächste Komma erst wahr, wenn vorher ein weiteres Anführungszeichen kommt”. Das geht aber nicht, weil man PHP explode() das nicht anweisen kann. Den Automatismus, Zeichen wie das Komma in Ausschnitten, die in Anführungszeichen stehen, zu ignorieren, benutzt PHP explode() nicht. Man könnte jetzt sagen, dass explode außer dem Komma auch das Anführungszeichen als Delimiter verwenden soll aber auch das ist nicht nur auf den zweiten Blick logischer Unsinn, es geht auch gar nicht: man kann zwar mehr als ein Zeichen als Delimter verwenden aber nicht verschiedene Delimiter im gleichen Arbeitsgang.

    Rein theoretisch könnte eine explode Funktion durchaus eine Option haben, die die Anführungszeichen berücksichtigt, PHP explode() hat das aber nicht.

Wir haben also erst einmal zerhackte Einzeldaten und noch ein weiteres Problem: die Datensätze sind unterschiedlich lang. Nicht alle Felder sind in jeder Zeile ausgefüllt und so kann man nicht sicher vorraussagen, wie die Nummer des Felds mit der Jahreszahl lautet. Auch das ist ein Fehler, der den Import sicher verhindert. Was nun?

Lösungsansätze

Programmieren geht immer von Wünschen aus. Präzise formulierte, vollständige Wünsche für eine verbesserte Situation.

Wir wünschen uns gleichförmige Datensätze mit vollständigen Einträgen.

Damit entfällt schon mal das Löschen der fiesen Fließkommaeinträge. Das würde zwar gleich lange Datensätze ermöglichen, aber die Messwerte sollen ja durchaus importiert werden.

Ach! Wären die Ersteller der Datensätze doch Kenianer oder Iren gewesen! Dann wären in den Zeilen Dezimalpunkte und alles würde super esasy funktionieren….. Wenn explode ein Komma sieht, könnte man sich dann darauf verlassen, dass es ein Feld abtrennt und nicht eine Dezimalstelle.

Wie wäre es, wenn wir dem Schicksal nachhelfen? Ersetzen wir doch einfach die Kommata durch Punkte! Cool, nur: dann müsste man das aber so machen, dass nur die Kommata in den Fließkommazahlen, nicht aber die zwischen den Datenfeldern ersetzt werden. Also so:

Aus:
"CS",GMA_BF_F1,"F1",,,,,,,,"0,05","0,1",-,-,-,-,,,,,,,,,,,,,,,,,,,,,2018,
Wird:
"CS",GMA_BF_F1,"F1",,,,,,,,"0.05","0.1",-,-,-,-,,,,,,,,,,,,,,,,,,,,,2018,

Toll… genau so und wie?

Formulieren wir den Algorithmus erst mal in menschlicher Sprache:

Wenn Du ein Anführungszeichen siehst und gleich danach eine oder mehr Zahlen 
und dann ein Komma, wieder Zahlen oder eine Zahl 
und noch ein Anführungszeichen...
Dann ersetze das Komma durch einen Punkt.

Wie man das mit sed machen kann

Man kann sed ein Muster wie “ZahlenKommaZahlen” jederzeit erklären, er kann aber nicht in so einem Muster begrenzt suchen und ersetzen. Stattdessen kann sed aber durchaus das ganze Muster ersetzen. Nur durch was? Im Grunde muss sed das Muster durch sich selbst ersetzen, nur mit Punkt statt Komma. Also so:

Finde:
"0,05"
Ersetze es durch:
"0.05"

Schau Dir das Muster an….
Das sind 3 Teile: “0, das Komma, das ein Punkt werden soll und 05″.

Kann ich mit sed diese Musterauftrennung vornehmen und dann die Teile nach meinen Wünschen neu einsetzen?

Na klar, das geht. Aber versuchen wir erst einmal, ob wir das Muster sicher finden können. Dazu geben wir nur das Ende der Datei mit tail aus und leiten das Ergebnis mit der Pipeline | weiter an sed, sed bekommt in einfachen Anführungszeichen den Befehl s für substitute, dazu nach einem Querstrich das Suchmuster \”0, dann nach einem weiteren Querstrich das, was stattdessen in der Zeile stehen soll und die Option g für global oder greedy, damit sed das Muster so oft ersetzt, wie es gefunden wird. Ersetzen wir es erst mal einfach komplett durch “mehh”:

tail daten.csv | sed 's/\"0,/mehh/g'
Ergebnis:
"CS",GMA_BF_F1,"F1",,,,,,,,mehh05",mehh1",-,-,-,-,,,,,,,,,,,,,,,,,,,,,2018,

Das fiese Komma ist schon mal weg und unsere Suche hat nur die richtigen getroffen. Toll. Es ist klar, dass das auch für den rechten Teil des Musters geht. Nur bedenken wir, dass es nicht immer eine Null ist, die da steht, sondern irgendwelche Zahlen, die Suche nach irgendwelchen Zahlen irgendwelcher Anzahl geht so:

tail daten.csv |sed 's/\(\"[0-9]*\),\([0-9]*\"\)/mehh/g'
Das ergibt:
"CS",GMA_BF_F1,"F1",,,,,,,,mehh,mehh,-,-,-,-,,,,,,,,,,,,,,,,,,,,,2018,

Jetzt ist alles mehh und das geht so:

  • \” Finde ein Anführungszeichen(der Backslash verhindert, dass es als Programmiersymbol interpretiert wird).
  • [0-9]* die eckigen Klammern sollen interpretiert werden und zwar als Bereich von Zeichen, in diesem Fall alle Dezimalzahlen von 0 bis 9, nach dieser Bereichsbeschreibung sagt das Sternchen Asterisk: das, was vor mir steht, kann einmal oder mehrmals vorkommen, also 22367 oder 666 oder 0 etc.
  • Dann wird die am Anfang geöffnete einfache runde Klammer geschlossen, denn das wäre der erste Teil des Musters, nach dem wir suchen, der zweite ist einfach das diabolische Komma, dann kommt noch mal der gleiche Teil(also wieder beliebig viele Zahlen und eine Anführung). Die einfachen runden Klammern müssen genau wie das Anführungszeichen mit Backslashes maskiert (escaped) werden…. isso…

Wir können jetzt schon in menschlicher Sprache sagen:

 Ersetze das Suchmuster durch folgende drei Teile:
  1.) den Inhalt den Du mit dem Muster der ersten runden Klammer gefunden hast.
  2.) einen Punkt
  3.) den Inhalt dessen, was Du mit dem Muster der zweiten runden Klammer gefunden hast.
  
  also wird aus "0 nur 0
  aus , wird .
  und aus 05" wird 05
  

Die Anführungszeichen brauchen wir nicht mehr, sobald die Punkte ersetzt sind. Man könnte sie auch in die Klammern übernehmen, dann würden sie erhalten bleiben, genau wie die Zahlen, die wir ja als Messerwerte behalten wollen.

Bleibt nur noch ein Schritt: wir müssen herausfinden, wie man die Funde der Muster in den runden Klammern in den Teil des sed Befehls einsetzt, der oben einfach alles mit “mehh” überschreibt.

Für diesen Zweck bietet sed einen auch in vielen anderen Systemen automatisch eingebauten aber nur im “unsichtbaren” Hintergrund agierenden Mechanismus: die Suchergebnisse in Klammern sind als durchnummerierte Variablen gespeichert, solange der Befehl ausgeführt wird. Der Befehl beginnt mit ‘s/ und endet mit /g’, dazwischen können wir die Fundstücke durch Zahlen beginnend mit 1 aufrufen.

so:


sed 's/\(\"[0-9]*\),\([0-9]*\"\)/\1.\2/g'

Die Aufrufe der Fundstücke sind \1 und \2. Das wird zu 0 und 05, dazwischen ist im Befehl einfach ein Punkt notiert, der nicht mehr als ein Punkt ist. Ohne die Backslashes wären die Zahlen einfach Ziffern, mit den Escapezeichen vor ihnen erkennt sie sed als Referenzen auf seine internen Speicherplätze, die dank der runden Klammern gefüllt sind.

Alles zusammen:


"CS",GMA_BF_F1,"F1",,,,,,,,"0,05","0,1",-,-,-,-,,,,,,,,,,,,,,,,,,,,,2018,

 sed 's/\(\"[0-9]*\),\([0-9]*\"\)/\1.\2/g'

Resultat:
"CS",GMA_BF_F1,"F1",,,,,,,,0.05,0.1,-,-,-,-,,,,,,,,,,,,,,,,,,,,,2018,

Jetzt sind alle Zeilen Arrays 38 Felder lang, die Jahreszahl ist sicher in Feld 36 zu finden(weil sie im vorletzten Feld steht und weil im Gegensatz zu den Variablen in sed die Nummerierung mit 0 beginnt…)

Es gäbe noch andere Methoden, diese Aufgabe zu lösen. Zum einen könnte man sed in einer Schleife laufen lassen und so jedes einzelne Feld bearbeiten und in das Array zurückschreiben lassen. Zum anderen könnte man ähnliche Funktionen wie preg_replace() von PHP verwenden und auch Python und Perl wären Alternativen.

Die erste Methode wäre vergleichsweise umständlich zu programmieren, hätte aber unter Umständen den Vorteil, flexibler zu sein, wenn man schwierigere Muster finden muss. Die zweite wäre besser als Automatikfunktion in Webseiten geeignet, wäre aber auch einigermaßen umständlich.

Wenn man in einem Terminal mit sed arbeiten kann, bietet er für Jobs der hier beschriebenen Art eine der elegantesten und zuverlässigsten Methoden.

Older Posts »

endbild
Fitting perfectly well into the colours of a meadow since the late Cretaceous. Some 130 million years of learning can do wonders, methinks...