YAML-Manipulation mit Python

Ich mache es zwanzig mal am Tag: t('admin.users.show.name'), die YAML-Datei öffnen, in denen sich die Übersetzungen befinden, den Pfad absuchen und ggf. fehlende Teile einfügen und zuletzt die Übersetzung hineinschreiben und speichern. Wirklich kreativ ist dabei nur das Formulieren, den Rest würde ich gern automatisieren. Dies sollte jedem Rails-Entwickler eine Hilfe sein.

Mein erster Schritt ist eine simple Kommandozeilen-Applikation, die eine YAML-Datei und einen Pfad liest, den Pfad findet oder erstellt, den aktuellen Wert anzeigt, einen neuen entgegennimmt, einträgt und alles in eine Datei schreibt. Das alles möglichst nah an Vim und Sublime, so dass man das ganze später in ein Plugin überführen kann. Somit bietet sich Python an, schon allein um mal wieder was neues zu nutzen.

YAML parsen und Pfade finden

Für diesen Job kommt PyYAML zum Einsatz. Leider machte mir die wirklich schlechte Dokumentation der Bibliothek zu schaffen, im Endeffekt ist es aber recht einfach:

import yaml

input_file = open(args.input_file, "r")
yaml_doc = yaml.load(input_file)

Die Rückgabe ist ein Dict (oder etwas das sich so verhält). In diesem Dictonary kann man nun den gewünschten Pfad suchen:

def find_or_create_parent(yaml_doc, search_string):
  yaml_component = yaml_doc
  for path_component in search_string.split(".")[:-1]:
    if path_component in yaml_component:
      if type(yaml_component[path_component]) is dict:
        yaml_component = yaml_component[path_component]
      else:
        sys.exit("Error: Component {0} of {1} exists but is not an object".format(
          path_component, search_string
        ))
    else:
      yaml_component[path_component] = dict()
      yaml_component = yaml_component[path_component]
  return yaml_component

Ich suche oder erstelle hier das Vaterobjekt des gesuchten Wertes. Ist ein Zwischenschritt kein Dict, breche ich aus Sicherheitsgründen ab, damit keine einfachen Werte oder aber Listen verloren gehen.

Werte lesen und ändern

Mit dem Vaterelement ist das Lesen und Schreiben recht einfach:

def current_value(yaml_doc, seach_string):
  parent =  find_or_create_parent(yaml_doc, search_string)
  if search_string.split(".")[-1] in parent:
    return parent[search_string.split(".")[-1]]
  else:
    return None

def set_value(yaml_doc, search_string, value):
  parent = find_or_create_parent(yaml_doc, search_string)
  parent[search_string.split(".")[-1]] = value

Mit diesen Funktionen kann man nun eine einfache CLI-Applikation formulieren:

print "Current value of {0}:".format(search_string)
print current_value(yaml_doc, search_string)
new_value = raw_input("Please enter new value: ").decode(sys.stdin.encoding)
set_value(yaml_doc, search_string, new_value)

Hier hat mir vor allem Pythons UTF-8-Handhabung Probleme gemacht, weshalb der Wert aus raw_input hier noch dekodiert werden muss. Keine Ahnung wie das schöner geht, in meinen Augen aber erschreckend dass man sich um sowas noch kümmern muss.

In Datei oder Terminal ausgeben

Für das Dumpen des YAML-Dokuments habe sich für mich folgende Einstellungen bewährt:

def dump(yaml_doc):
  return yaml.dump(yaml_doc, default_flow_style=False, allow_unicode=True, encoding="utf-8")

Auch hier scheint UTF-8 wieder etwas Unerwartetes zu sein. Aber es funktioniert:

if output_file_path is None:
  print ""
  print dump(yaml_doc)
else:
  output_file = open(output_file_path, "wb")
  output_file.write(dump(yaml_doc))
  print "Changes have been written into {0}".format(output_file_path)

Das Ergebnis

Mit Argparse konnte ich eine Demoanwendung erstellen, die im nächsten Schritt als Vorlage für ein Plugin für Vim oder Sublime dienen soll:

usage: test.py [-h] input_file search_string [output_file]

positional arguments:
  input_file
  search_string
  output_file

optional arguments:
  -h, --help     show this help message and exit

Das Skript kann mit einer Datei und einer Pfadangabe aufgerufen werden. Es zeigt den aktuellen Wert, erfragt einen neuen und schreibt dies in eine angegebene Datei oder das Terminal.

Offene Probleme

Mit dieser Lösung habe ich vor allem ein Problem: PyYAML liest und schreibt die Datei vollständig. Auch Zeilen, in denen nichts geändert wurde, werden daher an die präferierte PyYAML-Syntax angepasst. So wird beispielsweise

de:
  users:
    edit:
      text: |
        Hier können Sie Ihr Passwort anpassen.
        Lassen Sie die Felder frei, um keine Änderung vorzunehmen.

zu

de:                                                                                                                                                                                                  
  users:                                                                                                                                                                                             
    edit:
      change_password: Passwort ändern
      text: 'Hier können Sie Ihr Passwort anpassen.\nLassen Sie die Felder frei, um keine Änderung vorzunehmen.'

Das ist immer noch gültiges YAML, macht sich im Git-Diff aber nicht so schön.

Das gesamte Skript

#!/usr/bin/env python

import sys
import argparse
import yaml

parser = argparse.ArgumentParser()
parser.add_argument('input_file')
parser.add_argument('search_string')
parser.add_argument('output_file', default=None, nargs="?")
args = parser.parse_args()

input_file = open(args.input_file, "r")
search_string = args.search_string
output_file_path = args.output_file
yaml_doc = yaml.load(input_file)


def find_or_create_parent(yaml_doc, search_string):
  yaml_component = yaml_doc
  for path_component in search_string.split(".")[:-1]:
    if path_component in yaml_component:
      if type(yaml_component[path_component]) is dict:
        yaml_component = yaml_component[path_component]
      else:
        sys.exit("Error: Component {0} of {1} exists but is not an object".format(
          path_component, search_string
        ))
    else:
      yaml_component[path_component] = dict()
      yaml_component = yaml_component[path_component]
  return yaml_component

def current_value(yaml_doc, seach_string):
  parent =  find_or_create_parent(yaml_doc, search_string)
  if search_string.split(".")[-1] in parent:
    return parent[search_string.split(".")[-1]]
  else:
    return None

def set_value(yaml_doc, search_string, value):
  parent = find_or_create_parent(yaml_doc, search_string)
  parent[search_string.split(".")[-1]] = value

def dump(yaml_doc):
  return yaml.dump(yaml_doc, default_flow_style=False, allow_unicode=True, encoding="utf-8")


print "Current value of {0}:".format(search_string)
print current_value(yaml_doc, search_string)
new_value = raw_input("Please enter new value: ").decode(sys.stdin.encoding)
set_value(yaml_doc, search_string, new_value)

if output_file_path is None:
  print ""
  print dump(yaml_doc)
else:
  output_file = open(output_file_path, "wb")
  output_file.write(dump(yaml_doc))
  print "Changes have been written into {0}".format(output_file_path)

Abhängigkeiten

Natürlich muss Python installiert sein. Wenn sich das Skript über ein fehlendes YAML-Modul beschwert (ImportError: No module named yaml), lässt sich das in Ubuntu folgendermaßen nachrüsten:

sudo apt-get install python-yaml