diff --git a/newsfragments/4155.minor b/newsfragments/4155.minor new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/allmydata/test/test_time_format.py b/src/allmydata/test/test_time_format.py index f3b9a89903..f89df10d2e 100644 --- a/src/allmydata/test/test_time_format.py +++ b/src/allmydata/test/test_time_format.py @@ -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 diff --git a/src/allmydata/util/time_format.py b/src/allmydata/util/time_format.py index fb4d735ab0..6523657876 100644 --- a/src/allmydata/util/time_format.py +++ b/src/allmydata/util/time_format.py @@ -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) @@ -50,30 +68,53 @@ def iso_utc_time_to_seconds(isotime, _conversion_re=re.compile(r"(?P\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: `` + 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