Detección de fecha con Python RegEx

Oct 09 2020

Es un proyecto de práctica del libro "Automatizar las cosas aburridas con Python". Soy un programador de Python de nivel intermedio e intenté resolver este problema con la menor cantidad de código posible. Este código no tendrá en cuenta ninguna fecha incorrecta, por ejemplo: 29-02-2002 no se seleccionará porque 2002 no es un año bisiesto y solo los años bisiestos tienen el 29 de febrero. No agregué código para detectar también fechas con meses escritos en palabras, también podría hacerlo, pero quiero mantener las cosas simples por ahora y tampoco usé el módulo de pyperclip para detectar fechas del texto copiado al portapapeles porque no quiero confundir a cualquier principiante que también quiera aprender viendo mi código. Quiero que los programadores maestros revisen mi código y si hay otra forma posible de detectar fechas, publique sus soluciones. También agradecería cualquier consejo y crítica positiva, así sé dónde me encuentro ahora y qué necesito mejorar. Gracias. El código es el siguiente:

    import re


    def date_detector(text):
        date_pattern = re.compile('''
        ([12][0-9]|3[0-1]|0?[1-9])             # to detect days from 1 to 31
        ([./-])                                # to detect different separations
        (1[0-2]|0?[1-9])                       # to detect number of months
        ([./-])                                # to detect different seperations
        (2?1?[0-9][0-9][0-9])                  # to detect number of years from 1000-2999 years
         ''', re.VERBOSE)

        days = []
        months = []
        years = []
        dates = []
        for date in date_pattern.findall(text):
            days.append(int(date[0]))
            months.append(int(date[2]))
            years.append(int(date[4]))

        for num in range(len(days)):

        # appending dates in a list that dont need any filtering to detect wrong dates
            if months[num] not in (2, 4, 6, 9, 11):
                dates.append([days[num], months[num], years[num]])

        # detecting those dates with months that have only 30 days
            elif days[num] < 31 and months[num] in (4, 6, 9, 11):
                dates.append([days[num], months[num], years[num]])

        # filtering leap years with Feb months that have 29 days
            elif months[num] == 2 and days[num] == 29:
                if years[num] % 4 == 0:
                    if years[num] % 100 == 0:
                        if years[num] % 400 == 0:
                            dates.append([days[num], months[num], years[num]])
                    else:
                        dates.append([days[num], months[num], years[num]])

        # appending Feb dates that have less than 29 days
            elif months[num] == 2 and days[num] < 29:
                dates.append([days[num], months[num], years[num]])

        if len(dates) > 0:
            for date in dates:
                print(date)


data = '30-06-2012, 31-12-2012, 15-02-2002, 29-02-2004, 29-02-2002, 31-02-2004, 31-06-2012'

date_detector(data)
```

Respuestas

2 AlexanderMashin Oct 09 2020 at 14:56

Sugiero algunas mejoras menores en la expresión regular:

  • asegúrese de que se utiliza el mismo separador entre día y mes y entre mes y año con una referencia inversa (?P=sep),
  • reemplace los grupos de captura numerados con nombres y cree grupos que no sean necesarios, si hubiera alguno, que no sean de captura ?:. En consecuencia, finditery groupdictse usan, y el día se obtiene de la coincidencia con int(date['day']), etc. Esto hará que el código sea algo más humano.

Más importante aún, le sugiero que deshacerse de days, monthsy yearslistas por completo. Estos datos se pueden almacenar en diccionarios en dateslista y filtrar antes de agregarlos dates.

Como consecuencia, no necesitará un bucle range(len(days)).

Las condiciones de validación pueden combinarse con OR sin perder claridad, y propongo convertirlo en una función separada date_is_valid(day: int, month: int, year: int) -> bool.

Además, el único parámetro en el date_detectorse pueden hacer con tipo: def date_detector(text: str):.

Para resumir las modificaciones sugeridas:

import re

def date_is_valid(day: int, month: int, year: int) -> bool:
    return (month not in (2, 4, 6, 9, 11)   # 31 days in month (Jan, Mar, May, Jul, Aug, Oct, Dec).
    or day < 31 and month in (4, 6, 9, 11)  # 30 days in month (Feb, Apr, Jun, Sep, Nov).
    or month == 2 and day == 29 and year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
                                            # February, 29th in a Gregorian leap year.
    or month == 2 and day < 29)             # February, 1st-28th.

def date_detector(text: str):
    date_pattern = re.compile('''
    (?P<day>[12][0-9]|3[0-1]|0?[1-9])   # to detect days from 1 to 31
    (?P<sep>[./-])                      # to detect different separations
    (?P<month>1[0-2]|0?[1-9])           # to detect number of months
    (?P=sep)                            # to detect different seperations
    (?P<year>2?1?[0-9][0-9][0-9])       # to detect number of years from 1000-2999 years
     ''', re.VERBOSE)

    dates = []
    for match in date_pattern.finditer(text):
        date = match.groupdict()                            # convert Match object to dictionary.
        del date['sep']                                     # we don't need the separator any more.
        date = {key: int(val) for key, val in date.items()} # apply int() to all items.
        
        if date_is_valid(date['day'], date['month'], date['year']):
            dates.append(date)

    if len(dates) > 0:
        for date in dates:
            print(date)

data = '30-06-2012, 31-12-2012, 15-02-2002, 29-02-2004, 29-02-2002, 31-02-2004, 31-06-2012'

date_detector(data)
```
2 yedpodtrzitko Oct 09 2020 at 15:33

Sé que esto es parte de un ejercicio, pero se siente como una gran reinvención de la rueda donde puedes aprovechar las capacidades integradas de Python para la validación de fechas:

from datetime import date

>>> date(2020, 2, 29) # leap year date works
datetime.date(2020, 2, 29)

>>> date(2002, 2, 29) # non-leap year will raise ValueError
ValueError: day is out of range for month

>>> date(2002, 9, 31) # 31th day will raise ValueError
ValueError: day is out of range for month
  • en lugar de crear 3 listas separadas para años , meses y días , puede crear solo una lista, ya que siempre accede a estas partes en el mismo índice. Eso también simplifica el forciclo que le da los valores directamente en lugar de darle un índice al que desea acceder en estas listas.

  • Python es un lenguaje dinámico en el que se evalúan las colecciones vacías False, por lo que cuando desee verificar si una lista tiene algún elemento, no tiene que hacerlo explícitamente a través de if len(list) > 0, pero puede hacerlo if list:. Para imprimir elementos en la lista, puede dar un paso más y omitir la condición por completo, ya que iterar a través de una lista vacía no imprimirá nada. Fragmento antes / después:

# before
if len(dates) > 0:
    for date in dates:
        print(date)

# after
for date in dates:
    print(date)

todas las sugerencias aplicadas:

import re
from datetime import date

def date_detector(text):
    date_pattern = re.compile('''
    ([12][0-9]|3[0-1]|0?[1-9])             # to detect days from 1 to 31
    ([./-])                                # to detect different separations
    (1[0-2]|0?[1-9])                       # to detect number of months
    ([./-])                                # to detect different seperations
    (2?1?[0-9][0-9][0-9])                  # to detect number of years from 1000-2999 years
        ''', re.VERBOSE)

    # use only one list for storing all parts of match together
    parsed = []
    for match in date_pattern.findall(text):
        # year, month, day for easier passing to date()
        parsed.append([ int(match[4]), int(match[2]), int(match[0])] )

    valid = [] 
    for item in parsed:
        try:
            # pass list of [year, month, day] to date() and let it check its validity for us
            date(*item)
        except ValueError as e:
            pass # invalid date, dont do anything
        else:
            valid.append(item)

    for item in valid:
        print(item)


data = '30-06-2012, 31-12-2012, 15-02-2002, 29-02-2004, 29-02-2002, 31-02-2004, 31-06-2012'

date_detector(data)
  • esto se puede simplificar aún más fusionando ambos forbucles, para que no se repita la recopilación de datos dos veces innecesariamente.