Hierarchische Konfigurationsdatei parsen

Code-Stücke können hier veröffentlicht werden.
Antworten
syntor
User
Beiträge: 88
Registriert: Donnerstag 2. Dezember 2010, 03:56

Hallo

Ich möchte für eine Applikation hierarchische Konfigurationsdateien einlesen können.
Der Syntax den ich mir dafür vorgestellt habe, ist folgender:

Code: Alles auswählen

# Kommentare sind erlaubt
#Ein Block zeichnet sich dadurch aus, dass die betreffende Zeile von einem Doppelpunkt abgeschlossen wird und dass er der einzige auf dieser Zeile ist.
Block:
   #Der erste Doppelpunkt trennt Key und Value
   Key1: Value1
   # Die Zeile oben gibt vor, um wieviel die im Block enthaltenen Elemente eingerückt sein müssen. (Wie in Python)
   Key2: Value2
# Kommentare beeinflussen die Hierarchie nicht.
   Verschachtelter Block:
# Auch Leerzeilen sind erlaubt.

        # Weitere Doppelpunkte werden Normal dem Wert zugeordnet.
        Key1: Wer1MitDoppelPunkt:
Block2:
  etc:etc
Etwas, das mir wichtig ist, ist, dass auch die Kommentare in die erzeugte Baumstruktur aufgenommen werden, um programmatisch Werte ändern oder ganze Blöcke hinzufügen zu können, ohne dass Kommentare verloren gehen. Kommentare zu beginn herauszufiltern ist also nicht gewünscht :)



Zum Code: Er ist noch nicht ganz fertig. Das Parsen funktioniert soweit ganz gut, Exceptions werden geworfen wenn eine Zeile falsch eingerückt ist. Werte können jedoch noch nicht wieder ausgelesen werden, das sollte jedoch nur etwas kleines sein. Wichtiger ist es mir vielmehr, ein paar Meinungen zur Struktur zu bekommen, oder gänzlich andere Ansätze.

Den Namen 'Meta' habe ich gewählt, da die Dateien, die ich parse, etwas über die Applikation aussagen werden. Nun gibt es folgende Klassen:
  • Meta: Repräsentiert eine Sammlung von ineinander verschachtelten Blöcken.
  • MetaParser: transformiert eine Liste von Zeilen in eine Meta-Struktur
  • MetaLine: repräsentiert eine Zeile während dem Parsen und bestimmt, ob die Zeile ein Kommentar/Block ist etc
Die anderen Klassen sollten klar sein, denke ich.

Ich habe gesehen, dass es die Pakete SimpleParse bzw pyarsing gibt. Was denkt ihr zu diesen? Wäre die Benutzung derer besser geeignet für das, was ich mache und weniger fehleranfällig?

Code: Alles auswählen

#{ Meta Exceptions
class MetaSyntaxError(Exception):
    """Basic Exception when MetaFile does not conform to syntax."""

class MetaIndentationError(SyntaxError):
    """Syntax Error: Wrong indentation."""
    pass
#}


class Meta(object): #{
    """Meta Syntax parser."""
    def __init__(self, root_block=None):
        self._block = root_block or MetaBlock()

    #{ magic
    def __len__(self):
        return len(self._block)

    def __iter__(self):
        return iter(self._block)
    #} magic
#}

class MetaLine(object): #{
    """Line in a Meta config file."""
    def __init__(self, number, string):
        self._number = number
        self._string = string
        self._is_comment = string.lstrip().startswith('#')
        self._indent = len(string) - len(string.lstrip())

    #{ Properties
    @property
    def is_comment(self):
        """True or false depending whether line is comment."""
        return self._is_comment

    @property
    def is_empty(self):
        """True or false if line is empty or contains only whitespace."""
        return len(self._string.strip()) == 0

    @property
    def indent_level(self):
        """Returns current indentation level."""
        return self._indent

    @property
    def raw(self):
        """The unprocessed line as found in the input."""
        return self._string

    @property
    def is_block(self):
        """True/False: One colon only at the right end."""
        stripped = self._string.strip()
        return stripped.count(':') and stripped.endswith(':')
    #}
    def __str__(self):
        return self.raw
#}

#{ Parsing helper functions
def _ignore_indent_for(metaline):
    """Does the indent of this line affect the hierarchy?"""
    if metaline.is_comment:
        return True
    if metaline.is_empty:
        return True
    return False

def _manufacture_item(metaline):
    """Creates the appropriate MetaItem from a MetaLine."""
    if metaline.is_comment:
        return MetaComment(metaline)
    elif metaline.is_block:
        return MetaBlock(metaline)
    elif metaline.is_empty:
        return MetaSpacer(metaline)
    else:
        return MetaPair(metaline)
#}

class MetaParser(object): #{
    """Parse lines into a Meta structure."""
    def __init__(self):
        self._block_stack = [MetaBlock()]
        self._indent_stack = []

    def parse_lines(self, lines):
        """Pass a list of individual lines."""
        for number, line in enumerate(lines):
            metaline = MetaLine(number, line)
            self._add_line(metaline)
        return Meta(self._block_stack[0])

    #{ #protected
    def _add_line(self, line):
        """Convert MetaLine into MetaItem and add."""
        while self._belongs_to_outer_block(line):
            self._restore_block()
        if self._belongs_to_current_block(line):
            self._add_line_to_current_block(line)
        else:
            raise MetaIndentationError("Invalid indent level on line: %s" %
                    line)

    def _restore_block(self):
        """Make previous block active by popping from stacks."""
        self._block_stack.pop()
        self._indent_stack.pop()

    def _add_line_to_current_block(self, line):
        """Create item from line and add to block."""
        item = _manufacture_item(line)
        self._current_block.add_item(item)
        self._update_indent_stack(line)
        if isinstance(item, MetaBlock):
            self._block_stack.append(item)

    def _update_indent_stack(self, line):
        """Add indent to _index_stack if appropriate."""
        missing_indent = len(self._indent_stack) < len(self._block_stack)
        dont_ignore = not _ignore_indent_for(line)
        if dont_ignore and missing_indent:
            self._indent_stack.append(line.indent_level)


    def _belongs_to_current_block(self, line):
        """Does a item belong to this block based on the indentation?"""
        if _ignore_indent_for(line):
            return True
        if self._current_indent == line.indent_level:
            return True

        if self._current_indent is None:
            if not self._on_root_block:
                return self._is_first_block_member_indented(line)
            return True
        return False

    def _is_first_block_member_indented(self, line):
        """First member of new block must be indented more."""
        try:
            block_indent = self._indent_stack[-1]
            return line.indent_level > block_indent
        except IndexError:
            return False

    def _belongs_to_outer_block(self, line):
        """Is the line's indentation bigger than the other owned item's?"""
        print "Comparing", line.indent_level, "to", self._current_indent
        return line.indent_level < self._current_indent

    #}
    #{ properties
    @property
    def _current_block(self):
        """Last added block."""
        return self._block_stack[-1]

    @property
    def _on_root_block(self):
        """Any nested blocks open?"""
        return len(self._block_stack) == 1

    @property
    def _current_indent(self):
        """Last level of current path of absolute indent levels."""
        current_index = len(self._block_stack) - 1
        try:
            return self._indent_stack[current_index]
        except IndexError:
            return None

    #} properties
#} class MetaParser

#{ cls: MetaItem, MetaSpacer, MetaPair
class MetaItem(object):
    """Base class for MetaItems."""
    def __init__(self, metaline):
        self._metaline = metaline

    @property
    def indent_level(self):
        """Amount of blank spaces from the left."""
        return self._metaline.indent_level

class MetaSpacer(MetaItem):
    """Represents an empty line."""
    pass

class MetaComment(MetaItem):
    """Comment in a MetaFile."""
    pass

class MetaPair(MetaItem):
    """Key:Value pair."""
    pass
#} cls: MetaItem, MetaSpacer, MetaPair

class MetaBlock(object): #{
    """Hiearchical block in a MetaFile.

    A block is introduced by a line that contains only a single colon which is
    at the very right of a line.

    Each block can contain an unlimited amount of MetaLines or MetaBlocks,
    which in turn can contain further MetaBlocks.

    """
    def __init__(self, metaline=None):
        self._items = []
        self._metaline = metaline

    def add_item(self, item):
        """Insert new contained item at the end."""
        self._items.append(item)

    #{ Magic
    def __iter__(self):
        return iter(self._items)

    def __len__(self):
        return len(self._items)
    #}
#}
Zuletzt geändert von syntor am Mittwoch 26. Januar 2011, 14:37, insgesamt 1-mal geändert.
Benutzeravatar
/me
User
Beiträge: 3555
Registriert: Donnerstag 25. Juni 2009, 14:40
Wohnort: Bonn

Steigen wir mit der Frage mal ganz vorne - also beim Format - ein: Was spricht gegen XML als strukturierte Konfigurationsdatei? Zu geschwätzig? Soll das jemand häufig von Hand ändern?
syntor
User
Beiträge: 88
Registriert: Donnerstag 2. Dezember 2010, 03:56

Ja genau, ich möchte, dass man es möglichst schnell von Hand ändern kann.
XML finde ich sehr unangenehm von Hand zu erstellen.
Benutzeravatar
Hyperion
Moderator
Beiträge: 7478
Registriert: Freitag 4. August 2006, 14:56
Wohnort: Hamburg
Kontaktdaten:

Prinzipiell eine interessante Idee! Allerdings sieht mir Dein Code auf den ersten Blick reichlich kompliziert aus. Zudem finde ich diese Kommentare störend:

Code: Alles auswählen

#{ Meta Exceptions
#}
Wozu dienen die?

Im pocoo-Projekt gab es mal eine kleine Lib namens CleverCSS; aktuelle finde ich die nur noch als Haskell-Projekt, aber iirc gabs auch mal eine Python-Variante. Die Idee dahinter bezog sich zwar auf das Formatieren von CSS, aber evtl. könnte man sich von dem Parser etwas abgucken?

Außerdem fehlt dem Format noch ein Name :D
encoding_kapiert = all(verstehen(lesen(info)) for info in (Leonidas Folien, Blog, Folien & Text inkl. Python3, utf-8 everywhere))
assert encoding_kapiert
syntor
User
Beiträge: 88
Registriert: Donnerstag 2. Dezember 2010, 03:56

Diese Kommentare sind foldmarker für vim. So kann ich schnell Code ein und ausklappen und bekomme sogleich eine Art Inhaltsverzeichnis.

"kompliziert"... ja, ich weiss, es ist nicht wahnsinnig angenehm zum lesen, aber ich denke es ist doch relativ übersichtlich geblieben. Bedenke dass alles, was nach der Parser-Klasse kommt, nicht wirklich für das Parsen an und für sich benötigt wird.
DasIch
User
Beiträge: 2718
Registriert: Montag 19. Mai 2008, 04:21
Wohnort: Berlin

Du solltest dir mal YAML anschauen, quasi genau was du suchst mit mehr Funktionen und in standardisiert.
syntor
User
Beiträge: 88
Registriert: Donnerstag 2. Dezember 2010, 03:56

Ich kenne YAML, wie man sehen kann, ist das Format, das ich beschrieben haben, auch daran angelehnt :)

Ich habe PyYAML auch schon ausprobiert, jedoch scheint es Kommentare wegzuwerfen.

Ein weitere Punkt ist, dass es nicht nur primär darum geht, die Daten zu verarbeiten, sondern auch darum, wie man so etwas am besten löst, bzw auch einfach bloss eleganter - also wie man eine eigentliche Implementation für so etwas schreibt.
sma
User
Beiträge: 3018
Registriert: Montag 19. November 2007, 19:57
Wohnort: Kiel

Als schnelle kurze Lösung, die an meinen Python-Scanner angelehnt ist, kann ich folgendes bieten, allerdings schmeiße ich die Kommentare (in `c`) weg, weil ich nicht weiß, wie ich sie in meinem dict-of-dict-Modell repräsentieren soll:

Code: Alles auswählen

def parse(s):
    indents = [0]
    blocks = [{}]
    last_key = ''

    for c, i, k, v in re.findall(r"(?m)(^ *(?:#.*)?)\n|(^ *)([\w ]+):(?: *([^ \n][^\n]*))?\n", s):
        if k:
            indent = len(i)
            if indent > indents[-1]:
                indents.append(indent)
                blocks.append({})
                blocks[-2][last_key] = blocks[-1]
            while indent < indents[-1]:
                indents.pop()
                blocks.pop()
            if v:
                blocks[-1][k] = v
            else:
                last_key = k

    return blocks[0]
Vielleicht so:

Code: Alles auswählen

class Block:
    def __init__(self): self.lines = []
    def append(self, line): self.lines.append(line)
    def __repr__(self): return self.__class__.__name__ + repr(self.lines)
    def __getitem__(self, key):
        for line in self.lines:
            if isinstance(line, tuple) and line[0] == key: return line[1]
        raise KeyError(key)
    
    def emit(self, i=0):
        for line in self.lines:
            if isinstance(line, tuple):
                k, v = line
                if isinstance(v, Block):
                    print("%s%s:" % ("   "*i, k))
                    v.emit(i + 1)
                else:
                    print("%s%s: %s" % ("   "*i, k, v))
            else:
                print(line)
    
def parse(s):
    indents = [0]
    blocks = [Block()]
    last_key = ''

    for c, i, k, v in re.findall(r"(?m)(^ *(?:#.*)?)\n|(^ *)([\w ]+):(?: *([^ \n][^\n]*))?\n", s):
        if k:
            indent = len(i)
            if indent > indents[-1]:
                indents.append(indent)
                blocks.append(Block())
                blocks[-2].append((last_key, blocks[-1]))
            while indent < indents[-1]:
                indents.pop()
                blocks.pop()
            if v:
                blocks[-1].append((k, v))
            else:
                last_key = k
        else:
            blocks[-1].append(c)

    return blocks[0]

parse(s).emit()
Stefan
syntor
User
Beiträge: 88
Registriert: Donnerstag 2. Dezember 2010, 03:56

Vielen Dank!

Ich habe leider gerade keine Zeit um den Code auszuprobieren, aber hier einmal meine Gedanken zu dem was ich gerade "sehe" (oder zumindest denke, dass ich es tue :D)
  • Wenn ich zum Beispiel zweimal nacheinander einen neuen Block öffne der gleichweit eingerückt ist, dann überspringt er den vorherigen, "leeren" einfach?
  • inkonsistente Einrückungen - also z.B. zuerst 4, dann 3 - führen dazu, dass er auf den übergeordneten Block zurückspringt?
  • interessanter Ansatz mit dem Token-artigen parsen durch for ... in ... re.findall
  • ähnliche Funktionsweise über die beiden Stacks
  • niedrigerer Abstraktionsgrad (will damit nicht sagen, dass das schlecht ist, siehe Overengineering)
Ich werde es später oder morgen noch richtig ausprobieren :)
Noah
User
Beiträge: 15
Registriert: Montag 17. Januar 2011, 08:20

Wenn du dir das Format der Konfigurationsdatei aussuchen darfst: Warum nicht einfach ein python-script als Config nehmen, wie es z.B. hier: http://buildbot.net/buildbot/docs/lates ... ile-Format gemacht wird?
DasIch
User
Beiträge: 2718
Registriert: Montag 19. Mai 2008, 04:21
Wohnort: Berlin

Python für Konfigurationsdateien hat den Nachteil dass früher oder später die Nutzer auch tatsächlich Python in den Konfigurationsdateien nutzen.

Das wird spätestens dann interessant wenn es Veränderungen gibt die nicht rückwärtskompatibel sind, die gab es in der 2.x Reihe schon mit jedem neuem Statement und wenn man will dass es auf 3.x auch läuft hat man ein Problem.
sma
User
Beiträge: 3018
Registriert: Montag 19. November 2007, 19:57
Wohnort: Kiel

syntor hat geschrieben:
  • Wenn ich zum Beispiel zweimal nacheinander einen neuen Block öffne der gleichweit eingerückt ist, dann überspringt er den vorherigen, "leeren" einfach?
  • inkonsistente Einrückungen - also z.B. zuerst 4, dann 3 - führen dazu, dass er auf den übergeordneten Block zurückspringt?
  • interessanter Ansatz mit dem Token-artigen parsen durch for ... in ... re.findall
  • ähnliche Funktionsweise über die beiden Stacks
  • niedrigerer Abstraktionsgrad (will damit nicht sagen, dass das schlecht ist, siehe Overengineering)
Beim ersten Punkt ist mir gerade nicht ganz klar, was du meinst, aber falls es dies ist, dann ja, ich prüfe nicht, ob die Syntax auch eingehalten wird. Das trifft auch auf inkonsistente Einrückung zu. Für die Mittagspause war mir das zu mühsam, genauer auszuprobieren und ich hoffte, es fällt vielleicht nicht auf :)

Code: Alles auswählen

foo:
   bar:
   baz:
     qux: qzt
=>
{foo: {baz: {qux: qzt}} (bar wird ignoriert, ich hätte gedacht, das wäre illegal)
Der deutlich geringere Abstraktionsgrad war dann auch der Grund, warum ich's überhaupt schrieb.

Ansonsten: Mächtigkeit von RE plus Stack entspricht der eines kontextfreien Parsers und ist eigentlich mein Standardhammer für alles, was geparst werden will :)

Stefan
Antworten