Skip to content

Commit

Permalink
Support ordered case-insensitive duration strings for timedelta (#366)
Browse files Browse the repository at this point in the history
* Support ordered case-insensitive duration strings for timedelta

* Simplify groups unpacking

* Allow '0s', disallow '', allow negative integers

* Switch to strictly lowercase

* Update changelog

---------

Co-authored-by: Steven Loria <[email protected]>
  • Loading branch information
ddelange and sloria authored Nov 14, 2024
1 parent 4b30384 commit 031cb9b
Show file tree
Hide file tree
Showing 4 changed files with 88 additions and 4 deletions.
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# Changelog

## 11.1.0 (2025-11-11)
## 11.2.0 (unreleased)

Features:

- `Env.timedelta` can parse [GEP-2257](https://gateway-api.sigs.k8s.io/geps/gep-2257/)
duration strings ([#366](https://github.com/sloria/environs/pull/366)).
Thanks [ddelange](https://github.com/ddelange) for the PR.

## 11.1.0 (2024-11-11)

Features:

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ secret = env("SECRET") # => raises error if not set
# casting
max_connections = env.int("MAX_CONNECTIONS") # => 100
ship_date = env.date("SHIP_DATE") # => datetime.date(1984, 6, 25)
ttl = env.timedelta("TTL") # => datetime.timedelta(0, 42)
ttl = env.timedelta("TTL") # => datetime.timedelta(seconds=42)
log_level = env.log_level("LOG_LEVEL") # => logging.DEBUG

# providing a default value
Expand Down 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)
- `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.url`
- `env.uuid`
- `env.log_level`
Expand Down
38 changes: 37 additions & 1 deletion src/environs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import re
import typing
from collections.abc import Mapping
from datetime import timedelta
from enum import Enum
from pathlib import Path
from urllib.parse import ParseResult, urlparse
Expand All @@ -31,6 +32,23 @@


_EXPANDED_VAR_PATTERN = re.compile(r"(?<!\\)\$\{([A-Za-z0-9_]+)(:-[^\}:]*)?\}")
# Ordered 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 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
)


class EnvError(ValueError):
Expand Down Expand Up @@ -356,6 +374,24 @@ def _format_num(self, value) -> int:
raise ma.ValidationError("Not a valid log level.") from error


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"
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),
)
return super()._deserialize(value, *args, **kwargs)


class Env:
"""An environment variable reader."""

Expand Down Expand Up @@ -390,7 +426,7 @@ class Env:
time = _field2method(ma.fields.Time, "time")
path = _field2method(PathField, "path")
log_level = _field2method(LogLevelField, "log_level")
timedelta = _field2method(ma.fields.TimeDelta, "timedelta")
timedelta = _field2method(TimeDeltaField, "timedelta")
uuid = _field2method(ma.fields.UUID, "uuid")
url = _field2method(URLField, "url")
enum = _func2method(_enum_parser, "enum")
Expand Down
40 changes: 40 additions & 0 deletions tests/test_environs.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,48 @@ 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, units subselection (but descending ordering)
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)
# all supported units
set_env({"TIMEDELTA": "42w 42d 42h 42m 42s 42ms 42us"})
assert env.timedelta("TIMEDELTA") == 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"})
with pytest.raises(environs.EnvError):
env.timedelta("TIMEDELTA")
set_env({"TIMEDELTA": "4.2s"})
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 031cb9b

Please sign in to comment.