diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c7236..dd9dc18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/README.md b/README.md index fe0ccf1..38d6874 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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` diff --git a/src/environs/__init__.py b/src/environs/__init__.py index 3fd370b..8014260 100644 --- a/src/environs/__init__.py +++ b/src/environs/__init__.py @@ -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 @@ -31,6 +32,23 @@ _EXPANDED_VAR_PATTERN = re.compile(r"(? 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.""" @@ -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") diff --git a/tests/test_environs.py b/tests/test_environs.py index 8885062..1920c52 100644 --- a/tests/test_environs.py +++ b/tests/test_environs.py @@ -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"})