Created
July 30, 2020 06:46
-
-
Save axieum/bb8319e06dd74fa247843b12bf3094ef to your computer and use it in GitHub Desktop.
Pendulum Period From Words
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import re | |
import pendulum | |
def period_from_words(words: str, **options) -> pendulum.Period: | |
""" | |
Parses a given datetime period query. | |
:param words: datetime period query, i.e. 'x [-|to] y', 'an hour ago', 'in 3 years' or 'x' | |
:param options: options passed to pendulum parse | |
:return: parsed datetime period instance | |
:raise ValueError: if unable to parse the words into a period instance | |
""" | |
# Wrapper for the Pendulum parse to additionally check for 'yesterday', 'today' or 'tomorrow' | |
parse = lambda text: pendulum.yesterday() if text == 'yesterday' \ | |
else pendulum.today() if text == 'today' \ | |
else pendulum.tomorrow() if text == 'tomorrow' \ | |
else pendulum.parse(text, **options) | |
try: | |
# Datetime range, i.e. 'x to y' or 'x - y' | |
if re.search(r'\s+(?:to|-)\s+', words, flags=re.IGNORECASE): | |
start, end = re.split(r'\s+(?:to|-)\s+', words, flags=re.IGNORECASE) | |
return pendulum.period(parse(start), parse(end), absolute=True) | |
# Duration offset, i.e. 'in 3 minutes' or '5 years and 2 months ago' | |
elif words.startswith('in ') or words.endswith(' from now'): | |
# Future offset, i.e. 'in an hour' | |
return pendulum.period(pendulum.now(), pendulum.now() + duration_from_words(words), absolute=False) | |
elif words.endswith(' ago') or words.startswith('past '): | |
# Past offset, i.e. 'an hour ago' | |
return pendulum.period(pendulum.now() - duration_from_words(words), pendulum.now(), absolute=False) | |
# Fallback singular datetime, i.e. '9:00am 01/01/1971' | |
else: | |
return pendulum.period(parse(words), pendulum.now(), absolute=True) | |
except: | |
raise ValueError(f'Unsupported period format: {words}') | |
def duration_from_words(words: str) -> pendulum.Duration: | |
""" | |
Parses a given datetime duration query. | |
:param words: datetime duration query | |
:return: parsed datetime duration instance | |
:raise ValueError: if unable to parse the words into a duration instance | |
""" | |
words = re.sub(r'(?<!\w)an?(?!\w)', '1', words, flags=re.IGNORECASE) # e.g. replaces 'an hour' with '1 hour' | |
terms = re.findall(r'(?<!\w)(\d+)\s*(year|month|fortnight|week|day|hour|minute|min|second|sec|millisecond|ms)s?', | |
words, flags=re.IGNORECASE) | |
try: | |
years = months = weeks = days = hours = minutes = seconds = milliseconds = 0 | |
for magnitude, unit in terms: | |
if 'year' == unit: | |
years += int(magnitude) | |
elif 'month' == unit: | |
months += int(magnitude) | |
elif 'fortnight' == unit: | |
weeks += int(magnitude) * 2 | |
elif 'week' == unit: | |
weeks += int(magnitude) | |
elif 'day' == unit: | |
days += int(magnitude) | |
elif 'hour' == unit: | |
hours += int(magnitude) | |
elif 'min' == unit or 'minute' == unit: | |
minutes += int(magnitude) | |
elif 'sec' == unit or 'second' == unit: | |
seconds += int(magnitude) | |
elif 'ms' == unit or 'millisecond' == unit: | |
milliseconds += int(magnitude) | |
except: | |
raise ValueError(f'Invalid duration format: {words}') | |
duration = pendulum.Duration(years=years, months=months, weeks=weeks, days=days, hours=hours, minutes=minutes, | |
seconds=seconds, milliseconds=milliseconds) | |
if duration.total_seconds() != 0: | |
return duration | |
else: | |
raise ValueError(f'No duration offsets found: {words}') |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from unittest import TestCase | |
from pendulum import now, today, datetime, period, Duration, yesterday, tomorrow | |
from pendulum_words import period_from_words, duration_from_words | |
class TestPendulumWords(TestCase): | |
"""Test Cases for Pendulum Words""" | |
def test_period_from_words(self): | |
"""Tests parsing a period from words""" | |
valid_words = [ | |
('5:15am 04/06/2019 to 5/4/2020', period(datetime(year=2019, month=6, day=4, hour=5, minute=15, tz='local'), | |
datetime(year=2020, month=4, day=5, tz='local'))), | |
('5:15am 04/06/2019 - 15:11', period(datetime(year=2019, month=6, day=4, hour=5, minute=15, tz='local'), | |
today().replace(hour=15, minute=11))), | |
('5:15am 04-06-2019 - 15:11', period(datetime(year=2019, month=6, day=4, hour=5, minute=15, tz='local'), | |
today().replace(hour=15, minute=11))), | |
('7:11pm 04/06/2019', period(datetime(year=2019, month=6, day=4, hour=19, minute=11, tz='local'), now())), | |
('04/06/2019', period(datetime(year=2019, month=6, day=4, tz='local'), now())), | |
('1:00pm', period(today().replace(hour=13, minute=0), now())), | |
('yesterday', period(yesterday(), now())), | |
('today', period(today(), now())), | |
('tomorrow', period(now(), tomorrow())), | |
('2 years ago', period(now().subtract(years=2), now())), | |
('past 2 years', period(now().subtract(years=2), now())), | |
('an hour ago', period(now().subtract(hours=1), now())), | |
('1 fortnight ago', period(now().subtract(weeks=2), now())), | |
('1 year, 10 minutes, 3 minutes ago', period(now().subtract(years=1, minutes=13), now())), | |
('in 1 year, 10 minutes', period(now(), now().add(years=1, minutes=10))), | |
('1 year, 10 minutes from now', period(now(), now().add(years=1, minutes=10))), | |
('in a day', period(now(), now().add(days=1))) | |
] | |
for words, actual in valid_words: | |
parsed = period_from_words(words, strict=False, day_first=True, tz='local') | |
self.assertAlmostEqual(actual.as_timedelta(), parsed.as_timedelta(), delta=Duration(milliseconds=100)) | |
def test_invalid_period_from_words(self): | |
"""Tests parsing a period from invalid words""" | |
invalid_words = [ | |
'5:15am 04/06/2019 through 5/4/2020', | |
'25:11 04/06/2019 - 15:11', | |
'25:11am 04-06-2019 - 15:11', | |
'7:11pm 0406/2019', | |
'past fortnight', | |
'tomorroww', | |
'1::00pm', | |
'0 seconds ago', | |
'an hour ago today', | |
'1f0 minutes ago', | |
'1 year in 10 minutes', | |
'in and day' | |
] | |
for words in invalid_words: | |
with self.assertRaises(ValueError): | |
period_from_words(words, strict=False, day_first=True, tz='local') | |
def test_duration_from_words(self): | |
"""Tests parsing a duration from words""" | |
valid_words = [ | |
('2 years', Duration(years=2)), | |
('an hour', Duration(hours=1)), | |
('1 year, 10 minutes', Duration(years=1, minutes=10)), | |
('1 year, 10 minutes, 3 minutes', Duration(years=1, minutes=13)), | |
('a day', Duration(days=1)), | |
('24 hours', Duration(days=1)), | |
('a fortnight', Duration(weeks=2)), | |
('3 fortnights', Duration(weeks=6)) | |
] | |
for words, actual in valid_words: | |
self.assertEqual(actual, duration_from_words(words)) | |
def test_invalid_duration_from_words(self): | |
"""Tests parsing a duration from invalid words""" | |
invalid_words = [ | |
'one fortnight', | |
'tomorroww', | |
'1::00pm', | |
'1f0 minutes', | |
'1f year in 1f0 minutes', | |
'and day' | |
] | |
for words in invalid_words: | |
with self.assertRaises(ValueError): | |
duration_from_words(words) | |
empty_words = ['0 minutes', '1f year'] | |
for words in empty_words: | |
with self.assertRaisesRegex(ValueError, r'No duration offsets found*'): | |
duration_from_words(words) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment