Skip to content

Commit

Permalink
Merge pull request tahoe-lafs#1425 from sgerodes/master
Browse files Browse the repository at this point in the history
fix(parse_duration): resolve error when parsing durations in seconds
  • Loading branch information
meejah authored Jan 15, 2025
2 parents f451755 + 8aed2d5 commit a1efd53
Show file tree
Hide file tree
Showing 3 changed files with 89 additions and 28 deletions.
Empty file added newsfragments/4155.minor
Empty file.
34 changes: 27 additions & 7 deletions src/allmydata/test/test_time_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,22 +81,42 @@ def test_parse_duration(self):
DAY = 24*60*60
MONTH = 31*DAY
YEAR = 365*DAY

# seconds
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)

# days
self.failUnlessEqual(p("1 day"), DAY)
self.failUnlessEqual(p("2 days"), 2*DAY)
self.failUnlessEqual(p("3 months"), 3*MONTH)
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("5days"), 5*DAY)
self.failUnlessEqual(p("7days"), 7*DAY)
self.failUnlessEqual(p("31day"), 31*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("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("11YEARS"), 11*YEAR)

# errors
e = self.failUnlessRaises(ValueError, p, "123")
self.failUnlessIn("No valid unit in",str(e))
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):
p = time_format.parse_date
Expand Down
83 changes: 62 additions & 21 deletions src/allmydata/util/time_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,26 @@
"""

import calendar, datetime, re, time

from typing import Optional
from enum import Enum


class ParseDurationUnitFormat(str, 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):
return time.strftime("%Y-%m-%d %H:%M:%S", t)
Expand Down Expand Up @@ -50,30 +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


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
DAY = 24*60*60
MONTH = 31*DAY
YEAR = 365*DAY
if s.endswith("s"):
s = s[:-1]
if s.endswith("day"):
unit = DAY
s = s[:-len("day")]
elif s.endswith("month"):
unit = MONTH
s = s[:-len("month")]
elif s.endswith("mo"):
unit = MONTH
s = s[:-len("mo")]
elif s.endswith("year"):
unit = YEAR
s = s[:-len("YEAR")]
else:
raise ValueError("no unit (like day, month, or year) in '%s'" % orig)
s = s.strip()
return int(s) * unit
time_map = {
ParseDurationUnitFormat.SECONDS0: SECOND,
ParseDurationUnitFormat.SECONDS1: SECOND,
ParseDurationUnitFormat.SECONDS2: SECOND,
ParseDurationUnitFormat.DAYS0: DAY,
ParseDurationUnitFormat.DAYS1: DAY,
ParseDurationUnitFormat.MONTHS0: MONTH,
ParseDurationUnitFormat.MONTHS1: MONTH,
ParseDurationUnitFormat.MONTHS2: MONTH,
ParseDurationUnitFormat.YEARS0: YEAR,
ParseDurationUnitFormat.YEARS1: YEAR,
}

# Build a regex pattern dynamically from the list of valid values
unit_pattern = "|".join(re.escape(unit) for unit in ParseDurationUnitFormat.list_values())
pattern = rf"^\s*(\d+)\s*({unit_pattern})\s*$"

# case-insensitive regex matching
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):
# return seconds-since-epoch for the UTC midnight that starts the given
Expand Down

0 comments on commit a1efd53

Please sign in to comment.