Skip to content
This repository was archived by the owner on Mar 4, 2026. It is now read-only.

Commit e90cb1a

Browse files
feat: add feature flags registry
Introduce a simple feature flags config module that allows flags to be set programmatically via configure() and overridden locally with UIPATH_FEATURE_<name> environment variables. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 42d8f3e commit e90cb1a

4 files changed

Lines changed: 275 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""UiPath Feature Flags.
2+
3+
Local-only feature flag registry for the UiPath SDK.
4+
"""
5+
6+
from .feature_flags import configure, get, is_enabled, reset
7+
8+
__all__ = [
9+
"configure",
10+
"get",
11+
"is_enabled",
12+
"reset",
13+
]
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Feature flags configuration for UiPath SDK.
2+
3+
A simple, local-only feature flag registry. Flags can be set
4+
programmatically via :func:`configure` or overridden per-flag with
5+
environment variables named ``UIPATH_FEATURE_<FlagName>``.
6+
7+
Environment variables always take precedence over programmatic values.
8+
9+
Example usage::
10+
11+
from uipath.core.feature_flags import configure, is_enabled, get
12+
13+
# Programmatic configuration (e.g. from an upstream layer)
14+
configure({"NewSerialization": True, "ModelOverride": "gpt-4"})
15+
16+
# Check a boolean flag
17+
if is_enabled("NewSerialization"):
18+
...
19+
20+
# Get an arbitrary value
21+
model = get("ModelOverride", default="default-model")
22+
23+
# Local override via environment variable
24+
# $ export UIPATH_FEATURE_NewSerialization=false
25+
"""
26+
27+
import json
28+
import os
29+
from typing import Any
30+
31+
_flags: dict[str, Any] = {}
32+
33+
34+
def configure(flags: dict[str, Any]) -> None:
35+
"""Merge feature flag values into the registry.
36+
37+
Args:
38+
flags: Mapping of flag names to their values. Existing flags
39+
with the same name are overwritten.
40+
"""
41+
_flags.update(flags)
42+
43+
44+
def reset() -> None:
45+
"""Clear all configured flags. Mainly useful in tests."""
46+
_flags.clear()
47+
48+
49+
def _parse_env_value(raw: str) -> Any:
50+
"""Convert an environment variable string to a Python value.
51+
52+
Booleans are matched first (case-insensitive). For all other values
53+
JSON decoding is attempted so that dicts, lists and numbers survive
54+
the env-var round-trip. Plain strings that are not valid JSON are
55+
returned as-is.
56+
"""
57+
lower = raw.lower()
58+
if lower == "true":
59+
return True
60+
if lower == "false":
61+
return False
62+
try:
63+
parsed = json.loads(raw)
64+
except (json.JSONDecodeError, ValueError):
65+
return raw
66+
# Only promote structured types (dict/list); scalars stay as strings.
67+
if isinstance(parsed, (dict, list)):
68+
return parsed
69+
return raw
70+
71+
72+
def get(name: str, *, default: Any = None) -> Any:
73+
"""Return a flag value.
74+
75+
Resolution order:
76+
77+
1. ``UIPATH_FEATURE_<name>`` environment variable (highest priority)
78+
2. Value set via :func:`configure`
79+
3. *default*
80+
81+
Args:
82+
name: The feature flag name.
83+
default: Fallback when the flag is not set anywhere.
84+
"""
85+
env_val = os.environ.get(f"UIPATH_FEATURE_{name}")
86+
if env_val is not None:
87+
return _parse_env_value(env_val)
88+
return _flags.get(name, default)
89+
90+
91+
def is_enabled(name: str, *, default: bool = False) -> bool:
92+
"""Check whether a boolean flag is enabled.
93+
94+
Uses the same resolution order as :func:`get`.
95+
96+
Args:
97+
name: The feature flag name.
98+
default: Fallback when the flag is not set anywhere.
99+
"""
100+
return bool(get(name, default=default))

tests/feature_flags/__init__.py

Whitespace-only changes.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Unit tests for the feature flags registry."""
2+
3+
from typing import TYPE_CHECKING
4+
5+
from uipath.core.feature_flags import configure, get, is_enabled, reset
6+
from uipath.core.feature_flags.feature_flags import _parse_env_value
7+
8+
if TYPE_CHECKING:
9+
from _pytest.monkeypatch import MonkeyPatch
10+
11+
12+
class TestParseEnvValue:
13+
"""Tests for _parse_env_value."""
14+
15+
def test_true_string(self) -> None:
16+
assert _parse_env_value("true") is True
17+
18+
def test_true_uppercase(self) -> None:
19+
assert _parse_env_value("TRUE") is True
20+
21+
def test_true_mixed_case(self) -> None:
22+
assert _parse_env_value("True") is True
23+
24+
def test_false_string(self) -> None:
25+
assert _parse_env_value("false") is False
26+
27+
def test_false_uppercase(self) -> None:
28+
assert _parse_env_value("FALSE") is False
29+
30+
def test_string_passthrough(self) -> None:
31+
assert _parse_env_value("gpt-4") == "gpt-4"
32+
33+
def test_empty_string(self) -> None:
34+
assert _parse_env_value("") == ""
35+
36+
def test_numeric_string(self) -> None:
37+
assert _parse_env_value("42") == "42"
38+
39+
def test_json_dict(self) -> None:
40+
result = _parse_env_value('{"model": "gpt-4", "enabled": true}')
41+
assert result == {"model": "gpt-4", "enabled": True}
42+
43+
def test_json_list(self) -> None:
44+
result = _parse_env_value('["a", "b", "c"]')
45+
assert result == ["a", "b", "c"]
46+
47+
def test_json_nested_dict(self) -> None:
48+
result = _parse_env_value('{"outer": {"inner": 1}}')
49+
assert result == {"outer": {"inner": 1}}
50+
51+
def test_float_string_stays_string(self) -> None:
52+
assert _parse_env_value("3.14") == "3.14"
53+
54+
def test_plain_string_not_json(self) -> None:
55+
assert _parse_env_value("gpt-4") == "gpt-4"
56+
57+
58+
class TestConfigure:
59+
"""Tests for configure / reset."""
60+
61+
def setup_method(self) -> None:
62+
reset()
63+
64+
def test_configure_sets_flags(self) -> None:
65+
configure({"FeatureA": True, "FeatureB": "value"})
66+
assert get("FeatureA") is True
67+
assert get("FeatureB") == "value"
68+
69+
def test_configure_merges(self) -> None:
70+
configure({"FeatureA": True})
71+
configure({"FeatureB": False})
72+
assert get("FeatureA") is True
73+
assert get("FeatureB") is False
74+
75+
def test_configure_overwrites(self) -> None:
76+
configure({"FeatureA": True})
77+
configure({"FeatureA": False})
78+
assert get("FeatureA") is False
79+
80+
def test_reset_clears_all(self) -> None:
81+
configure({"FeatureA": True})
82+
reset()
83+
assert get("FeatureA") is None
84+
85+
86+
class TestGet:
87+
"""Tests for get."""
88+
89+
def setup_method(self) -> None:
90+
reset()
91+
92+
def test_returns_default_when_unset(self) -> None:
93+
assert get("Missing") is None
94+
95+
def test_returns_custom_default(self) -> None:
96+
assert get("Missing", default="fallback") == "fallback"
97+
98+
def test_returns_configured_value(self) -> None:
99+
configure({"FeatureA": "hello"})
100+
assert get("FeatureA") == "hello"
101+
102+
def test_env_var_overrides_configured(self, monkeypatch: "MonkeyPatch") -> None:
103+
configure({"FeatureA": True})
104+
monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "false")
105+
assert get("FeatureA") is False
106+
107+
def test_env_var_overrides_default(self, monkeypatch: "MonkeyPatch") -> None:
108+
monkeypatch.setenv("UIPATH_FEATURE_X", "custom")
109+
assert get("X", default="other") == "custom"
110+
111+
def test_env_var_string_value(self, monkeypatch: "MonkeyPatch") -> None:
112+
monkeypatch.setenv("UIPATH_FEATURE_Model", "gpt-4-turbo")
113+
assert get("Model") == "gpt-4-turbo"
114+
115+
def test_env_var_json_dict(self, monkeypatch: "MonkeyPatch") -> None:
116+
monkeypatch.setenv(
117+
"UIPATH_FEATURE_Models", '{"gpt-4": true, "claude": false}'
118+
)
119+
assert get("Models") == {"gpt-4": True, "claude": False}
120+
121+
def test_env_var_json_list(self, monkeypatch: "MonkeyPatch") -> None:
122+
monkeypatch.setenv("UIPATH_FEATURE_AllowedModels", '["gpt-4", "claude"]')
123+
assert get("AllowedModels") == ["gpt-4", "claude"]
124+
125+
126+
class TestIsEnabled:
127+
"""Tests for is_enabled."""
128+
129+
def setup_method(self) -> None:
130+
reset()
131+
132+
def test_enabled_flag(self) -> None:
133+
configure({"FeatureA": True})
134+
assert is_enabled("FeatureA") is True
135+
136+
def test_disabled_flag(self) -> None:
137+
configure({"FeatureA": False})
138+
assert is_enabled("FeatureA") is False
139+
140+
def test_missing_flag_defaults_false(self) -> None:
141+
assert is_enabled("Missing") is False
142+
143+
def test_missing_flag_custom_default(self) -> None:
144+
assert is_enabled("Missing", default=True) is True
145+
146+
def test_truthy_string_is_enabled(self) -> None:
147+
configure({"FeatureA": "some-value"})
148+
assert is_enabled("FeatureA") is True
149+
150+
def test_none_is_disabled(self) -> None:
151+
configure({"FeatureA": None})
152+
assert is_enabled("FeatureA") is False
153+
154+
def test_env_override_disables(self, monkeypatch: "MonkeyPatch") -> None:
155+
configure({"FeatureA": True})
156+
monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "false")
157+
assert is_enabled("FeatureA") is False
158+
159+
def test_env_override_enables(self, monkeypatch: "MonkeyPatch") -> None:
160+
configure({"FeatureA": False})
161+
monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "true")
162+
assert is_enabled("FeatureA") is True

0 commit comments

Comments
 (0)