From e238856d0ede772f144727ecb7fbe4921a378efc Mon Sep 17 00:00:00 2001 From: ddelange <14880945+ddelange@users.noreply.github.com> Date: Tue, 12 Nov 2024 19:11:24 +0100 Subject: [PATCH 1/5] Support ordered case-insensitive duration strings for timedelta --- CHANGELOG.md | 2 +- README.md | 4 ++-- src/environs/__init__.py | 32 +++++++++++++++++++++++++++++++- tests/test_environs.py | 19 +++++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c7236..a272a15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 11.1.0 (2025-11-11) +## 11.1.0 (2024-11-11) Features: diff --git a/README.md b/README.md index fe0ccf1..8818091 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 case-insensitive duration string like `7m7s` 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..4ebb201 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,17 @@ _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 any(groups := match.groups()): + return timedelta( + weeks=int(groups[0] or 0), + days=int(groups[1] or 0), + hours=int(groups[2] or 0), + minutes=int(groups[3] or 0), + seconds=int(groups[4] or 0), + milliseconds=int(groups[5] or 0), + microseconds=int(groups[6] or 0), + ) + return super()._deserialize(value, *args, **kwargs) + + class Env: """An environment variable reader.""" @@ -390,7 +420,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..260b17c 100644 --- a/tests/test_environs.py +++ b/tests/test_environs.py @@ -231,6 +231,25 @@ def test_date_cast(self, set_env, env): def test_timedelta_cast(self, set_env, env): set_env({"TIMEDELTA": "42"}) assert env.timedelta("TIMEDELTA") == dt.timedelta(seconds=42) + set_env({"TIMEDELTA": "42s"}) + assert env.timedelta("TIMEDELTA") == dt.timedelta(seconds=42) + # whitespace, case-insensitive, missing units + 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, + ) def test_time_cast(self, set_env, env): set_env({"TIME": "10:30"}) From 8afb8a430c2b74d3bada32920707e3db7ea400ae Mon Sep 17 00:00:00 2001 From: ddelange <14880945+ddelange@users.noreply.github.com> Date: Wed, 13 Nov 2024 08:20:35 +0100 Subject: [PATCH 2/5] Simplify groups unpacking --- README.md | 2 +- src/environs/__init__.py | 16 ++++++++-------- tests/test_environs.py | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8818091..dfbd5f5 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 case-insensitive duration string like `7m7s` or `7w 7d 7h 7m 7s 7ms 7us`) +- `env.timedelta` (assumes value is an integer in seconds, or an ordered case-insensitive 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 4ebb201..eb38d1e 100644 --- a/src/environs/__init__.py +++ b/src/environs/__init__.py @@ -373,15 +373,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()): + if match is not None and any(groups := match.groups(default=0)): return timedelta( - weeks=int(groups[0] or 0), - days=int(groups[1] or 0), - hours=int(groups[2] or 0), - minutes=int(groups[3] or 0), - seconds=int(groups[4] or 0), - milliseconds=int(groups[5] or 0), - microseconds=int(groups[6] or 0), + 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]), ) return super()._deserialize(value, *args, **kwargs) diff --git a/tests/test_environs.py b/tests/test_environs.py index 260b17c..ebdc957 100644 --- a/tests/test_environs.py +++ b/tests/test_environs.py @@ -233,7 +233,7 @@ def test_timedelta_cast(self, set_env, env): assert env.timedelta("TIMEDELTA") == dt.timedelta(seconds=42) set_env({"TIMEDELTA": "42s"}) assert env.timedelta("TIMEDELTA") == dt.timedelta(seconds=42) - # whitespace, case-insensitive, missing units + # whitespaces, case-insensitive, units subselection set_env({"TIMEDELTA": " 42 D 42s "}) assert env.timedelta("TIMEDELTA") == dt.timedelta(days=42, seconds=42) # unicode µs (in addition to us below) From 06d8d1d055dea1a086d6fe37b70735229a4456fd Mon Sep 17 00:00:00 2001 From: ddelange <14880945+ddelange@users.noreply.github.com> Date: Thu, 14 Nov 2024 10:48:37 +0100 Subject: [PATCH 3/5] Allow '0s', disallow '', allow negative integers --- src/environs/__init__.py | 38 +++++++++++++++++++++++--------------- tests/test_environs.py | 25 +++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/environs/__init__.py b/src/environs/__init__.py index eb38d1e..cad480f 100644 --- a/src/environs/__init__.py +++ b/src/environs/__init__.py @@ -32,15 +32,23 @@ _EXPANDED_VAR_PATTERN = re.compile(r"(? 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) diff --git a/tests/test_environs.py b/tests/test_environs.py index ebdc957..1ddce0a 100644 --- a/tests/test_environs.py +++ b/tests/test_environs.py @@ -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) @@ -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"}) From 5b6829d539f0a70733c4510ad04734a857079a8f Mon Sep 17 00:00:00 2001 From: ddelange <14880945+ddelange@users.noreply.github.com> Date: Thu, 14 Nov 2024 21:02:27 +0100 Subject: [PATCH 4/5] Switch to strictly lowercase --- README.md | 2 +- src/environs/__init__.py | 4 +--- tests/test_environs.py | 6 +++--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index dfbd5f5..38d6874 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 case-insensitive duration string like `7h7s` or `7w 7d 7h 7m 7s 7ms 7us`) +- `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 cad480f..8014260 100644 --- a/src/environs/__init__.py +++ b/src/environs/__init__.py @@ -32,11 +32,10 @@ _EXPANDED_VAR_PATTERN = re.compile(r"(? Date: Thu, 14 Nov 2024 16:06:52 -0500 Subject: [PATCH 5/5] Update changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a272a15..dd9dc18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 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: