En este post abordaremos el principio DRY (Don't repeat yourself) refactorizando el código resultante de este otro post (fechas palíndromos) para hacerlo reusable y fácilmente comprobable por pruebas unitarias. Pero primero veamos brevemente de Wikipedia la definición de DRY:
"DRY is a principle of software development aimed at reducing repetition of software patterns, replacing it with abstractions or using data normalization to avoid redundancy."
Si analizamos el código a refactorizar:
from datetime import date
from datetime import timedelta
oneDay = timedelta(days=1)
def IsPalindromoDate(date):
strDate = str(date).replace('-', '')
return strDate == strDate[: : -1]
print('Past palindromes:')
while str(currentDate) != '1000-01-01':
if IsPalindromoDate(currentDate):
print(currentDate)
currentDate -= oneDay
currentDate = date.today() + oneDay
print('Future palindromes:')
while str(currentDate) != '2999-12-31':
if IsPalindromoDate(currentDate):
print(currentDate)
currentDate += oneDay
Notamos que los dos ciclos tienen diferentes condiciones de parada y la
variable que itera lo hace en sentido opuesto en cada caso, pero en esencia ambos
ciclos tienen la misma estructura: iterar por un rango de fechas y obtener las
que cumplen cierta propiedad. Esto puede abstraerse usando
el patrón Iterator, y Python tiene un protocolo para implementar este
patrón en clases. Veámoslo en un ejemplo simple que itera sobre el
rango [a, b)
de enteros:
class my_range:
def __init__(self, a, b):
self.__a = a
self.__b = b
def __iter__(self):
return self
def __next__(self):
if self.__a >= self.__b:
raise StopIteration()
else:
result = self.__a
self.__a += 1
return result
Luego para consumir el iterador lo hacemos por medio de un for
:
for i in my_range(1, 5):
print(i)
1
2
3
4
En este ejemplo vemos que la clase debe implementar los métodos __iter__
y __next__
y lanzar la excepción StopIteration
cuando __next__()
no pueda producir
mas elementos a iterar.
Entonces rediseñemos la iteración sobre las fechas así:
import datetime
class DateRange:
"""
Iterator over every date in a range [date_a, date_b). If a predicate is defined
then iterate only over those dates thats evaluate filter(date) as True. Examples:
>>> list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3)))
[datetime.date(2019, 12, 30), datetime.date(2019, 12, 31), datetime.date(2020, 1, 1), datetime.date(2020, 1, 2)]
>>> list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3), lambda x: x.day % 2 == 0))
[datetime.date(2019, 12, 30), datetime.date(2020, 1, 2)]
"""
def __init__(self, date_a, date_b, filter=None):
# TODO: add impl
pass
def __iter__(self):
return self
def __next__(self):
# TODO: add impl
raise StopIteration()
Antes de pasar a implementar la lógica de la clase comentaremos un poco sobre las
pruebas. Primero notemos que el comentario con la documentación
de la clase tiene 2 ejemplos, estos dos ejemplos pueden servir como pruebas unitarias de la clase, para correrlas
ejecutemos python -m doctest -v DateRange.py
y python se encarga de descubrirlas, ejecutarlas y darnos los resultados:
Trying:
list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3)))
Expecting:
[datetime.date(2019, 12, 30), datetime.date(2019, 12, 31), datetime.date(2020, 1, 1), datetime.date(2020, 1, 2)]
**********************************************************************
File "....\Sources\BinaryCoffeeBlogPosts\Codes\DateRange.py", line 13, in DateRange.DateRange
Failed example:
list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3)))
Expected:
[datetime.date(2019, 12, 30), datetime.date(2019, 12, 31), datetime.date(2020, 1, 1), datetime.date(2020, 1, 2)]
Got:
[]
Trying:
list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3), lambda x: x.day % 2 == 0))
Expecting:
[datetime.date(2019, 12, 30), datetime.date(2020, 1, 2)]
**********************************************************************
File "....\Sources\BinaryCoffeeBlogPosts\Codes\DateRange.py", line 16, in DateRange.DateRange
Failed example:
list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3), lambda x: x.day % 2 == 0))
Expected:
[datetime.date(2019, 12, 30), datetime.date(2020, 1, 2)]
Got:
[]
**********************************************************************
1 items had failures:
2 of 2 in DateRange.DateRange
2 tests in 5 items.
0 passed and 2 failed.
***Test Failed*** 2 failures.
Como vemos tenemos 2 pruebas fallidas. Estas dos pruebas son parte del docstring
de la clase DateRange
y puede accederse por medio del atributo DateRange.__doc__
.
Añadamos un test más que pruebe el problema original de fechas palíndromos antes
de pasar a la implementar la lógica del iterador:
>>> IsPal = lambda date: str(date).replace('-', '') == str(date).replace('-', '')[: : -1]
>>> list(DateRange(datetime.date(2020, 1, 1), datetime.date(2020, 12, 31), IsPal))
[datetime.date(2020, 2, 2)]
El lector en este punto puede no seguir leyendo para no ver la implementación propuesta que pasa las pruebas y hacer su propia implementación del iterador haciendo uso así del desarrollo dirigido por pruebas o TDD por sus siglas en inglés.
La implementación nuestra:
import datetime
from datetime import date
from datetime import timedelta
def IsDate(value):
return type(value) is date
class DateRange:
"""
Iterator over every date in a range [date_a, date_b). If a predicate is defined
then iterate only over those dates thats evaluate filter(date) as True. Examples:
>>> list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3)))
[datetime.date(2019, 12, 30), datetime.date(2019, 12, 31), datetime.date(2020, 1, 1), datetime.date(2020, 1, 2)]
>>> list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3), lambda x: x.day % 2 == 0))
[datetime.date(2019, 12, 30), datetime.date(2020, 1, 2)]
>>> IsPal = lambda date: str(date).replace('-', '') == str(date).replace('-', '')[: : -1]
>>> list(DateRange(datetime.date(2020, 1, 1), datetime.date(2020, 12, 31), IsPal))
[datetime.date(2020, 2, 2)]
"""
__oneDay = timedelta(days=1)
def __init__(self, date_a, date_b, filter=None):
pass
if not IsDate(date_a) or not IsDate(date_b):
raise ValueError("Arguments must be of type datetime.date")
if date_a > date_b:
raise ValueError("The first date must be the same or before the second date")
self.__A = date_a
self.__B = date_b
self.__filter = filter
def __iter__(self):
return self
def __next__(self):
while not self._filter(self.__A):
self.__checkif_must_stop()
self.__A += DateRange.__oneDay
result = self.__A
self.__checkif_must_stop()
self.__A += DateRange.__oneDay
return result
def __checkif_must_stop(self):
if self.__A >= self.__B:
raise StopIteration()
def _filter(self, date):
if self.__filter is None:
return True
return self.__filter(date)
Luego la solución al problema original usando el módulo DateRange
:
from datetime import date
from DateRange import DateRange
def IsPalindromoDate(date):
strDate = str(date).replace('-', '')
return strDate == strDate[: : -1]
print('Past palindromes:')
for pd in DateRange(date(1000, 1, 1), date.today(), IsPalindromoDate):
print(pd)
print('Future palindromes:')
for pd in DateRange(date.today(), date(2999, 12, 31), IsPalindromoDate):
print(pd)
Todo el código del post está disponible en gist. Además, allí también hay otra implementación de DateRange
usando la instrución yield
que hace que la función retorne un generator.
En este post vimos cuando en nuestras soluciones identificamos código repetido podemos llevar a cabo una refactorización de la solución que use una abstracción adecuada y haga más mantenible, reusable y comprobable dicho código.