refactor(time_format): enhance duration parsing with Enum and dynamic regex

- Introduced `ParseDurationUnitFormat` Enum for cleaner unit handling.
- Improved `parse_duration` to support case-insensitive matching and dynamic error messages.
- Added detailed docstrings for better clarity and usability.
- Refactored and added testcases
This commit is contained in:
sgerodes 2025-01-10 00:48:00 +01:00
parent c09a0ebeb2
commit 14bf5adadb
2 changed files with 86 additions and 29 deletions

View File

@ -81,24 +81,42 @@ class TimeFormat(unittest.TestCase, TimezoneMixin):
DAY = 24*60*60 DAY = 24*60*60
MONTH = 31*DAY MONTH = 31*DAY
YEAR = 365*DAY YEAR = 365*DAY
# seconds
self.failUnlessEqual(p("1s"), 1) self.failUnlessEqual(p("1s"), 1)
self.failUnlessEqual(p("12 s"), 12)
self.failUnlessEqual(p("333second"), 333)
self.failUnlessEqual(p(" 333 second "), 333)
self.failUnlessEqual(p("5 seconds"), 5)
self.failUnlessEqual(p("60 SECONDS"), 60)
self.failUnlessEqual(p("86400s"), DAY) self.failUnlessEqual(p("86400s"), DAY)
# days
self.failUnlessEqual(p("1 day"), DAY) self.failUnlessEqual(p("1 day"), DAY)
self.failUnlessEqual(p("2 days"), 2*DAY) self.failUnlessEqual(p("2 days"), 2*DAY)
self.failUnlessEqual(p("3 months"), 3*MONTH) self.failUnlessEqual(p("5days"), 5*DAY)
self.failUnlessEqual(p("4 mo"), 4*MONTH)
self.failUnlessEqual(p("5 years"), 5*YEAR)
e = self.failUnlessRaises(ValueError, p, "123")
self.failUnlessIn("no unit (like day, month, or year) in '123'",
str(e))
self.failUnlessEqual(p("7days"), 7*DAY) self.failUnlessEqual(p("7days"), 7*DAY)
self.failUnlessEqual(p("31day"), 31*DAY) self.failUnlessEqual(p("31day"), 31*DAY)
self.failUnlessEqual(p("60 days"), 60*DAY) self.failUnlessEqual(p("60 days"), 60*DAY)
self.failUnlessEqual(p("70 DAYS"), 70*DAY)
# months
self.failUnlessEqual(p("4 mo"), 4*MONTH)
self.failUnlessEqual(p("2mo"), 2*MONTH) self.failUnlessEqual(p("2mo"), 2*MONTH)
self.failUnlessEqual(p("3 month"), 3*MONTH) self.failUnlessEqual(p("3 month"), 3*MONTH)
self.failUnlessEqual(p("3 months"), 3*MONTH)
# years
self.failUnlessEqual(p("5 years"), 5*YEAR)
self.failUnlessEqual(p("8 year"), 8*YEAR)
self.failUnlessEqual(p("2years"), 2*YEAR) self.failUnlessEqual(p("2years"), 2*YEAR)
self.failUnlessEqual(p("11YEARS"), 11*YEAR)
# errors
e = self.failUnlessRaises(ValueError, p, "123")
self.failUnlessIn("No valid unit in",str(e))
e = self.failUnlessRaises(ValueError, p, "2kumquats") e = self.failUnlessRaises(ValueError, p, "2kumquats")
self.failUnlessIn("no unit (like day, month, or year) in '2kumquats'", str(e)) self.failUnlessIn("No valid unit in", str(e))
def test_parse_date(self): def test_parse_date(self):
p = time_format.parse_date p = time_format.parse_date

View File

@ -6,8 +6,26 @@ http://www.cl.cam.ac.uk/~mgk25/iso-time.html
""" """
import calendar, datetime, re, time import calendar, datetime, re, time
from typing import Optional from typing import Optional
from enum import Enum
class ParseDurationUnitFormat(Enum):
SECONDS0 = "s"
SECONDS1 = "second"
SECONDS2 = "seconds"
DAYS0 = "day"
DAYS1 = "days"
MONTHS0 = "mo"
MONTHS1 = "month"
MONTHS2 = "months"
YEARS0 = "year"
YEARS1 = "years"
@classmethod
def list_values(cls):
return list(map(lambda c: c.value, cls))
def format_time(t): def format_time(t):
return time.strftime("%Y-%m-%d %H:%M:%S", t) return time.strftime("%Y-%m-%d %H:%M:%S", t)
@ -50,32 +68,53 @@ def iso_utc_time_to_seconds(isotime, _conversion_re=re.compile(r"(?P<year>\d{4})
return calendar.timegm( (year, month, day, hour, minute, second, 0, 1, 0) ) + subsecfloat return calendar.timegm( (year, month, day, hour, minute, second, 0, 1, 0) ) + subsecfloat
def parse_duration(s): def parse_duration(s):
orig = s """
unit = None Parses a duration string and converts it to seconds. The unit format is case insensitive
Args:
s (str): The duration string to parse. Expected format: `<number><unit>`
where `unit` can be one of the values defined in `ParseDurationUnitFormat`.
Returns:
int: The duration in seconds.
Raises:
ValueError: If the input string does not match the expected format or contains invalid units.
"""
SECOND = 1 SECOND = 1
DAY = 24*60*60 DAY = 24*60*60
MONTH = 31*DAY MONTH = 31*DAY
YEAR = 365*DAY YEAR = 365*DAY
if s.endswith("s"): time_map = {
unit = SECOND ParseDurationUnitFormat.SECONDS0: SECOND,
s = s[:-1] ParseDurationUnitFormat.SECONDS1: SECOND,
elif s.endswith("day"): ParseDurationUnitFormat.SECONDS2: SECOND,
unit = DAY ParseDurationUnitFormat.DAYS0: DAY,
s = s[:-len("day")] ParseDurationUnitFormat.DAYS1: DAY,
elif s.endswith("month"): ParseDurationUnitFormat.MONTHS0: MONTH,
unit = MONTH ParseDurationUnitFormat.MONTHS1: MONTH,
s = s[:-len("month")] ParseDurationUnitFormat.MONTHS2: MONTH,
elif s.endswith("mo"): ParseDurationUnitFormat.YEARS0: YEAR,
unit = MONTH ParseDurationUnitFormat.YEARS1: YEAR,
s = s[:-len("mo")] }
elif s.endswith("year"):
unit = YEAR # Build a regex pattern dynamically from the list of valid values
s = s[:-len("YEAR")] unit_pattern = "|".join(re.escape(unit) for unit in ParseDurationUnitFormat.list_values())
else: pattern = rf"^\s*(\d+)\s*({unit_pattern})\s*$"
raise ValueError("no unit (like s, day, mo, month, or year) in '%s'" % orig)
s = s.strip() # case-insensitive regex matching
return int(s) * unit match = re.match(pattern, s, re.IGNORECASE)
if not match:
# Generate dynamic error message
valid_units = ", ".join(f"'{value}'" for value in ParseDurationUnitFormat.list_values())
raise ValueError(f"No valid unit in '{s}'. Expected one of: ({valid_units})")
number = int(match.group(1)) # Extract the numeric value
unit = match.group(2).lower() # Extract the unit & normalize the unit to lowercase
return number * time_map[unit]
def parse_date(s): def parse_date(s):
# return seconds-since-epoch for the UTC midnight that starts the given # return seconds-since-epoch for the UTC midnight that starts the given