2017-02-24 55 views
6

Piszę klasy RecurringInterval, która - w oparciu o obiekt dateutil.rrule - reprezentuje okresowy okres w czasie. Zdefiniowałem dla niego niestandardową, możliwą do odczytania dla człowieka metodę __str__ i chciałbym również zdefiniować metodę parse, która (podobnie jak funkcja rrulestr()) parsuje łańcuch z powrotem do obiektu.W języku Python, jak analizować ciąg reprezentujący zestaw argumentów słów kluczowych tak, że zamówienie nie ma znaczenia

Oto metoda parse i niektóre przypadki testowe, aby przejść z nim:

import re 
from dateutil.rrule import FREQNAMES 
import pytest 

class RecurringInterval(object): 
    freq_fmt = "{freq}" 
    start_fmt = "from {start}" 
    end_fmt = "till {end}" 
    byweekday_fmt = "by weekday {byweekday}" 
    bymonth_fmt = "by month {bymonth}" 

    @classmethod 
    def match_pattern(cls, string): 
     SPACES = r'\s*' 

     freq_names = [freq.lower() for freq in FREQNAMES] + [freq.title() for freq in FREQNAMES]  # The frequencies may be either lowercase or start with a capital letter 
     FREQ_PATTERN = '(?P<freq>{})?'.format("|".join(freq_names)) 

     # Start and end are required (their regular expressions match 1 repetition) 
     START_PATTERN = cls.start_fmt.format(start=SPACES + r'(?P<start>.+?)') 
     END_PATTERN = cls.end_fmt.format(end=SPACES + r'(?P<end>.+?)') 

     # The remaining tokens are optional (their regular expressions match 0 or 1 repetitions) 
     BYWEEKDAY_PATTERN = cls.optional(cls.byweekday_fmt.format(byweekday=SPACES + r'(?P<byweekday>.+?)')) 
     BYMONTH_PATTERN = cls.optional(cls.bymonth_fmt.format(bymonth=SPACES + r'(?P<bymonth>.+?)')) 

     PATTERN = SPACES + FREQ_PATTERN \ 
       + SPACES + START_PATTERN \ 
       + SPACES + END_PATTERN \ 
       + SPACES + BYWEEKDAY_PATTERN \ 
       + SPACES + BYMONTH_PATTERN \ 
       + SPACES + "$"     # The character '$' is needed to make the non-greedy regular expressions parse till the end of the string 

     return re.match(PATTERN, string).groupdict() 

    @staticmethod 
    def optional(pattern): 
     '''Encloses the given regular expression in an optional group (i.e., one that matches 0 or 1 repetitions of the original regular expression).''' 
     return '({})?'.format(pattern) 


'''Tests''' 
def test_match_pattern_with_byweekday_and_bymonth(): 
    string = "Weekly from 2017-11-03 15:00:00 till 2017-11-03 16:00:00 by weekday Monday, Tuesday by month January, February" 

    groups = RecurringInterval.match_pattern(string) 
    assert groups['freq'] == "Weekly" 
    assert groups['start'].strip() == "2017-11-03 15:00:00" 
    assert groups['end'].strip() == "2017-11-03 16:00:00" 
    assert groups['byweekday'].strip() == "Monday, Tuesday" 
    assert groups['bymonth'].strip() == "January, February" 

def test_match_pattern_with_bymonth_and_byweekday(): 
    string = "Weekly from 2017-11-03 15:00:00 till 2017-11-03 16:00:00 by month January, February by weekday Monday, Tuesday " 

    groups = RecurringInterval.match_pattern(string) 
    assert groups['freq'] == "Weekly" 
    assert groups['start'].strip() == "2017-11-03 15:00:00" 
    assert groups['end'].strip() == "2017-11-03 16:00:00" 
    assert groups['byweekday'].strip() == "Monday, Tuesday" 
    assert groups['bymonth'].strip() == "January, February" 


if __name__ == "__main__": 
    # pytest.main([__file__]) 
    pytest.main([__file__+"::test_match_pattern_with_byweekday_and_bymonth"])  # This passes 
    # pytest.main([__file__+"::test_match_pattern_with_bymonth_and_byweekday"])  # This fails 

Chociaż parser działa jeśli podasz argumenty w „prawo” porządku, to jest „sztywne” w tym, że nie robi Dopuszczalne są opcjonalne argumenty w dowolnej kolejności. Właśnie dlatego drugi test kończy się niepowodzeniem.

Jaki byłby sposób, aby analizator składni przeanalizował pola "opcjonalne" w dowolnej kolejności, tak aby oba testy przeszły pomyślnie? (Myślałem o zrobieniu iteratora z wszystkimi permutacjami wyrażeń regularnych i próbowaniu na każdym z nich re.match, ale to nie wydaje się być eleganckim rozwiązaniem).

+2

Czy możesz nieco zmniejszyć swój kod? Wydaje się, że jest to interesujące pytanie, ale w tej chwili jest to dla mnie po prostu "ściana kodu". A [mcve] byłoby miłe. –

+0

Oczywiście oryginalny fragment zawierał prawie wszystkie parametry [dateutil.rrule] (http://dateutil.readthedocs.io/en/stable/rrule.html), ale usunąłem te, które nie były używane w testach, aby zmniejszyć liczba linii. –

+0

Nie mam czasu ani wglądu, aby odpowiedzieć, ale mam przegłosowanie. –

Odpowiedz

3

W tym momencie twój język staje się na tyle skomplikowany, że nadszedł czas, aby pozbyć się wyrażeń regularnych i nauczyć się korzystać z odpowiedniej biblioteki analizy. Zrzuciłem to razem, używając pyparsing, i opisałem je mocno, próbując wyjaśnić, co się dzieje, ale jeśli coś jest niejasne, zapytaj, a ja spróbuję wyjaśnić.

from pyparsing import Regex, oneOf, OneOrMore 

# Boring old constants, I'm sure you know how to fill these out... 
months  = ['January', 'February'] 
weekdays = ['Monday', 'Tuesday'] 
frequencies = ['Daily', 'Weekly'] 

# A datetime expression is anything matching this regex. We could split it down 
# even further to get day, month, year attributes in our results object if we felt 
# like it 
datetime_expr = Regex(r'(\d{4})-(\d\d?)-(\d\d?) (\d{2}):(\d{2}):(\d{2})') 

# A from or till expression is the word "from" or "till" followed by any valid datetime 
from_expr = 'from' + datetime_expr.setResultsName('from_') 
till_expr = 'till' + datetime_expr.setResultsName('till') 

# A range expression is a from expression followed by a till expression 
range_expr = from_expr + till_expr 

# A weekday is any old weekday 
weekday_expr = oneOf(weekdays) 
month_expr = oneOf(months) 
frequency_expr = oneOf(frequencies) 

# A by weekday expression is the words "by weekday" followed by one or more weekdays 
by_weekday_expr = 'by weekday' + OneOrMore(weekday_expr).setResultsName('weekdays') 
by_month_expr = 'by month' + OneOrMore(month_expr).setResultsName('months') 

# A recurring interval, then, is a frequency, followed by a range, followed by 
# a weekday and a month, in any order 
recurring_interval = frequency_expr + range_expr + (by_weekday_expr & by_month_expr) 

# Let's parse! 
if __name__ == '__main__': 
    res = recurring_interval.parseString('Daily from 1111-11-11 11:00:00 till 1111-11-11 12:00:00 by weekday Monday by month January February') 

    # Note that setResultsName causes everything to get packed neatly into 
    # attributes for us, so we can pluck all the bits and pieces out with no 
    # difficulty at all 
    print res 
    print res.from_ 
    print res.till 
    print res.weekdays 
    print res.months 
+1

Zgadzam się. Regex to ładne narzędzie, które działa doskonale na tyle prostego parsowania. Ale gdy staje się zbyt skomplikowane, wyrażenie regularne dodaje tylko inny poziom złożoności. * Mamy problem z parsowaniem. Po prostu użyj wyrażeń regularnych. Teraz są dwa problemy * –

+0

Wygląda na to, że od słowa '((by_weekday_expr + by_month_expr) | (by_month_expr + by_weekday_expr))' ta metoda nadal wymaga listy możliwych permutacji opcjonalnych argumentów. W tym uproszczonym przykładzie są tylko dwa, ale dla rzeczywistego zastosowania jest ich więcej jak 10, co może doprowadzić do 10! = 3 628 800 permutacji.Przypuszczam, że mogą one być generowane i 'join'ed, ale nadal nie wydaje się bardzo elegancki i może spowolnić kod, nie? –

+1

@KurtPeek Rzeczywiście, myślałem tak dużo, a następnie czytać dokumenty nieco trudniejsze. Spójrz na edycję - '&' jest tym, czego potrzebujemy tutaj. – ymbirtt

1

Masz wiele opcji tutaj, każda z różnymi wadami.

Jedno podejście byłoby użyć powtarzane naprzemiennie, jak (by weekday|by month)*:

(?P<freq>Weekly)?\s+from (?P<start>.+?)\s+till (?P<end>.+?)(?:\s+by weekday (?P<byweekday>.+?)|\s+by month (?P<bymonth>.+?))*$ 

To będzie pasować ciągi postaci week month i month week, ale również week week lub month week month itp

Innym rozwiązaniem byłoby użyj uprzedzeń, takich jak: (?=.*by weekday)?(?=.*by month)?:

(?P<freq>Weekly)?\s+from (?P<start>.+?)\s+till (?P<end>.+?(?=$| by))(?=.*\s+by weekday (?P<byweekday>.+?(?=$| by))|)(?=.*\s+by month (?P<month>.+?(?=$| by))|) 

Wymaga to jednak znanego separatora (użyłem "przez"), aby wiedzieć, jak daleko do siebie pasować. Ponadto będzie milcząco ignorować dodatkowe znaki (co oznacza, że ​​będą pasować do ciągów w postaci by weekday [some gargabe] by month).