Seite 1 von 1

Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Freitag 23. Oktober 2015, 07:46
von NoPy
Das Thema etwas genauer:

ich will in irgendeiner Form mit Excel spielen.

ich benötige etwas, das aus
- C1 irgendwie zeile 0, spalte 2
- C1:F2 irgendwie von_zeile 0, von_spalte 2, von_zeile 1, bis_spalte 5
- C:C
und umgekehrt

klassisch wäre etwas in der Form

Code: Alles auswählen

def berechne_zellenkoordinaten(eine_zelle):
    if eine_zelle == 'C1':
        return {'spalte':2;'zeile':0}

def berechne_bereichskoordinaten(ein_bereich):
    if ein_bereich == 'C1:F2':
        von = berechne_zellenkoordinaten('C1')
        bis = berechne_zellenkoordinaten('F2')
        return {'von_spalte':von['spalte'], 'bis_spalte':bis['spalte'], 'von_zeile':von['zeile'], 'bis_zeile':bis['zeile']}

def berechne_bereichstext_aus_woerterbuch(eine_zelle):
    if eine_zelle=={'spalte':2;'zeile':0}:
        return 'C1'

def berechne_zellentext_aus_liste(spalte,zeile):
    if spalte ==2 and zeile==0:
        return 'C1'
usw.
Das ist natürlich hinreichend eklig.

besser erscheint mir irgendetwas der Form

Code: Alles auswählen

class excel_umrechnung(objekt):
    def __init__(self,ein_bereich):
        if ein_bereich == 'C1:F2':
            self.koordinaten = {'von_spalte':2, 'bis_spalte':5, 'von_zeile':0, 'bis_zeile':1}
        elif ein_bereich == {'von_spalte':2, 'bis_spalte':5, 'von_zeile':0, 'bis_zeile':1}:
            self.koordinaten = {'von_spalte':2, 'bis_spalte':5, 'von_zeile':0, 'bis_zeile':1}

    def als_string(self):
        return 'C1:F2'

    def als_woerterbuch(self):
        return {'von_spalte':2, 'bis_spalte':5, 'von_zeile':0, 'bis_zeile':1}

    def als_liste(self):
        return [2,0,5,1]
Aber wie macht man das nun "richtig"?

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Freitag 23. Oktober 2015, 08:27
von Sirius3
@NoPy: sauber wären zwei Klassen

Code: Alles auswählen

import re
from collections import namedtuple

class ExcelCoordinate(namedtuple("ExcelCoordinate", "column, row")):
    @classmethod
    def from_string(cls, cell):
        cell = re.match('^([A-Za-z]+)(\d+)$', cell)
        if cell is None:
            raise ValueError("no cell value")
        return cls(abc_to_int(cell.group(1)), int(cell.group(2)))
    
    def __str__(self):
        return '%s%d' %(int_to_abc(self.column), self.row)

class ExcelArea(namedtuple("ExcelArea", "upper_left, lower_right")):
    @classmethod
    def from_string(cls, area):
        ul, lr = area.split(':')
        return cls(ExcelCoordinate.from_string(ul),ExcelCoordinate.from_string(lr))
    
    def __str__(self):
        return '%s:%s' %(self.ul, self.lr)
Man teilt also das Problem in kleinere Teile, ein Bereich besteht aus zwei Koordinaten, und löst jedes unabhängig. Schnittstellen werden eindeutig definiert, so dass es keine Gott-Funktionen gibt, die mit allem umgehen können (müssen), sondern ich als Anwender der Klassen weiß ja, ob ich mit einem String oder schon mit Zahlentupeln arbeite.

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Freitag 23. Oktober 2015, 09:22
von NoPy
die eigentliche Umrechnungslogik steckt dann in abc_to_int bzw. int_to_abc

Und wie stelle ich mir dann einen Aufruf vor?

Code: Alles auswählen

k=ExcelCoordinate('C1')
print k['column'],k['row']
Wie baue ich dann Bereiche ein? Mache ich "from_string" intelligenter

cell = re.match('^([A-Za-z][A-Za-z]?)(\d+)([:]([A-Za-z][A-Za-z]?)(\d+))?$', cell)
oder
cell_einzeln = re.match('^([A-Za-z]+)(\d+)$', cell)
cell_bereich = re.match('^([A-Za-z]+)(\d+)[:]([A-Za-z]+)(\d+)$', cell)
cell_spalten = re.match('^([A-Za-z]+)[:]([A-Za-z]+)$', cell)
cell_zeilen = re.match('^(\d+)[:](\d+)$', cell)
?

An welcher Stelle sollte man dann prüfen, dass 'E:A' oder 'C4:D2' nicht geht?
Absolute Positionierung '$A$2:C$5' (also mir geht es nicht um den regulären Ausdruck, den bekomme ich hin), wie/wo bringe ich elegant unter, dass eine von- Spalte absolut und die bis- Spalte relativ ist?

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Freitag 23. Oktober 2015, 09:35
von pillmuncher
@NoPy: Die besten Erfahrungen mit Python + Excel habe ich gemacht, wenn ich win32com verwendet habe, um Excel fernzusteuern. Hier eine Kurzanleitung: http://pythonexcels.com/python-excel-mini-cookbook/

Dein Problem verschwindet damit insofern, als das Umrechnen von Zellenadressen direkt von Excel selbst erledigt wird, und Excel, so steht anzunehmen, kann das einigermaßen gut.

Funktioniert allerdings nur unter Windows.

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Freitag 23. Oktober 2015, 09:51
von NoPy
pillmuncher hat geschrieben:@NoPy: Die besten Erfahrungen mit Python + Excel habe ich gemacht, wenn ich win32com verwendet habe, um Excel fernzusteuern. Hier eine Kurzanleitung: http://pythonexcels.com/python-excel-mini-cookbook/

Dein Problem verschwindet damit insofern, als das Umrechnen von Zellenadressen direkt von Excel selbst erledigt wird, und Excel, so steht anzunehmen, kann das einigermaßen gut.

Funktioniert allerdings nur unter Windows.
Ich weiß, das hatte ich auch schon überlegt. Aber das setzt nicht nur Windows voraus, sondern auch Excel. Und diesbezüglich wollte ich es flach halten, da u.U. auch mal nötig ist, dass ich das an OpenOffice anpassen muss oder irgendwelche Software auf einem Server laufen lassen muss, auf dem Windows oder Excel nicht installiert ist.

Üblicherweise können alle Kollegen so leidlich mit Excel umgehen, so dass ein Austausch von Informationen auf diesem Wege immer funktioniert, daher bin ich daran interessiert, eine zukunftsfähige Schnittstelle zu Excel- Dateien zu etablieren.

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Freitag 23. Oktober 2015, 10:27
von Sirius3
@NoPy: ich hatte gedacht, es geht Dir ums Konzept und nicht um die Umsetzung.
Wie schon geschrieben, sollte eine Funktion ein einfaches Interface haben und man macht lieber zwei Funktionen, wenn der Input verschieden ist. In diesem Fall heißt das:

Code: Alles auswählen

k = ExcelCoordinate.from_string('C1')
b = ExcelCoordinate(3,1)
assert k.column = b.column
Für "absolut" brauchst Du halt noch weitere Attribute row_fixed, column_fixed. Mehr Funktionalität bedeutet komplexere Klassen.
Wenn Du bliebige Excel-Zell-Range-Beschreibungen parsen willst, würde ich das in eine eigene Funktion auslagern, bzw. ExcelArea den Spezialfall, dass upper-left = lower-right ist, beibringen.
Wie man prüft, ob der reguläre Ausdruck passt, habe ich Dir ja schon gezeigt.

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Freitag 23. Oktober 2015, 10:59
von NoPy
Sirius3 hat geschrieben:@NoPy: ich hatte gedacht, es geht Dir ums Konzept und nicht um die Umsetzung.
Oh, darum geht es mir immer noch. Deiner Antwort habe ich entnommen, dass Du im Grunde bei diesen beiden Klassen bleiben und diesen letztlich mehr Parameter spendieren würdest.
Sirius3 hat geschrieben:Wie schon geschrieben, sollte eine Funktion ein einfaches Interface haben und man macht lieber zwei Funktionen, wenn der Input verschieden ist. In diesem Fall heißt das:

Code: Alles auswählen

k = ExcelCoordinate.from_string('C1')
b = ExcelCoordinate(3,1)
assert k.column = b.column
Für "absolut" brauchst Du halt noch weitere Attribute row_fixed, column_fixed. Mehr Funktionalität bedeutet komplexere Klassen.
Wenn Du bliebige Excel-Zell-Range-Beschreibungen parsen willst, würde ich das in eine eigene Funktion auslagern, bzw. ExcelArea den Spezialfall, dass upper-left = lower-right ist, beibringen.
Wie man prüft, ob der reguläre Ausdruck passt, habe ich Dir ja schon gezeigt.
Das mit den regulären Ausdrücken ist kein Problem, damit hantiere ich seit Jahren.
Die Schnittstelle nach außen sollte schon so klein wie möglich sein. So sieht das schon mal gut aus.
An welcher Stelle würdest Du die Fallunterscheidung unterbringen?
- in der "Hilfsfunktion", die die die eigentliche Berechnung macht? (abc_to_int)
- in "from_string" a la if ... elif ...
- noch anders?
Ich gehe mal davon aus, dass alle von einer Klassenmethode aufgerufenen Methoden auch Klassenmethoden sein müssen, da je kein Objekt existiert

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Freitag 23. Oktober 2015, 14:23
von Sirius3
@NoPy: Konkret ist immer besser, als groß Fallunterscheidungen zu machen. Sehe jetzt auch nicht, wo man da if..elif.. braucht.

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Freitag 23. Oktober 2015, 15:44
von Liffi
Vielleicht hilft es ja schon, dass 'echte' Format zu benutzen, welches Excel auch intern nutzt. Mit einer Deutschen Local wird dann aus C1 Z1S3, damit sollte man schon mal einfacher arbeiten können.

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Freitag 23. Oktober 2015, 17:51
von NoPy
Sirius3 hat geschrieben:@NoPy: Konkret ist immer besser, als groß Fallunterscheidungen zu machen. Sehe jetzt auch nicht, wo man da if..elif.. braucht.
na um zu unterscheiden zwischen C1, C1:D2, C$1, $C1, $C$1, C:C, $C:C ...
Vielleicht kann man auch notfalls einen universellen regulären Ausdruck schreiben und dann - je nachdem, was in den Gruppen steht - füllen.
Aber ist das besser?

Code: Alles auswählen

// AB   b              a CD   d    cE   FG   g              f HI   i    he
 '^(([$])?[A-Za-z]{1,2})?(([$])?\d+)([:](([$])?[A-Za-z]{1,2})?(([$])?\d+))?$'
(zur Erklärung: Ich hab mir angewöhnt, öffnende Klammern mit Groß- und schließende mit Kleinbuchstaben zu markieren. Wartet sich leichter)
spalte_von_absolut = group(1)='$'
spalte_von = group(0) (wobei man noch das evtl. vorhandene $ entfernen muss, entweder, indem man den regulären Ausdruck noch weiter aufbohrt, oder indem man eine Fallunterscheidung macht.

Aber dennoch müsste man auch noch prüfen, ob das Ergebnis valide ist, denn A:3 frißt zwar der reguläre Ausdruck, nicht aber Excel.
Also an irgend einer Stelle müssen Fälle unterschieden werden.
Liffi hat geschrieben:Vielleicht hilft es ja schon, dass 'echte' Format zu benutzen, welches Excel auch intern nutzt. Mit einer Deutschen Local wird dann aus C1 Z1S3, damit sollte man schon mal einfacher arbeiten können.
Das umrechnen ist zwar nicht unkomplex, aber auch nicht unmöglich. Mit Z1S3 kann openpyxl - zumindest, soweit ich das gesehen habe - nicht umgehen.

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Freitag 23. Oktober 2015, 21:18
von NoPy
Jetzt verlasse ich mal die Frage nach dem Design kurz:

Mit

Code: Alles auswählen

//        AB   b C             ca DE   e F   fd G   HI   i J             jh KL   l M   mk g
lvsRE = '^(([$])?([A-Za-z]{1,2}))?(([$])?(\d+))?([:](([$])?([A-Za-z]{1,2}))?(([$])?(\d+))?)?$'
kann ich im Grunde jeden Fall abbilden, in den Gruppen 2,5,9 und 12 stecken die Dollars drin, in 3 und 10 die Buchstaben, in 6 und 13 die Zahlen.

Eine Klasse, die von named_tupel erbt (das Konzept named_tupel habe ich noch nicht verstanden), sollte jetzt im Grunde irgendetwas in der Form

Code: Alles auswählen

    cls.von_spalte = abc_to_int(cell.group(3))
    cls.von_spalte_fix = cell.group(2)=='$'
    cls.von_zeile = int(cell.group(6))
    cls.von_zeile_fix = cell.group(5)=='$'
    ...
    return cls
machen, oder? Wie geht das technisch in oben beschriebener Konstruktion, vorausgesetzt natürlich, die Namen sind in der Tupel- Definition drin.

Code: Alles auswählen

class ExcelCoordinate(namedtuple("ExcelCoordinate", "von_spalte, von_spalte_fix, von_zeile,von_zeile_fix")):
    @classmethod
    def from_string(cls, cell):
        cell = re.match('^(([$])?([A-Za-z]{1,2}))?(([$])?(\d+))?([:](([$])?([A-Za-z]{1,2}))?(([$])?(\d+))?)?$', cell)
        if cell is None:
            raise ValueError("no cell value")
        else:
            cls.von_spalte = abc_to_int(cell.group(3))
            cls.von_spalte_fix = cell.group(2)=='$'
            #...
            if ist_gueltig(cls):
                return cls
            else:
                raise ValueError("no cell value")
Welchen Vorteil hat die Implementation als Klasse/Klassenmethode an dieser Stelle? Könnte man dann nicht genauso eine 08/15- Funktion benutzen? Wenn diese ein named tupel zurückwirft, dann stellt sich das für den Aufrufenden genauso dar, oder? (Keine Kritik, nur eine Frage zum Verständnis)

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Samstag 24. Oktober 2015, 19:29
von Sirius3
@NoPy: wenn Du das als Klassenfunktion schreibst, dann kannst Du die Klasse auch ableiten, wenn es nötig wird. Aber sonst kannst Du natürlich auch eine normale Funktion schreiben. Wie gesagt, ich würde das Problem aufspalten in eine einzelne Koordinate und einem Range-Objekt. Bei "ExcelCoordinate" macht das 'von_'-Präfix irgendwie keinen Sinn.

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Samstag 24. Oktober 2015, 20:30
von snafu
NoPy hat geschrieben:Jetzt verlasse ich mal die Frage nach dem Design kurz:

Mit

Code: Alles auswählen

//        AB   b C             ca DE   e F   fd G   HI   i J             jh KL   l M   mk g
lvsRE = '^(([$])?([A-Za-z]{1,2}))?(([$])?(\d+))?([:](([$])?([A-Za-z]{1,2}))?(([$])?(\d+))?)?$'
kann ich im Grunde jeden Fall abbilden, in den Gruppen 2,5,9 und 12 stecken die Dollars drin, in 3 und 10 die Buchstaben, in 6 und 13 die Zahlen.

Eine Klasse, die von named_tupel erbt (das Konzept named_tupel habe ich noch nicht verstanden), sollte jetzt im Grunde irgendetwas in der Form

Code: Alles auswählen

    cls.von_spalte = abc_to_int(cell.group(3))
    cls.von_spalte_fix = cell.group(2)=='$'
    cls.von_zeile = int(cell.group(6))
    cls.von_zeile_fix = cell.group(5)=='$'
    ...
    return cls
machen, oder?
Naja, das wäre wohl ziemlich schlecht wartbar. Inbesondere falls jemand anderes als du mal mit dem Code zu tun hat.

Ich würde den komplexen regulären Ausdruck eher in Teilausdrücke zerlegen und mitels passender Hilfsfunktionen direkt bei Erzeugung des Klassenexemplars die Teilausdrücke durchtesten. Die Hilfsfunktionen würden in etwa so aussehen:

Code: Alles auswählen

def has_pattern(s, pattern):
    return bool(re.search(pattern, s))

def get_int(s, pattern):
    match = re.search(pattern, s)
    if not match:
        raise ValueError('no match')
    return int(match.group(0))

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Sonntag 25. Oktober 2015, 00:20
von cofi
Ich wuerde noch named groups und re.VERBOSE vorschlagen um das Monster zu baendigen.

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Sonntag 25. Oktober 2015, 03:20
von pillmuncher
Man kann sich auch Parserkombinatoren bauen:

Code: Alles auswählen

import re
import pprint


class Parser:

    def _parse(self, result, text):
        raise NotImplementedError

    def parse(self, text):
        result, rest = self._parse([], text)
        if rest:
            raise ValueError('Found trailing string: {}'.format(rest))
        else:
            return result

    def __add__(self, parser):
        return Then(self, parser)

    def __or__(self, parser):
        return Either(self, parser)


class Then(Parser):

    def __init__(self, first, second):
        self.first = first
        self.second = second

    def _parse(self, result, text):
        return self.second._parse(*self.first._parse(result, text))


class Either(Parser):

    def __init__(self, first, second):
        self.first = first
        self.second = second

    def _parse(self, result, text):
        try:
            return self.first._parse(result, text)
        except ValueError:
            return self.second._parse(result, text)


class Optional(Parser):

    def __init__(self, parser):
        self.parser = parser

    def _parse(self, result, text):
        try:
            return self.parser._parse(result, text)
        except ValueError:
            return result, text


class RegexParser(Parser):

    def __init__(self):
        self.match = re.compile(pattern=self.pattern).match

    def _parse(self, result, text):
        match = re.match(self.pattern, text)
        if match:
            return (
                result + [{type(self).__name__: match.group()}],
                text[match.end():],
            )
        else:
            raise ValueError


class Column(RegexParser):
    pattern = '[A-Z]+'


class Row(RegexParser):
    pattern = '\d+'


class Colon(RegexParser):
    pattern = ':'


class Dollar(RegexParser):
    pattern = '\$'


EXCEL_ADDRESS = (
    Optional(Dollar())
    + Column()
    + Optional(Dollar())
    + Row()
    + Optional(Colon()
               + Optional(Dollar())
               + Column()
               + Optional(Dollar())
               + Row())
    |
    Optional(Dollar())
    + Column()
    + Colon()
    + Optional(Dollar())
    + Column()
    |
    Optional(Dollar())
    + Row()
    + Colon()
    + Optional(Dollar())
    + Row()
)


def parse(addr):
    return EXCEL_ADDRESS.parse(addr)


def show(addr):
    print(addr)
    pprint.pprint(parse(addr))
    print()


def main():
    show('A1')
    show('$A1')
    show('A$1')
    show('$A$1')
    show('A1:G7')
    show('$A$1:$G$7')
    show('$A:$G')
    show('1:1')


if __name__ == '__main__':
    main()
Ergebnis:

Code: Alles auswählen

A1
[{'Column': 'A'}, {'Row': '1'}]

$A1
[{'Dollar': '$'}, {'Column': 'A'}, {'Row': '1'}]

A$1
[{'Column': 'A'}, {'Dollar': '$'}, {'Row': '1'}]

$A$1
[{'Dollar': '$'}, {'Column': 'A'}, {'Dollar': '$'}, {'Row': '1'}]

A1:G7
[{'Column': 'A'}, {'Row': '1'}, {'Colon': ':'}, {'Column': 'G'}, {'Row': '7'}]

$A$1:$G$7
[{'Dollar': '$'},
 {'Column': 'A'},
 {'Dollar': '$'},
 {'Row': '1'},
 {'Colon': ':'},
 {'Dollar': '$'},
 {'Column': 'G'},
 {'Dollar': '$'},
 {'Row': '7'}]

$A:$G
[{'Dollar': '$'},
 {'Column': 'A'},
 {'Colon': ':'},
 {'Dollar': '$'},
 {'Column': 'G'}]

1:1
[{'Row': '1'}, {'Colon': ':'}, {'Row': '1'}]
Das ganze ist nur eine schematische Darstellung, wie man sowas bauen könnte.

Parserkombinatoren können - anders als reguläre Ausdrücke - auch kontextbehaftete Grammatiken parsen und es ist leicht, die Klassen um semantische Prüfungen zu erweitern, damit zB. "G10:A1" ungültig ist. Dazu sollte man natürlich eine weniger naive Ergebnisstruktur aufbauen, die man dann während des Parsens lesen und manipulieren kann.

Noch besser, als die Prüfungen in die Klassen einzubauen, ist es, monadische Bind- und Return-Parser zu verwenden:

Code: Alles auswählen

class Bind(Parser):

    def __init__(self, parser, function):
        self.parser = parser
        self.function = function

    def _parse(self, result, text):
        r, t = self.parser._parse(result, text)
        return self.function(r)._parse(r, t)


class Return(Parser):

    def __init__(self, value=None):
        self.value = value

    def _parse(self, result, text):
        if self.value is None:
            return result, text
        else:
            return result + [self.value], text
Der Return-Parser fügt einfach einen vordefinierten (nicht-None-) Wert zum Ergebnis dazu. Der Bind-Parser ist etwas komplizierter: er verwendet einen Parser, dessen Ergebnis dann an eine Funktion übergeben wird, die wieder einen Parser produziert, der dann den restlichen Text weiterparst. Damit kann man während des Parsens dynamisch die zugrundeliegende Grammatik ändern. Oder eben semantische Prüfungen einbauen:

Code: Alles auswählen

def check_rows_are_monotonic(result):
    result = iter(result)
    for token in result:
        if 'Row' in token:
            start_row = int(token['Row'])
            break
    for token in result:
        if 'Row' in token:
            end_row = int(token['Row'])
            break
    if start_row > end_row:
        raise ValueError('Wrong order of row indices!')
    else:
        return Return()

EXCEL_ADDRESS = (
    ...  # like before
    |
    Optional(Dollar())
    + Row()
    + Colon()
    + Optional(Dollar())
    + Bind(Row(), check_rows_are_monotonic)

...

show('2:1')
Ergebnis:

Code: Alles auswählen

Traceback (most recent call last):
...
  File "exad.py", line 32, in _parse
    return self.function(r)._parse(r, t)
  File "exad.py", line 122, in check_rows_are_monotonic
    raise ValueError('Wrong order of row indices!')
ValueError: Wrong order of row indices!
Mein Code ist allerdings nicht ganz korrekt. Bind sollte statt des gesamten Ergebnisses nur das Ergebnis des Parsers übergeben, den es kapselt. Dazu müsste ich allerdings die gesamte Implementation ändern und das ist mir um diese Uhrzeit zu viel Arbeit. Damit Bind Zugriff auf Start- und Endzeile hat, müsste es dann so aussehen:

Code: Alles auswählen

EXCEL_ADDRESS = (
    ...  # like before
    |
    Bind(
        Optional(Dollar())
        + Row()
        + Colon()
        + Optional(Dollar())
        + Row(), check_rows_are_monotonic)

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Sonntag 25. Oktober 2015, 11:56
von pillmuncher
Hab es gerichtet & vereinfacht:

Code: Alles auswählen

import re


def compose(f, g):
    def composed(x):
        return f(g(x))
    return composed


class ParsingError(Exception):
    pass


class Parser:

    def _parse(self, result, text):
        raise NotImplementedError

    def parse(self, text):
        result, rest = self._parse([], text)
        if rest:
            raise ValueError('Found trailing string: {}'.format(rest))
        else:
            return result

    def __add__(self, parser):
        return Then(self, parser)

    def __or__(self, parser):
        return Either(self, parser)


class Bind(Parser):

    def __init__(self, parser, function):
        self.parser = parser
        self.function = function

    def _parse(self, result, text):
        r, t = self.parser._parse([], text)
        return self.function(r)._parse(result + r, t)


def bind_compose(f, g):
    def bind_composed(parser):
        return Bind(Bind(parser, g), f)
    return bind_composed


class Return(Parser):

    def __init__(self, value):
        self.value = value

    def _parse(self, result, text):
        if self.value is not None:
            return result + [self.value], text
        else:
            return result, text


class Then(Parser):

    def __init__(self, first, second):
        self.first = first
        self.second = second

    def _parse(self, result, text):
        return self.second._parse(*self.first._parse(result, text))


class Either(Parser):

    def __init__(self, first, second):
        self.first = first
        self.second = second

    def _parse(self, result, text):
        try:
            return self.first._parse(result, text)
        except ValueError:
            return self.second._parse(result, text)


class Optional(Parser):

    def __init__(self, parser):
        self.parser = parser

    def _parse(self, result, text):
        try:
            return self.parser._parse(result, text)
        except ValueError:
            return result, text


class RegexParser(Parser):

    def __init__(self, pattern, name):
        self.pattern = pattern
        self.name = name
        self.match = re.compile(pattern=pattern).match

    def _parse(self, result, text):
        match = self.match(text)
        if match:
            return (
                result + [{self.name: match.group()}],
                text[match.end():],
            )
        else:
            raise ValueError('could not parse pattern "{}": {}'
                             .format(self.pattern, text))


##############################################################################


column = RegexParser(pattern='[A-Z]+', name='Column')
row = RegexParser(pattern='\d+', name='Row')
colon = RegexParser(pattern=':', name='Colon')
dollar = RegexParser(pattern='\$', name='Dollar')


def check_columns_are_monotonic(result):
    result = iter(result)
    for token in result:
        if 'Column' in token:
            start_column = token['Column']
            break
    for token in result:
        if 'Column' in token:
            end_column = token['Column']
            break
    else:
        return Return(None)
    if start_column > end_column:
        raise ParsingError('Wrong order of column indices!')
    else:
        return Return(None)


def check_rows_are_monotonic(result):
    result = iter(result)
    for token in result:
        if 'Row' in token:
            start_row = int(token['Row'])
            break
    for token in result:
        if 'Row' in token:
            end_row = int(token['Row'])
            break
    else:
        return Return(None)
    if start_row > end_row:
        raise ParsingError('Wrong order of row indices!')
    else:
        return Return(None)


EXCEL_ADDRESS = (
    bind_compose(check_columns_are_monotonic, check_rows_are_monotonic)(
        Optional(dollar)
        + column
        + Optional(dollar)
        + row
        + Optional(
            colon
            + Optional(dollar)
            + column
            + Optional(dollar)
            + row))
    |
    Bind(
        Optional(dollar)
        + column
        + colon
        + Optional(dollar)
        + column, check_columns_are_monotonic)
    |
    Bind(
        Optional(dollar)
        + row
        + colon
        + Optional(dollar)
        + row, check_rows_are_monotonic)
)


def parse(addr):
    return EXCEL_ADDRESS.parse(addr)


def show(addr):
    import pprint
    print(addr)
    pprint.pprint(parse(addr))
    print()


def main():
    show('A1')
    show('$A1')
    show('A$1')
    show('$A$1')
    show('A1:G7')
    show('$A$1:$G$7')
    show('$A:$G')
    show('1:1')
    # show('2:1')
    # show('$G1:$A1')


if __name__ == '__main__':
    main()

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Montag 26. Oktober 2015, 14:27
von pillmuncher
Oder so:

Code: Alles auswählen

import copy
import re


class ParsingError(Exception):
    pass


class Parser:

    def join(self, result):
        return result

    def parse(self, t0):
        result, rest = self(t0)
        if rest:
            raise ValueError('Found trailing string: {}'.format(rest))
        else:
            return result

    def __call__(self, t0):
        raise NotImplementedError

    def __add__(self, parser):
        return Then(self, parser)

    def __or__(self, parser):
        return Either(self, parser)

    def __getitem__(self, join):
        new = copy.copy(self)
        new.join = join
        return new


class Bind(Parser):

    def __init__(self, parser, function):
        self.parser = parser
        self.function = function

    def __call__(self, t0):
        r1, t1 = self.parser(t0)
        r2, t2 = self.function(r1)(t1)
        return self.join(r1, r2), t2

    def join(self, r1, r2):
        return [r1, r2]


def bind_compose(f, g):
    def bind_composed(parser):
        return Bind(Bind(parser, g), f)
    return bind_composed


class Return(Parser):

    def __init__(self, result):
        self.result = result

    def __call__(self, t0):
        if self.result is None:
            return [], t0
        else:
            return self.join(self.result), t0

    def __str__(self):
        return 'Return({})'.format(self.result)

    __repr__ = __str__


class Then(Parser):

    def __init__(self, first, second):
        self.first = first
        self.second = second

    def __call__(self, t0):
        r1, t1 = self.first(t0)
        r2, t2 = self.second(t1)
        return self.join(r1, r2), t2

    def join(self, r1, r2):
        return [r1, r2]


class Either(Parser):

    def __init__(self, first, second):
        self.first = first
        self.second = second

    def __call__(self, t0):
        try:
            r, t = self.first(t0)
        except ParsingError:
            r, t = self.second(t0)
        return self.join(r), t


class Optional(Parser):

    def __init__(self, parser):
        self.parser = parser

    def __call__(self, t0):
        try:
            r, t = self.parser(t0)
        except ParsingError:
            return [], t0
        return self.join(r), t


class PrefixOperator(Parser):

    def __init__(self, operator, operand):
        self.operator = operator
        self.operand = operand

    def __call__(self, t0):
        r1, t1 = self.operator(t0)
        r2, t2 = self.operand(t1)
        return self.join(r1, r2), t2

    def join(self, r1, r2):
        return [r1, r2]


class InfixOperator(Parser):

    def __init__(self, left, operator, right):
        self.left = left
        self.operator = operator
        self.right = right

    def __call__(self, t0):
        r1, t1 = self.left(t0)
        r2, t2 = self.operator(t1)
        r3, t3 = self.right(t2)
        return self.join(r1, r2, r3), t3

    def join(self, r1, r2, r3):
        return [r1, r2, r3]


class RegexParser(Parser):

    def __init__(self, pattern):
        self.pattern = pattern
        self.match = re.compile(pattern=pattern).match

    def __call__(self, t0):
        match = self.match(t0)
        if match:
            return self.join(match.group()), t0[match.end():]
        else:
            raise ParsingError(
                'could not parse pattern "{}": {}' .format(self.pattern, t0)
            )


##############################################################################


from collections import namedtuple
from string import ascii_uppercase


ALPHA_VALUES = {k: v for v, k in enumerate(ascii_uppercase, start=1)}


def column2int(column):
    return sum(
        ALPHA_VALUES[c] * len(ALPHA_VALUES) ** i
        for i, c in enumerate(reversed(column))
    )


Column = namedtuple('Column', 'index absolute')
Row = namedtuple('Row', 'index absolute')
Cell = namedtuple('Cell', 'column row')
CellRange = namedtuple('CellRange', 'first last')
ColumnRange = namedtuple('ColumnRange', 'first last')
RowRange = namedtuple('RowRange', 'first last')


def make_relative_column(index):
    return Column(column2int(index), False)


def make_absolute_column(_, index):
    return Column(column2int(index), True)


def make_relative_row(index):
    return Row(int(index), False)


def make_absolute_row(_, index):
    return Row(int(index), True)


def make_cell(column, row):
    return Cell(column, row)


def make_cell_range(left, _, right):
    if left.column.index > right.column.index:
        raise ValueError('wrong column values!')
    if left.row.index > right.row.index:
        raise ValueError('wrong row values!')
    return CellRange(left, right)


def make_column_range(left, _, right):
    if left.index > right.index:
        raise ValueError('wrong column values!')
    return ColumnRange(left, right)


def make_row_range(left, _, right):
    if left.index > right.index:
        raise ValueError('wrong row values!')
    return RowRange(left, right)


COLUMN = RegexParser('[A-Z]+')
ROW = RegexParser('\d+')
COLON = RegexParser(':')
DOLLAR = RegexParser('\$')
COLUMN_ADDRESS = (
    PrefixOperator(DOLLAR, COLUMN)[make_absolute_column]
    | COLUMN[make_relative_column]
)
ROW_ADDRESS = (
    PrefixOperator(DOLLAR, ROW)[make_absolute_row]
    | ROW[make_relative_row]
)
CELL_ADDRESS = (COLUMN_ADDRESS + ROW_ADDRESS)[make_cell]
EXCEL_ADDRESS = (
    InfixOperator(COLUMN_ADDRESS, COLON, COLUMN_ADDRESS)[make_column_range]
    | InfixOperator(ROW_ADDRESS, COLON, ROW_ADDRESS)[make_row_range]
    | InfixOperator(CELL_ADDRESS, COLON, CELL_ADDRESS)[make_cell_range]
    | CELL_ADDRESS
)


def parse(addr):
    return EXCEL_ADDRESS.parse(addr)


def show(addr):
    import pprint
    print(addr)
    pprint.pprint(parse(addr))
    print()


def main():
    show('A1')
    show('$A1')
    show('A$1')
    show('$A$1')
    show('A1:B2')
    show('$A$1:$G$7')
    show('$A:$G')
    show('AA:AG')
    show('$1:$1')
    # show('2:1')
    # show('$G1:$A1')
    assert parse('$A$1:$G$7').last.column.absolute


if __name__ == '__main__':
    main()
Ergebnis:

Code: Alles auswählen

A1
Cell(column=Column(index=1, absolute=False), row=Row(index=1, absolute=False))

$A1
Cell(column=Column(index=1, absolute=True), row=Row(index=1, absolute=False))

A$1
Cell(column=Column(index=1, absolute=False), row=Row(index=1, absolute=True))

$A$1
Cell(column=Column(index=1, absolute=True), row=Row(index=1, absolute=True))

A1:B2
CellRange(first=Cell(column=Column(index=1, absolute=False), row=Row(index=1, absolute=False)), last=Cell(column=Column(index=2, absolute=False), row=Row(index=2, absolute=False)))

$A$1:$G$7
CellRange(first=Cell(column=Column(index=1, absolute=True), row=Row(index=1, absolute=True)), last=Cell(column=Column(index=7, absolute=True), row=Row(index=7, absolute=True)))

$A:$G
ColumnRange(first=Column(index=1, absolute=True), last=Column(index=7, absolute=True))

AA:AG
ColumnRange(first=Column(index=27, absolute=False), last=Column(index=33, absolute=False))

$1:$1
RowRange(first=Row(index=1, absolute=True), last=Row(index=1, absolute=True))

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Donnerstag 29. Oktober 2015, 20:22
von NoPy
Du liest mich Schweigen vor Ehrfurcht ;)

Ich werde es gewiss nicht so machen, weil ich es nicht verstehe und nicht einfach mit copy/paste irgend etwas zusammenschubsen will.
Aber ich werde es mir nach und nach anschauen. Das sieht sehr spannend aus. Es wird wohl immer mal wieder eine Frage dazu geben.

Vielen Dank, Du erweiterst gerade meinen Horizont!

Also ich habe grob verstanden, worauf Du hinauswillst, glaube ich. So eine Art "Teile und Herrsche".

Meine ersten Fragen (ich fange mal hinten an):

Code: Alles auswählen

def parse(addr):
    return EXCEL_ADDRESS.parse(addr)
Was genau passiert hier? EXCEL_ADDRESS sorgt für das Erzeugen eines Objektes der Klasse InfixOperator, die von Parser erbt und als Parameter für den Konstruktor eine Oder- Verknüfung aller möglichen Beschreibungen für gültige Excel- Bereiche (wobei - soweit ich das überblickt habe - die Überprüfung, ob die von- Spalte/Zeile kleiner ist, als die bis- Spalte/Zeile noch nicht integriert ist, oder? Also C10:A3 wäre gültig, oder?)

Wie ist die Konstruktion EXCEL_ADDRESS = (Funktionsaufruf()) zu verstehen? Was wird damit definiert?

Code: Alles auswählen

    def parse(self, t0):
        result, rest = self(t0)
        if rest:
            raise ValueError('Found trailing string: {}'.format(rest))
        else:
            return result
was passiert bei self(t0)?

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Donnerstag 29. Oktober 2015, 21:43
von pillmuncher
NoPy hat geschrieben:Ich werde es gewiss nicht so machen, weil ich es nicht verstehe und nicht einfach mit copy/paste irgend etwas zusammenschubsen will.
Statt selber Parserkombinatoren zu bauen, kannst du eine entsprechende Bibliothek verwenden. Ich mag Parcon. Dazu unten mehr.
NoPy hat geschrieben:Also ich habe grob verstanden, worauf Du hinauswillst, glaube ich. So eine Art "Teile und Herrsche".
Wie immer beim Programmieren. ;)
NoPy hat geschrieben:Meine ersten Fragen (ich fange mal hinten an):

Code: Alles auswählen

def parse(addr):
    return EXCEL_ADDRESS.parse(addr)
Was genau passiert hier? EXCEL_ADDRESS sorgt für das Erzeugen eines Objektes der Klasse InfixOperator, die von Parser erbt und als Parameter für den Konstruktor eine Oder- Verknüfung aller möglichen Beschreibungen für gültige Excel- Bereiche (wobei - soweit ich das überblickt habe - die Überprüfung, ob die von- Spalte/Zeile kleiner ist, als die bis- Spalte/Zeile noch nicht integriert ist, oder? Also C10:A3 wäre gültig, oder?)

Wie ist die Konstruktion EXCEL_ADDRESS = (Funktionsaufruf()) zu verstehen? Was wird damit definiert?
Man muss das alles aus ein paar Meter Entfernung betrachten, um die Grobstruktur erkennen zu können. Die übergreifende Idee ist, kleine Parser für Teilaufgaben zu verwenden und diese "zusammenzustecken", wodurch man wieder Parser erhält. Der Then-Parser beispielsweise bekommt bei der Initialisierung zwei Parser als Argumente. Er parst, indem er erst den ersten Parser aufruft, und erhält von diesem dessen Parsingergebnis und den restlichen, noch nicht geparsten Text. Mit diesem Text ruft Then dann den zweiten Parser auf, erhält von diesem dessen Parsingergebnis und den nicht geparsten restlichen Text. Die beiden Ergebnisse werden zu einer Liste zusammengefügt und diese Liste - durch self.join() transformiert - und der restliche Text werden als Returnwert zurückgegeben. Ein Parser ist also - mathematisch betrachtet - eine Funktion vom Typ Text --> (ParsingResult, Text). Damit Parser Objekte dieses Interface zur Verfügung stellen, müssen sie aufrufbar sein und in Python macht man das, indem man die __call__()-Methode implementiert. Ein Objekt x mit dieser Methode kann ich mittels x(...) aufrufen. Dadurch, dass ich lediglich die Aufrufbarkeit verlange, erreiche ich, dass man auch einfache Parser-Funktionen statt Klassen schreiben kann.

Zur Vereinfachung der Parser-Kombination habe ich zwei Operatoren implementiert. p1 + p2 bedeutet damit dasselbe wie Then(p1, p2). p1 | p2 bedeutet dasselbe wie Either(p1, p2), und Either versucht erst mit p1 zu parsen und dann und nur dann mit p2, wenn p1 scheitert.

Dass man mit + aus zwei Parsern einen neuen machen kann ist im Prinzip dasselbe, wie wenn man mit + aus zwei Zahlen eine neue macht.

Die Prüfung darauf, dass der zweite Zeilen-/Spaltenwert nie kleiner ist als der vorhergehende, passiert in den make_*_range() Funktionen. In den make_*() Funktionen werden die Datenstrukturen aus der Listenform in eine aussagekräftigere Datenstruktur übersetzt, zusammen mit den genannten Prüfungen. Diese Funktionen werden den Parsern mithilfe des Subskriptionsoperators mitgegeben, eine Idee, die ich von Parcon geklaut habe. Meine Implementierung ist nicht ganz korrekt, hier & hier habe ich es besser gemacht.

Die Idee ist also, sowohl die Parser zu neuen Parsern kombinieren zu können, als auch die Ergebnisse so zu transformieren, wie man es haben möchte, ohne dazu in den Parserklassen herumfummeln zu müssen.
NoPy hat geschrieben:

Code: Alles auswählen

    def parse(self, t0):
        result, rest = self(t0)
        if rest:
            raise ValueError('Found trailing string: {}'.format(rest))
        else:
            return result
was passiert bei self(t0)?
Wie oben schon beschrieben, ist auf Parsern die __call__()-Methode definiert, deswegen sind Parser aufrufbar. Und self ist ja ein Parser.

Hier noch eine Implementierung mit Parcon für Python 2.7: http://www.python-forum.de/pastebin.php?mode=view&s=467

Der Grund, warum es monadische Bind und Return Parser gibt, ist übrigens, damit man kontextsensitive Grammatiken parsen kann. Angenommen Strings der Form <Länge n in Dezimalschreibweise>:<n Buchstaben>, dann kann man das mit Parcon ganz einfach so machen:

Code: Alles auswählen

from parcon import OneOrMore, Chars, digit, concat, Bind, End
LEN = OneOrMore(digit)[concat][int]
STR = Bind(LEN + ':', Chars) + End()
print(STR.parse_string('5:hallo'))
Ergebnis:

Code: Alles auswählen

hallo
Wenn ich dagegen das hier versuche:

Code: Alles auswählen

print(STR.parse_string('5:halloabc'))
ist das Ergebnis:

Code: Alles auswählen

Traceback (most recent call last):
  File "parc.py", line 158, in <module>
    main1()
  File "parc.py", line 154, in main1
    print(STR.parse_string('5:halloabc'))
  File "/home/mick/.virtualenvs/parcon/local/lib/python2.7/site-packages/parcon/__init__.py", line 619, in parse_string
    raise ParseException("Parse failure: " + format_failure(result.expected))
parcon.ParseException: Parse failure: At position 7: expected one of EOF
Mit yacc, bison oder - Gott bewahre! - Regulären Ausdrücken kann man sowas nur schwer bis gar nicht parsen.

Re: Design- Frage: Excel Umrechnung 'F1:H2' --> (5,7,2,1)

Verfasst: Freitag 30. Oktober 2015, 18:24
von pillmuncher
NoPy hat geschrieben:Wie ist die Konstruktion EXCEL_ADDRESS = (Funktionsaufruf()) zu verstehen?
Die Klammern dienen nur dazu, dass ich dazwischen mehrzeilig schreiben kann. Genau wie hier:

Code: Alles auswählen

In [1]: x = (
   ...:     1
   ...:     +
   ...:     2
   ...: )

In [2]: x
Out[2]: 3