From a555c13f3873ac4aad53c61af544db7a2ff17d4b Mon Sep 17 00:00:00 2001 From: sgerodes Date: Wed, 8 Jan 2025 16:42:10 +0100 Subject: [PATCH 1/6] fix(parse_duration): resolve error when parsing durations in seconds - Added support for parsing durations specified in seconds (e.g., "10s"). - Fixed an issue where configuring seconds previously resulted in errors due to missing elif statement. --- src/allmydata/util/time_format.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/allmydata/util/time_format.py b/src/allmydata/util/time_format.py index fb4d735ab0..c65072c0db 100644 --- a/src/allmydata/util/time_format.py +++ b/src/allmydata/util/time_format.py @@ -53,12 +53,14 @@ def iso_utc_time_to_seconds(isotime, _conversion_re=re.compile(r"(?P\d{4}) def parse_duration(s): orig = s unit = None + SECOND = 1 DAY = 24*60*60 MONTH = 31*DAY YEAR = 365*DAY if s.endswith("s"): + unit = SECOND s = s[:-1] - if s.endswith("day"): + elif s.endswith("day"): unit = DAY s = s[:-len("day")] elif s.endswith("month"): From 875f7fa47e8832d6ab1d9d136b158817cf71ea54 Mon Sep 17 00:00:00 2001 From: sgerodes Date: Wed, 8 Jan 2025 16:50:29 +0100 Subject: [PATCH 2/6] test(parse_duration): add tests for seconds --- src/allmydata/test/test_time_format.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/allmydata/test/test_time_format.py b/src/allmydata/test/test_time_format.py index f3b9a89903..373b380a46 100644 --- a/src/allmydata/test/test_time_format.py +++ b/src/allmydata/test/test_time_format.py @@ -81,6 +81,8 @@ def test_parse_duration(self): DAY = 24*60*60 MONTH = 31*DAY YEAR = 365*DAY + self.failUnlessEqual(p("1s"), 1) + self.failUnlessEqual(p("86400s"), DAY) self.failUnlessEqual(p("1 day"), DAY) self.failUnlessEqual(p("2 days"), 2*DAY) self.failUnlessEqual(p("3 months"), 3*MONTH) From c09a0ebeb2d560f3fc8aea6e3a377749590d56f3 Mon Sep 17 00:00:00 2001 From: sgerodes Date: Wed, 8 Jan 2025 16:56:03 +0100 Subject: [PATCH 3/6] feat(parse_duration): improve the error message --- src/allmydata/util/time_format.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/allmydata/util/time_format.py b/src/allmydata/util/time_format.py index c65072c0db..fd86376d62 100644 --- a/src/allmydata/util/time_format.py +++ b/src/allmydata/util/time_format.py @@ -73,7 +73,7 @@ def parse_duration(s): unit = YEAR s = s[:-len("YEAR")] else: - raise ValueError("no unit (like day, month, or year) in '%s'" % orig) + raise ValueError("no unit (like s, day, mo, month, or year) in '%s'" % orig) s = s.strip() return int(s) * unit From 14bf5adadb4b5a070a069be78657b80b3e97aa98 Mon Sep 17 00:00:00 2001 From: sgerodes Date: Fri, 10 Jan 2025 00:48:00 +0100 Subject: [PATCH 4/6] 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 --- src/allmydata/test/test_time_format.py | 32 +++++++--- src/allmydata/util/time_format.py | 83 +++++++++++++++++++------- 2 files changed, 86 insertions(+), 29 deletions(-) diff --git a/src/allmydata/test/test_time_format.py b/src/allmydata/test/test_time_format.py index 373b380a46..f89df10d2e 100644 --- a/src/allmydata/test/test_time_format.py +++ b/src/allmydata/test/test_time_format.py @@ -81,24 +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 fd86376d62..4404b3f51e 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(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,32 +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"): - unit = SECOND - s = s[:-1] - elif 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 s, day, mo, 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 From c73541a88bfc0e37180f8dde75b1577213ba8313 Mon Sep 17 00:00:00 2001 From: sgerodes Date: Fri, 10 Jan 2025 00:49:38 +0100 Subject: [PATCH 5/6] chore(news): add newsfragment for ticket #4155 to ensure codechecks pass --- newsfragments/4155.minor | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 newsfragments/4155.minor diff --git a/newsfragments/4155.minor b/newsfragments/4155.minor new file mode 100644 index 0000000000..e69de29bb2 From 8aed2d51c83aa091642f33561b2cf1afdc35e3fb Mon Sep 17 00:00:00 2001 From: sgerodes Date: Fri, 10 Jan 2025 01:00:54 +0100 Subject: [PATCH 6/6] fix(time_format): invalid comparison of strings to enums --- src/allmydata/util/time_format.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/allmydata/util/time_format.py b/src/allmydata/util/time_format.py index 4404b3f51e..6523657876 100644 --- a/src/allmydata/util/time_format.py +++ b/src/allmydata/util/time_format.py @@ -10,7 +10,7 @@ from enum import Enum -class ParseDurationUnitFormat(Enum): +class ParseDurationUnitFormat(str, Enum): SECONDS0 = "s" SECONDS1 = "second" SECONDS2 = "seconds"