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

Commit c1dfdf8

Browse files
Merge pull request #39 from UiPath/feat/flags
feat: add feature flags registry
2 parents f97820c + 6af23fe commit c1dfdf8

6 files changed

Lines changed: 298 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-core"
3-
version = "0.4.1"
3+
version = "0.4.2"
44
description = "UiPath Core abstractions"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""UiPath Feature Flags.
2+
3+
Local-only feature flag registry for the UiPath SDK.
4+
"""
5+
6+
from .feature_flags import FeatureFlags, FeatureFlagsManager
7+
8+
__all__ = [
9+
"FeatureFlags",
10+
"FeatureFlagsManager",
11+
]
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Feature flags configuration for UiPath SDK.
2+
3+
A simple, local-only feature flag registry. Flags can be set
4+
programmatically via :meth:`FeatureFlagsManager.configure_flags` or
5+
supplied via environment variables named ``UIPATH_FEATURE_<FlagName>``
6+
when nothing has been configured programmatically.
7+
8+
Programmatic values always take precedence over environment variables.
9+
10+
Example usage::
11+
12+
from uipath.core.feature_flags import FeatureFlags
13+
14+
# Programmatic configuration (e.g. from an upstream layer)
15+
FeatureFlags.configure_flags({"NewSerialization": True, "ModelOverride": "gpt-4"})
16+
17+
# Check a boolean flag
18+
if FeatureFlags.is_flag_enabled("NewSerialization"):
19+
...
20+
21+
# Get an arbitrary value
22+
model = FeatureFlags.get_flag("ModelOverride", default="default-model")
23+
24+
# Local override via environment variable
25+
# $ export UIPATH_FEATURE_NewSerialization=false
26+
"""
27+
28+
import json
29+
import os
30+
from typing import Any
31+
32+
33+
def _parse_env_value(raw: str) -> Any:
34+
"""Convert an environment variable string to a Python value.
35+
36+
Booleans are matched first (case-insensitive). For all other values
37+
JSON decoding is attempted so that dicts, lists and numbers survive
38+
the env-var round-trip. Plain strings that are not valid JSON are
39+
returned as-is.
40+
"""
41+
lower = raw.lower()
42+
if lower == "true":
43+
return True
44+
if lower == "false":
45+
return False
46+
try:
47+
parsed = json.loads(raw)
48+
except (json.JSONDecodeError, ValueError):
49+
return raw
50+
# Only promote structured types (dict/list); scalars stay as strings.
51+
if isinstance(parsed, (dict, list)):
52+
return parsed
53+
return raw
54+
55+
56+
class FeatureFlagsManager:
57+
"""Singleton registry for UiPath feature flags.
58+
59+
Use the module-level :data:`FeatureFlags` instance rather than
60+
instantiating this class directly.
61+
"""
62+
63+
_instance: "FeatureFlagsManager | None" = None
64+
_flags: dict[str, Any]
65+
66+
def __new__(cls) -> "FeatureFlagsManager":
67+
"""Return the singleton instance, creating it on first call."""
68+
if cls._instance is None:
69+
cls._instance = super().__new__(cls)
70+
cls._instance._flags = {}
71+
return cls._instance
72+
73+
def configure_flags(self, flags: dict[str, Any]) -> None:
74+
"""Merge feature flag values into the registry.
75+
76+
Args:
77+
flags: Mapping of flag names to their values. Existing flags
78+
with the same name are overwritten.
79+
"""
80+
self._flags.update(flags)
81+
82+
def reset_flags(self) -> None:
83+
"""Clear all configured flags."""
84+
self._flags.clear()
85+
86+
def get_flag(self, name: str, *, default: Any = None) -> Any:
87+
"""Return a flag value.
88+
89+
Resolution order:
90+
91+
1. Value set via :meth:`configure_flags` (highest priority)
92+
2. ``UIPATH_FEATURE_<name>`` environment variable (fallback when nothing configured)
93+
3. *default*
94+
95+
Args:
96+
name: The feature flag name.
97+
default: Fallback when the flag is not set anywhere.
98+
"""
99+
if name in self._flags:
100+
return self._flags[name]
101+
env_val = os.environ.get(f"UIPATH_FEATURE_{name}")
102+
if env_val is not None:
103+
return _parse_env_value(env_val)
104+
return default
105+
106+
def is_flag_enabled(self, name: str, *, default: bool = False) -> bool:
107+
"""Check whether a boolean flag is enabled.
108+
109+
Uses the same resolution order as :meth:`get_flag`.
110+
111+
Args:
112+
name: The feature flag name.
113+
default: Fallback when the flag is not set anywhere.
114+
"""
115+
return bool(self.get_flag(name, default=default))
116+
117+
118+
FeatureFlags = FeatureFlagsManager()

tests/feature_flags/__init__.py

Whitespace-only changes.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Unit tests for the feature flags registry."""
2+
3+
from typing import TYPE_CHECKING
4+
5+
from uipath.core.feature_flags import FeatureFlags
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 TestConfigureFlags:
59+
"""Tests for configure_flags / reset_flags."""
60+
61+
def setup_method(self) -> None:
62+
FeatureFlags.reset_flags()
63+
64+
def test_configure_sets_flags(self) -> None:
65+
FeatureFlags.configure_flags({"FeatureA": True, "FeatureB": "value"})
66+
assert FeatureFlags.get_flag("FeatureA") is True
67+
assert FeatureFlags.get_flag("FeatureB") == "value"
68+
69+
def test_configure_merges(self) -> None:
70+
FeatureFlags.configure_flags({"FeatureA": True})
71+
FeatureFlags.configure_flags({"FeatureB": False})
72+
assert FeatureFlags.get_flag("FeatureA") is True
73+
assert FeatureFlags.get_flag("FeatureB") is False
74+
75+
def test_configure_overwrites(self) -> None:
76+
FeatureFlags.configure_flags({"FeatureA": True})
77+
FeatureFlags.configure_flags({"FeatureA": False})
78+
assert FeatureFlags.get_flag("FeatureA") is False
79+
80+
def test_reset_clears_all(self) -> None:
81+
FeatureFlags.configure_flags({"FeatureA": True})
82+
FeatureFlags.reset_flags()
83+
assert FeatureFlags.get_flag("FeatureA") is None
84+
85+
86+
class TestGetFlag:
87+
"""Tests for get_flag."""
88+
89+
def setup_method(self) -> None:
90+
FeatureFlags.reset_flags()
91+
92+
def test_returns_default_when_unset(self) -> None:
93+
assert FeatureFlags.get_flag("Missing") is None
94+
95+
def test_returns_custom_default(self) -> None:
96+
assert FeatureFlags.get_flag("Missing", default="fallback") == "fallback"
97+
98+
def test_returns_configured_value(self) -> None:
99+
FeatureFlags.configure_flags({"FeatureA": "hello"})
100+
assert FeatureFlags.get_flag("FeatureA") == "hello"
101+
102+
def test_configured_value_takes_precedence_over_env_var(
103+
self, monkeypatch: "MonkeyPatch"
104+
) -> None:
105+
FeatureFlags.configure_flags({"FeatureA": True})
106+
monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "false")
107+
assert FeatureFlags.get_flag("FeatureA") is True
108+
109+
def test_env_var_used_when_nothing_configured(
110+
self, monkeypatch: "MonkeyPatch"
111+
) -> None:
112+
monkeypatch.setenv("UIPATH_FEATURE_X", "custom")
113+
assert FeatureFlags.get_flag("X", default="other") == "custom"
114+
115+
def test_env_var_string_value(self, monkeypatch: "MonkeyPatch") -> None:
116+
monkeypatch.setenv("UIPATH_FEATURE_Model", "gpt-4-turbo")
117+
assert FeatureFlags.get_flag("Model") == "gpt-4-turbo"
118+
119+
def test_env_var_json_dict(self, monkeypatch: "MonkeyPatch") -> None:
120+
monkeypatch.setenv("UIPATH_FEATURE_Models", '{"gpt-4": true, "claude": false}')
121+
assert FeatureFlags.get_flag("Models") == {"gpt-4": True, "claude": False}
122+
123+
def test_env_var_json_list(self, monkeypatch: "MonkeyPatch") -> None:
124+
monkeypatch.setenv("UIPATH_FEATURE_AllowedModels", '["gpt-4", "claude"]')
125+
assert FeatureFlags.get_flag("AllowedModels") == ["gpt-4", "claude"]
126+
127+
128+
class TestIsFlagEnabled:
129+
"""Tests for is_flag_enabled."""
130+
131+
def setup_method(self) -> None:
132+
FeatureFlags.reset_flags()
133+
134+
def test_enabled_flag(self) -> None:
135+
FeatureFlags.configure_flags({"FeatureA": True})
136+
assert FeatureFlags.is_flag_enabled("FeatureA") is True
137+
138+
def test_disabled_flag(self) -> None:
139+
FeatureFlags.configure_flags({"FeatureA": False})
140+
assert FeatureFlags.is_flag_enabled("FeatureA") is False
141+
142+
def test_missing_flag_defaults_false(self) -> None:
143+
assert FeatureFlags.is_flag_enabled("Missing") is False
144+
145+
def test_missing_flag_custom_default(self) -> None:
146+
assert FeatureFlags.is_flag_enabled("Missing", default=True) is True
147+
148+
def test_truthy_string_is_enabled(self) -> None:
149+
FeatureFlags.configure_flags({"FeatureA": "some-value"})
150+
assert FeatureFlags.is_flag_enabled("FeatureA") is True
151+
152+
def test_none_is_disabled(self) -> None:
153+
FeatureFlags.configure_flags({"FeatureA": None})
154+
assert FeatureFlags.is_flag_enabled("FeatureA") is False
155+
156+
def test_configured_value_takes_precedence_over_env_var(
157+
self, monkeypatch: "MonkeyPatch"
158+
) -> None:
159+
FeatureFlags.configure_flags({"FeatureA": True})
160+
monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "false")
161+
assert FeatureFlags.is_flag_enabled("FeatureA") is True
162+
163+
def test_env_var_used_when_nothing_configured(
164+
self, monkeypatch: "MonkeyPatch"
165+
) -> None:
166+
monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "true")
167+
assert FeatureFlags.is_flag_enabled("FeatureA") is True

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)