From 1dce09ac4ca8b188e4180693da2e2480e5940ded Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Fri, 15 Nov 2024 13:38:32 +0100 Subject: [PATCH] fix: timedelta parsing for int and floats --- README.md | 2 +- src/environs/__init__.py | 37 +++++++++++++++++++-------- tests/test_environs.py | 55 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 80 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 38d6874..bb57c25 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ The following are all type-casting methods of `Env`: - `env.datetime` - `env.date` - `env.time` -- `env.timedelta` (assumes value is an integer in seconds, or an ordered duration string like `7h7s` or `7w 7d 7h 7m 7s 7ms 7us`) +- `env.timedelta` (assumes value is an float in seconds, or an ordered duration string like `7h7s` or `7w 7d 7h 7m 7s 7ms 7us`) - `env.url` - `env.uuid` - `env.log_level` diff --git a/src/environs/__init__.py b/src/environs/__init__.py index 8014260..acae182 100644 --- a/src/environs/__init__.py +++ b/src/environs/__init__.py @@ -40,7 +40,6 @@ # - this pattern does not allow duplicate unit occurrences, GEP-2257 does # - this pattern allows for negative integers, GEP-2257 does not _TIMEDELTA_PATTERN = re.compile( - r"^(?:\s*)" # optional whitespace at the beginning of the string r"(?:(-?\d+)\s*w\s*)?" # weeks with optional whitespace around unit r"(?:(-?\d+)\s*d\s*)?" # days with optional whitespace around unit r"(?:(-?\d+)\s*h\s*)?" # hours with optional whitespace around unit @@ -378,16 +377,34 @@ class TimeDeltaField(ma.fields.TimeDelta): def _deserialize(self, value, *args, **kwargs) -> timedelta: if isinstance(value, timedelta): return value - match = _TIMEDELTA_PATTERN.match(value) - if match is not None and match.group(0): # disallow "", allow "0s" + if isinstance(value, str): + if value.strip() == "": + raise ma.ValidationError( + "An empty string is not a valid period of time." + ) + match = _TIMEDELTA_PATTERN.match(value.strip()) + if match is not None: + return timedelta( + weeks=int(match.group(1) or 0), + days=int(match.group(2) or 0), + hours=int(match.group(3) or 0), + minutes=int(match.group(4) or 0), + seconds=int(match.group(5) or 0), + milliseconds=int(match.group(6) or 0), + microseconds=int(match.group(7) or 0), + ) + try: + value = float(value) + except ValueError: + raise ma.ValidationError("Not a valid period of time.") from None + if isinstance(value, bool): + raise ma.ValidationError("Not a valid period of time.") + if isinstance(value, (int, float)): + seconds = int(value) + milliseconds = int(value * 1000 % 1000) return timedelta( - weeks=int(match.group(1) or 0), - days=int(match.group(2) or 0), - hours=int(match.group(3) or 0), - minutes=int(match.group(4) or 0), - seconds=int(match.group(5) or 0), - milliseconds=int(match.group(6) or 0), - microseconds=int(match.group(7) or 0), + seconds=seconds, + milliseconds=milliseconds, ) return super()._deserialize(value, *args, **kwargs) diff --git a/tests/test_environs.py b/tests/test_environs.py index 1920c52..c93b4d3 100644 --- a/tests/test_environs.py +++ b/tests/test_environs.py @@ -229,26 +229,57 @@ def test_date_cast(self, set_env, env): assert env.date("DATE") == date def test_timedelta_cast(self, set_env, env): - # seconds as integer + # default as an integer, a float or a timedelta + assert env.timedelta("NO_VALUE", default=0) == dt.timedelta() + assert env.timedelta("NO_VALUE", default=42) == dt.timedelta(seconds=42) + assert env.timedelta("NO_VALUE", default=-42) == dt.timedelta(seconds=-42) + assert env.timedelta("NO_VALUE", default=42.3) == dt.timedelta( + seconds=42, milliseconds=300 + ) + assert env.timedelta( + "NO_VALUE", default=dt.timedelta(seconds=42) + ) == dt.timedelta(seconds=42) + assert env.timedelta( + "NO_VALUE", default=dt.timedelta(seconds=42, milliseconds=300) + ) == dt.timedelta(seconds=42, milliseconds=300) + # seconds as integer string set_env({"TIMEDELTA": "0"}) assert env.timedelta("TIMEDELTA") == dt.timedelta() + assert env.timedelta("NO_VALUE", default="0") == dt.timedelta() set_env({"TIMEDELTA": "42"}) assert env.timedelta("TIMEDELTA") == dt.timedelta(seconds=42) + assert env.timedelta("NO_VALUE", default="42") == dt.timedelta(seconds=42) set_env({"TIMEDELTA": "-42"}) assert env.timedelta("TIMEDELTA") == dt.timedelta(seconds=-42) + assert env.timedelta("NO_VALUE", default="-42") == dt.timedelta(seconds=-42) + # seconds as a float string + set_env({"TIMEDELTA": "42.3"}) + assert env.timedelta("TIMEDELTA") == dt.timedelta(seconds=42, milliseconds=300) + assert env.timedelta("NO_VALUE", default="42.3") == dt.timedelta( + seconds=42, milliseconds=300 + ) # seconds as duration string set_env({"TIMEDELTA": "0s"}) assert env.timedelta("TIMEDELTA") == dt.timedelta() + assert env.timedelta("NO_VALUE", default="0s") == dt.timedelta() set_env({"TIMEDELTA": "42s"}) assert env.timedelta("TIMEDELTA") == dt.timedelta(seconds=42) + assert env.timedelta("NO_VALUE", default="42s") == dt.timedelta(seconds=42) set_env({"TIMEDELTA": "-42s"}) assert env.timedelta("TIMEDELTA") == dt.timedelta(seconds=-42) + assert env.timedelta("NO_VALUE", default="-42s") == dt.timedelta(seconds=-42) # whitespaces, units subselection (but descending ordering) set_env({"TIMEDELTA": " 42 d -42s "}) assert env.timedelta("TIMEDELTA") == dt.timedelta(days=42, seconds=-42) + assert env.timedelta("NO_VALUE", default=" 42 d -42s ") == dt.timedelta( + days=42, seconds=-42 + ) # unicode µs (in addition to us below) set_env({"TIMEDELTA": "42µs"}) assert env.timedelta("TIMEDELTA") == dt.timedelta(microseconds=42) + assert env.timedelta("NO_VALUE", default="42µs") == dt.timedelta( + microseconds=42 + ) # all supported units set_env({"TIMEDELTA": "42w 42d 42h 42m 42s 42ms 42us"}) assert env.timedelta("TIMEDELTA") == dt.timedelta( @@ -260,17 +291,35 @@ def test_timedelta_cast(self, set_env, env): milliseconds=42, microseconds=42, ) + assert env.timedelta( + "NO_VALUE", default="42w 42d 42h 42m 42s 42ms 42us" + ) == dt.timedelta( + weeks=42, + days=42, + hours=42, + minutes=42, + seconds=42, + milliseconds=42, + microseconds=42, + ) # empty string not allowed set_env({"TIMEDELTA": ""}) with pytest.raises(environs.EnvError): env.timedelta("TIMEDELTA") - # float not allowed - set_env({"TIMEDELTA": "4.2"}) + set_env({"TIMEDELTA": "something"}) with pytest.raises(environs.EnvError): env.timedelta("TIMEDELTA") set_env({"TIMEDELTA": "4.2s"}) with pytest.raises(environs.EnvError): env.timedelta("TIMEDELTA") + with pytest.raises(environs.EnvError): + env.timedelta("NO_VALUE", default="") + with pytest.raises(environs.EnvError): + env.timedelta("NO_VALUE", default="4.2s") + with pytest.raises(environs.EnvError): + env.timedelta("NO_VALUE", default=True) + with pytest.raises(environs.EnvError): + env.timedelta("NO_VALUE", default=dt.datetime.now()) def test_time_cast(self, set_env, env): set_env({"TIME": "10:30"})