Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support ordered duration strings for timedelta #366

Merged
merged 5 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch

## 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