Skip to content

Commit

Permalink
fix: timedelta parsing for int and floats
Browse files Browse the repository at this point in the history
  • Loading branch information
Thomas Leonard committed Nov 15, 2024
1 parent a873df3 commit e75ea00
Show file tree
Hide file tree
Showing 3 changed files with 80 additions and 14 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
37 changes: 27 additions & 10 deletions src/environs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.")
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)

Expand Down
55 changes: 52 additions & 3 deletions tests/test_environs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"})
Expand Down

0 comments on commit e75ea00

Please sign in to comment.