Skip to content

Instantly share code, notes, and snippets.

@axieum
Created July 30, 2020 06:46
Show Gist options
  • Save axieum/bb8319e06dd74fa247843b12bf3094ef to your computer and use it in GitHub Desktop.
Save axieum/bb8319e06dd74fa247843b12bf3094ef to your computer and use it in GitHub Desktop.
Pendulum Period From Words
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}')
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