Skip to content

Commit

Permalink
Allow '0s', disallow '', allow negative integers
Browse files Browse the repository at this point in the history
  • Loading branch information
ddelange committed Nov 14, 2024
1 parent 8afb8a4 commit 06d8d1d
Show file tree
Hide file tree
Showing 2 changed files with 46 additions and 17 deletions.
38 changes: 23 additions & 15 deletions src/environs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,23 @@


_EXPANDED_VAR_PATTERN = re.compile(r"(?<!\\)\$\{([A-Za-z0-9_]+)(:-[^\}:]*)?\}")
# Ordered case-insensitive duration strings, loosely based on the [GEP-2257](https://gateway-api.sigs.k8s.io/geps/gep-2257/) spec
# Discrepancies between this pattern and GEP-2257 duration strings:
# - this pattern accepts units `w|d|h|m|s|ms|[uµ]s` (all units supported by the datetime.timedelta constructor), GEP-2257 accepts only `h|m|s|ms`
# - this pattern allows for optional whitespace around the units, GEP-2257 does not
# - this pattern is compiled to be case-insensitive, GEP-2257 expects lowercase units
# - this pattern expects ordered (descending) units, GEP-2257 allows arbitrary order
# - 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
r"(?:(\d+)\s*m\s*)?" # minutes with optional whitespace around unit
r"(?:(\d+)\s*s\s*)?" # seconds with optional whitespace around unit
r"(?:(\d+)\s*ms\s*)?" # milliseconds with optional whitespace around unit
r"(?:(\d+)\s*[µu]s\s*)?$", # microseconds with optional whitespace around unit
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
r"(?:(-?\d+)\s*m\s*)?" # minutes with optional whitespace around unit
r"(?:(-?\d+)\s*s\s*)?" # seconds with optional whitespace around unit
r"(?:(-?\d+)\s*ms\s*)?" # milliseconds with optional whitespace around unit
r"(?:(-?\d+)\s*[µu]s\s*)?$", # microseconds with optional whitespace around unit
flags=re.IGNORECASE,
)

Expand Down Expand Up @@ -373,15 +381,15 @@ def _deserialize(self, value, *args, **kwargs) -> timedelta:
if isinstance(value, timedelta):
return value
match = _TIMEDELTA_PATTERN.match(value)
if match is not None and any(groups := match.groups(default=0)):
if match is not None and match.group(0): # disallow "", allow "0s"
return timedelta(
weeks=int(groups[0]),
days=int(groups[1]),
hours=int(groups[2]),
minutes=int(groups[3]),
seconds=int(groups[4]),
milliseconds=int(groups[5]),
microseconds=int(groups[6]),
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),
)
return super()._deserialize(value, *args, **kwargs)

Expand Down
25 changes: 23 additions & 2 deletions tests/test_environs.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,13 +229,23 @@ def test_date_cast(self, set_env, env):
assert env.date("DATE") == date

def test_timedelta_cast(self, set_env, env):
# seconds as integer
set_env({"TIMEDELTA": "0"})
assert env.timedelta("TIMEDELTA") == dt.timedelta()
set_env({"TIMEDELTA": "42"})
assert env.timedelta("TIMEDELTA") == dt.timedelta(seconds=42)
set_env({"TIMEDELTA": "-42"})
assert env.timedelta("TIMEDELTA") == dt.timedelta(seconds=-42)
# seconds as duration string
set_env({"TIMEDELTA": "0s"})
assert env.timedelta("TIMEDELTA") == dt.timedelta()
set_env({"TIMEDELTA": "42s"})
assert env.timedelta("TIMEDELTA") == dt.timedelta(seconds=42)
set_env({"TIMEDELTA": "-42s"})
assert env.timedelta("TIMEDELTA") == dt.timedelta(seconds=-42)
# whitespaces, case-insensitive, units subselection
set_env({"TIMEDELTA": " 42 D 42s "})
assert env.timedelta("TIMEDELTA") == dt.timedelta(days=42, seconds=42)
set_env({"TIMEDELTA": " 42 D -42s "})
assert env.timedelta("TIMEDELTA") == 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)
Expand All @@ -250,6 +260,17 @@ def test_timedelta_cast(self, set_env, env):
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"})
with pytest.raises(environs.EnvError):
env.timedelta("TIMEDELTA")
set_env({"TIMEDELTA": "4.2d"})
with pytest.raises(environs.EnvError):
env.timedelta("TIMEDELTA")

def test_time_cast(self, set_env, env):
set_env({"TIME": "10:30"})
Expand Down

0 comments on commit 06d8d1d

Please sign in to comment.