From bed3d356ef20a15a61c15220c60cafc889c9f655 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 13 Sep 2026 21:54:02 +0200 Subject: [PATCH 1/6] add timedelta env var --- .../news/+envvar-timedelta.feature.md | 1 + .../src/reflex_base/environment.py | 52 +++++++++++++++ tests/units/test_environment.py | 66 +++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 packages/reflex-base/news/+envvar-timedelta.feature.md diff --git a/packages/reflex-base/news/+envvar-timedelta.feature.md b/packages/reflex-base/news/+envvar-timedelta.feature.md new file mode 100644 index 00000000000..56854458d2c --- /dev/null +++ b/packages/reflex-base/news/+envvar-timedelta.feature.md @@ -0,0 +1 @@ +`EnvVar` now supports `timedelta`. A bare number is read as seconds, and `ms`, `s`, `m`, `h` and `d` suffixes are understood, so `TIMEOUT=30`, `TIMEOUT=30s` and `TIMEOUT=5m` are all valid. diff --git a/packages/reflex-base/src/reflex_base/environment.py b/packages/reflex-base/src/reflex_base/environment.py index 3bb80d6970b..df69f80366e 100644 --- a/packages/reflex-base/src/reflex_base/environment.py +++ b/packages/reflex-base/src/reflex_base/environment.py @@ -7,7 +7,9 @@ import importlib import logging import os +import re from collections.abc import Sequence +from datetime import timedelta from functools import lru_cache from pathlib import Path from typing import ( @@ -116,6 +118,54 @@ def interpret_float_env(value: str, field_name: str) -> float: raise EnvironmentVarValueError(msg) from ve +_TIMEDELTA_UNITS: dict[str, str] = { + "ms": "milliseconds", + "s": "seconds", + "m": "minutes", + "h": "hours", + "d": "days", +} + +_TIMEDELTA_PATTERN = re.compile(r"([+-]?\d+(?:\.\d+)?)\s*([a-z]*)") + + +def interpret_timedelta_env(value: str, field_name: str) -> timedelta: + """Interpret a duration environment variable value. + + A bare number is read as seconds, so an existing integer setting keeps + working when its type becomes a duration. A unit suffix overrides that: + ``ms``, ``s``, ``m``, ``h`` and ``d`` are understood, making ``30``, ``30s``, + ``500ms`` and ``5m`` all valid. + + Args: + value: The environment variable value. + field_name: The field name. + + Returns: + The interpreted value. + + Raises: + EnvironmentVarValueError: If the value is invalid. + """ + match = _TIMEDELTA_PATTERN.fullmatch(value.strip().lower()) + keyword = _TIMEDELTA_UNITS.get(match.group(2) or "s") if match else None + if match is None or keyword is None: + units = ", ".join(_TIMEDELTA_UNITS) + msg = ( + f"Invalid duration value: {value!r} for {field_name}. Expected a " + f"number of seconds, optionally suffixed with one of {units}." + ) + raise EnvironmentVarValueError(msg) + try: + return timedelta(**{keyword: float(match.group(1))}) + except (OverflowError, ValueError) as e: + # A value can be well-formed and still be more than a timedelta holds. + # OverflowError is not a ValueError, so letting it out would escape the + # union fallback in `interpret_env_var_value` as well as this contract. + msg = f"Invalid duration value: {value!r} for {field_name} is out of range." + raise EnvironmentVarValueError(msg) from e + + def interpret_existing_path_env(value: str, field_name: str) -> ExistingPath: """Interpret a path environment variable value as an existing path. @@ -326,6 +376,8 @@ def interpret_env_var_value( return interpret_int_env(value, field_name) if field_type is float: return interpret_float_env(value, field_name) + if field_type is timedelta: + return interpret_timedelta_env(value, field_name) if field_type is Path: if PathExistsFlag in annotated_metadata: return interpret_existing_path_env(value, field_name) diff --git a/tests/units/test_environment.py b/tests/units/test_environment.py index f736b76eb28..57c7104891b 100644 --- a/tests/units/test_environment.py +++ b/tests/units/test_environment.py @@ -4,6 +4,7 @@ import logging import os import tempfile +from datetime import timedelta from pathlib import Path from typing import Annotated from unittest.mock import patch @@ -32,6 +33,7 @@ interpret_path_env, interpret_plugin_class_env, interpret_plugin_env, + interpret_timedelta_env, ) from reflex_base.plugins import Plugin from reflex_base.utils.exceptions import EnvironmentVarValueError @@ -683,3 +685,67 @@ def cleanup_env_vars(): if var in os.environ: print(var) del os.environ[var] + + +def test_interpret_timedelta_env_defaults_to_seconds() -> None: + """A bare number keeps an existing integer setting working unchanged.""" + assert interpret_timedelta_env("30", "TEST_FIELD") == timedelta(seconds=30) + assert interpret_timedelta_env("1.5", "TEST_FIELD") == timedelta(seconds=1.5) + assert interpret_timedelta_env("0", "TEST_FIELD") == timedelta(0) + + +def test_interpret_timedelta_env_units() -> None: + """A suffix overrides the default unit.""" + assert interpret_timedelta_env("500ms", "TEST_FIELD") == timedelta(milliseconds=500) + assert interpret_timedelta_env("30s", "TEST_FIELD") == timedelta(seconds=30) + assert interpret_timedelta_env("5m", "TEST_FIELD") == timedelta(minutes=5) + assert interpret_timedelta_env("2h", "TEST_FIELD") == timedelta(hours=2) + assert interpret_timedelta_env("1d", "TEST_FIELD") == timedelta(days=1) + + +def test_interpret_timedelta_env_tolerates_spacing_and_case() -> None: + """Values come from a shell, where spacing and case are easily off.""" + assert interpret_timedelta_env(" 5 M ", "TEST_FIELD") == timedelta(minutes=5) + + +def test_interpret_timedelta_env_negative() -> None: + """``timedelta`` is signed, so a negative offset is a legitimate value.""" + assert interpret_timedelta_env("-5m", "TEST_FIELD") == timedelta(minutes=-5) + + +def test_interpret_timedelta_env_invalid() -> None: + """Test duration interpretation with invalid values.""" + for value in ("not_a_number", "30 weeks", "30y", "", "s"): + with pytest.raises(EnvironmentVarValueError, match="Invalid duration value"): + interpret_timedelta_env(value, "TEST_FIELD") + + +def test_interpret_timedelta_env_out_of_range() -> None: + """A well-formed value can still be more than a timedelta holds. + + ``timedelta`` raises ``OverflowError``, which is not a ``ValueError``, so + letting it out would escape both this function's contract and the union + fallback in ``interpret_env_var_value``. + """ + for value in ("999999999999d", "9" * 400): + with pytest.raises(EnvironmentVarValueError, match="out of range"): + interpret_timedelta_env(value, "TEST_FIELD") + + +def test_timedelta_env_var_reads_a_duration(monkeypatch: pytest.MonkeyPatch) -> None: + """A duration setting reads like any other typed environment variable. + + Args: + monkeypatch: pytest monkeypatch fixture. + """ + monkeypatch.setenv("TEST_TIMEOUT", "90s") + env_var_instance = EnvVar("TEST_TIMEOUT", timedelta(seconds=30), timedelta) + + assert env_var_instance.getenv() == timedelta(seconds=90) + + +def test_timedelta_env_var_falls_back_to_its_default() -> None: + """An unset duration keeps the default the app declared.""" + env_var_instance = EnvVar("TEST_TIMEOUT_UNSET", timedelta(minutes=3), timedelta) + + assert env_var_instance.get() == timedelta(minutes=3) From 211ed18214ac2eb1ac38bc58e2c69ad115d5d028 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 13 Sep 2026 23:28:41 +0200 Subject: [PATCH 2/6] serialize durations numerically so set and get round-trip --- .../src/reflex_base/environment.py | 22 ++++++++++-- tests/units/test_environment.py | 36 +++++++++++++++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/environment.py b/packages/reflex-base/src/reflex_base/environment.py index df69f80366e..7c3ce45c55b 100644 --- a/packages/reflex-base/src/reflex_base/environment.py +++ b/packages/reflex-base/src/reflex_base/environment.py @@ -446,6 +446,24 @@ def interpret_env_var_value( T = TypeVar("T") +def _serialize_env_value(value: Any) -> str: + """Render a value in the form :func:`interpret_env_var_value` reads back. + + Only durations need help: ``str(timedelta)`` is ``0:01:30``, and past a day or + below zero it is ``1 day, 0:00:30`` / ``-1 day, 23:58:30``, none of which the + interpreter accepts. + + Args: + value: The value to render. + + Returns: + The rendered value. + """ + if isinstance(value, timedelta): + return str(value.total_seconds()) + return str(value) + + class EnvVar(Generic[T]): """Environment variable.""" @@ -518,9 +536,9 @@ def set(self, value: T | None) -> None: if isinstance(value, enum.Enum): value = value.value if isinstance(value, list): - str_value = ":".join(str(v) for v in value) + str_value = ":".join(_serialize_env_value(v) for v in value) else: - str_value = str(value) + str_value = _serialize_env_value(value) os.environ[self.name] = str_value diff --git a/tests/units/test_environment.py b/tests/units/test_environment.py index 57c7104891b..52e5d0cef01 100644 --- a/tests/units/test_environment.py +++ b/tests/units/test_environment.py @@ -744,8 +744,40 @@ def test_timedelta_env_var_reads_a_duration(monkeypatch: pytest.MonkeyPatch) -> assert env_var_instance.getenv() == timedelta(seconds=90) -def test_timedelta_env_var_falls_back_to_its_default() -> None: - """An unset duration keeps the default the app declared.""" +def test_timedelta_env_var_falls_back_to_its_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An unset duration keeps the default the app declared. + + Args: + monkeypatch: pytest monkeypatch fixture. + """ + monkeypatch.delenv("TEST_TIMEOUT_UNSET", raising=False) env_var_instance = EnvVar("TEST_TIMEOUT_UNSET", timedelta(minutes=3), timedelta) assert env_var_instance.get() == timedelta(minutes=3) + + +def test_timedelta_env_var_round_trips_through_set( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``set`` has to write a form the interpreter reads back. + + ``str(timedelta)`` renders ``0:01:30``, and ``-1 day, 23:58:30`` below zero, + neither of which is valid input. + + Args: + monkeypatch: pytest monkeypatch fixture. + """ + monkeypatch.delenv("TEST_TIMEOUT_ROUNDTRIP", raising=False) + env_var_instance = EnvVar("TEST_TIMEOUT_ROUNDTRIP", timedelta(0), timedelta) + + for value in ( + timedelta(minutes=1, seconds=30), + timedelta(days=1, seconds=30), + timedelta(seconds=-90), + ): + # `EnvVar` binds its type var to the class object, so a value argument + # never matches - the same quirk the other `set` tests here work around. + env_var_instance.set(value) # type: ignore[arg-type] + assert env_var_instance.get() == value From e78eaaff110de1d3d38a2858ec019526bd662f37 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Mon, 14 Sep 2026 08:40:23 +0200 Subject: [PATCH 3/6] serialize durations exactly and stop the round-trip test leaking its env var --- .../news/+envvar-timedelta.feature.md | 2 +- .../reflex-base/src/reflex_base/environment.py | 16 ++++++++++++---- tests/units/test_environment.py | 9 +++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/reflex-base/news/+envvar-timedelta.feature.md b/packages/reflex-base/news/+envvar-timedelta.feature.md index 56854458d2c..e89f2f65f30 100644 --- a/packages/reflex-base/news/+envvar-timedelta.feature.md +++ b/packages/reflex-base/news/+envvar-timedelta.feature.md @@ -1 +1 @@ -`EnvVar` now supports `timedelta`. A bare number is read as seconds, and `ms`, `s`, `m`, `h` and `d` suffixes are understood, so `TIMEOUT=30`, `TIMEOUT=30s` and `TIMEOUT=5m` are all valid. +`EnvVar` now supports `timedelta`. A bare number is read as seconds, and `us`, `ms`, `s`, `m`, `h` and `d` suffixes are understood, so `TIMEOUT=30`, `TIMEOUT=30s` and `TIMEOUT=5m` are all valid. diff --git a/packages/reflex-base/src/reflex_base/environment.py b/packages/reflex-base/src/reflex_base/environment.py index 7c3ce45c55b..21c5533e0c0 100644 --- a/packages/reflex-base/src/reflex_base/environment.py +++ b/packages/reflex-base/src/reflex_base/environment.py @@ -119,6 +119,7 @@ def interpret_float_env(value: str, field_name: str) -> float: _TIMEDELTA_UNITS: dict[str, str] = { + "us": "microseconds", "ms": "milliseconds", "s": "seconds", "m": "minutes", @@ -134,8 +135,8 @@ def interpret_timedelta_env(value: str, field_name: str) -> timedelta: A bare number is read as seconds, so an existing integer setting keeps working when its type becomes a duration. A unit suffix overrides that: - ``ms``, ``s``, ``m``, ``h`` and ``d`` are understood, making ``30``, ``30s``, - ``500ms`` and ``5m`` all valid. + ``us``, ``ms``, ``s``, ``m``, ``h`` and ``d`` are understood, making ``30``, + ``30s``, ``500ms`` and ``5m`` all valid. Args: value: The environment variable value. @@ -156,8 +157,11 @@ def interpret_timedelta_env(value: str, field_name: str) -> timedelta: f"number of seconds, optionally suffixed with one of {units}." ) raise EnvironmentVarValueError(msg) + amount = match.group(1) try: - return timedelta(**{keyword: float(match.group(1))}) + # Only a written fraction goes through float: an integer of microseconds + # is exact at any size, where float silently rounds the large ones. + return timedelta(**{keyword: float(amount) if "." in amount else int(amount)}) except (OverflowError, ValueError) as e: # A value can be well-formed and still be more than a timedelta holds. # OverflowError is not a ValueError, so letting it out would escape the @@ -460,7 +464,11 @@ def _serialize_env_value(value: Any) -> str: The rendered value. """ if isinstance(value, timedelta): - return str(value.total_seconds()) + # Not `total_seconds()`: it is a float, which drops microseconds on large + # durations and renders small ones in scientific notation. + microseconds = value // timedelta(microseconds=1) + seconds, fraction = divmod(microseconds, 1_000_000) + return f"{seconds}s" if fraction == 0 else f"{microseconds}us" return str(value) diff --git a/tests/units/test_environment.py b/tests/units/test_environment.py index 52e5d0cef01..0a1fb2b2b7d 100644 --- a/tests/units/test_environment.py +++ b/tests/units/test_environment.py @@ -677,6 +677,8 @@ def cleanup_env_vars(): "BOOLEAN", "LIST", "__INTERNAL_VAR", + # `EnvVar.set` writes `os.environ` directly, so monkeypatch never sees it + "TEST_TIMEOUT_ROUNDTRIP", ] yield @@ -776,6 +778,13 @@ def test_timedelta_env_var_round_trips_through_set( timedelta(minutes=1, seconds=30), timedelta(days=1, seconds=30), timedelta(seconds=-90), + # sub-second and boundary values are where a float round trip loses the + # microseconds or rounds past what a timedelta holds + timedelta(microseconds=1), + timedelta(seconds=90, microseconds=500000), + timedelta(days=999999998, microseconds=1), + timedelta.max, + timedelta.min, ): # `EnvVar` binds its type var to the class object, so a value argument # never matches - the same quirk the other `set` tests here work around. From e8b2c3299ecd67d428163e5d4033561491b5ad31 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Mon, 14 Sep 2026 09:33:45 +0200 Subject: [PATCH 4/6] describe the duration format directly and test the microsecond unit --- packages/reflex-base/news/+envvar-timedelta.feature.md | 2 +- packages/reflex-base/src/reflex_base/environment.py | 7 +++---- tests/units/test_environment.py | 3 ++- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/reflex-base/news/+envvar-timedelta.feature.md b/packages/reflex-base/news/+envvar-timedelta.feature.md index e89f2f65f30..fc768781f08 100644 --- a/packages/reflex-base/news/+envvar-timedelta.feature.md +++ b/packages/reflex-base/news/+envvar-timedelta.feature.md @@ -1 +1 @@ -`EnvVar` now supports `timedelta`. A bare number is read as seconds, and `us`, `ms`, `s`, `m`, `h` and `d` suffixes are understood, so `TIMEOUT=30`, `TIMEOUT=30s` and `TIMEOUT=5m` are all valid. +`EnvVar` reads `timedelta` values as a number of seconds, or with a `us`, `ms`, `s`, `m`, `h` or `d` suffix, so `TIMEOUT=30`, `TIMEOUT=30s` and `TIMEOUT=5m` are all valid. diff --git a/packages/reflex-base/src/reflex_base/environment.py b/packages/reflex-base/src/reflex_base/environment.py index 21c5533e0c0..85f0bd14399 100644 --- a/packages/reflex-base/src/reflex_base/environment.py +++ b/packages/reflex-base/src/reflex_base/environment.py @@ -133,10 +133,9 @@ def interpret_float_env(value: str, field_name: str) -> float: def interpret_timedelta_env(value: str, field_name: str) -> timedelta: """Interpret a duration environment variable value. - A bare number is read as seconds, so an existing integer setting keeps - working when its type becomes a duration. A unit suffix overrides that: - ``us``, ``ms``, ``s``, ``m``, ``h`` and ``d`` are understood, making ``30``, - ``30s``, ``500ms`` and ``5m`` all valid. + A bare number is read as seconds. A unit suffix overrides that: ``us``, + ``ms``, ``s``, ``m``, ``h`` and ``d`` are understood, making ``30``, ``30s``, + ``500ms`` and ``5m`` all valid. Args: value: The environment variable value. diff --git a/tests/units/test_environment.py b/tests/units/test_environment.py index 0a1fb2b2b7d..f6e85efca7a 100644 --- a/tests/units/test_environment.py +++ b/tests/units/test_environment.py @@ -690,7 +690,7 @@ def cleanup_env_vars(): def test_interpret_timedelta_env_defaults_to_seconds() -> None: - """A bare number keeps an existing integer setting working unchanged.""" + """A bare number is read as seconds.""" assert interpret_timedelta_env("30", "TEST_FIELD") == timedelta(seconds=30) assert interpret_timedelta_env("1.5", "TEST_FIELD") == timedelta(seconds=1.5) assert interpret_timedelta_env("0", "TEST_FIELD") == timedelta(0) @@ -698,6 +698,7 @@ def test_interpret_timedelta_env_defaults_to_seconds() -> None: def test_interpret_timedelta_env_units() -> None: """A suffix overrides the default unit.""" + assert interpret_timedelta_env("1us", "TEST_FIELD") == timedelta(microseconds=1) assert interpret_timedelta_env("500ms", "TEST_FIELD") == timedelta(milliseconds=500) assert interpret_timedelta_env("30s", "TEST_FIELD") == timedelta(seconds=30) assert interpret_timedelta_env("5m", "TEST_FIELD") == timedelta(minutes=5) From 60e96979a887f7645c1008dac67abf52d57dfc63 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Mon, 14 Sep 2026 09:39:14 +0200 Subject: [PATCH 5/6] drop the microseconds-per-second literal from the duration serializer --- packages/reflex-base/src/reflex_base/environment.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/environment.py b/packages/reflex-base/src/reflex_base/environment.py index 85f0bd14399..c937d0e9b34 100644 --- a/packages/reflex-base/src/reflex_base/environment.py +++ b/packages/reflex-base/src/reflex_base/environment.py @@ -465,9 +465,10 @@ def _serialize_env_value(value: Any) -> str: if isinstance(value, timedelta): # Not `total_seconds()`: it is a float, which drops microseconds on large # durations and renders small ones in scientific notation. - microseconds = value // timedelta(microseconds=1) - seconds, fraction = divmod(microseconds, 1_000_000) - return f"{seconds}s" if fraction == 0 else f"{microseconds}us" + seconds, fraction = divmod(value, timedelta(seconds=1)) + if not fraction: + return f"{seconds}s" + return f"{value // timedelta(microseconds=1)}us" return str(value) From f255933845b3be0f831d3160eba7432ff4216a5a Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Mon, 14 Sep 2026 09:41:41 -0700 Subject: [PATCH 6/6] Update packages/reflex-base/news/+envvar-timedelta.feature.md --- packages/reflex-base/news/+envvar-timedelta.feature.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/reflex-base/news/+envvar-timedelta.feature.md b/packages/reflex-base/news/+envvar-timedelta.feature.md index fc768781f08..6fe660928ea 100644 --- a/packages/reflex-base/news/+envvar-timedelta.feature.md +++ b/packages/reflex-base/news/+envvar-timedelta.feature.md @@ -1 +1 @@ -`EnvVar` reads `timedelta` values as a number of seconds, or with a `us`, `ms`, `s`, `m`, `h` or `d` suffix, so `TIMEOUT=30`, `TIMEOUT=30s` and `TIMEOUT=5m` are all valid. +`EnvVar` reads `timedelta` values as a number of seconds, or with a `us`, `ms`, `s`, `m`, `h` or `d` suffix.