From feac940d181e74060a9123bb19a13f6f2c046af5 Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Tue, 17 Feb 2026 08:01:09 +0200 Subject: [PATCH 1/3] feat: add feature flags registry --- pyproject.toml | 2 +- src/uipath/core/feature_flags/__init__.py | 13 ++ .../core/feature_flags/feature_flags.py | 100 +++++++++++ tests/feature_flags/__init__.py | 0 tests/feature_flags/test_feature_flags.py | 160 ++++++++++++++++++ uv.lock | 2 +- 6 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 src/uipath/core/feature_flags/__init__.py create mode 100644 src/uipath/core/feature_flags/feature_flags.py create mode 100644 tests/feature_flags/__init__.py create mode 100644 tests/feature_flags/test_feature_flags.py diff --git a/pyproject.toml b/pyproject.toml index aba54eb..20bd97f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.4.1" +version = "0.4.2" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/core/feature_flags/__init__.py b/src/uipath/core/feature_flags/__init__.py new file mode 100644 index 0000000..7d7a64b --- /dev/null +++ b/src/uipath/core/feature_flags/__init__.py @@ -0,0 +1,13 @@ +"""UiPath Feature Flags. + +Local-only feature flag registry for the UiPath SDK. +""" + +from .feature_flags import configure, get, is_enabled, reset + +__all__ = [ + "configure", + "get", + "is_enabled", + "reset", +] diff --git a/src/uipath/core/feature_flags/feature_flags.py b/src/uipath/core/feature_flags/feature_flags.py new file mode 100644 index 0000000..719e945 --- /dev/null +++ b/src/uipath/core/feature_flags/feature_flags.py @@ -0,0 +1,100 @@ +"""Feature flags configuration for UiPath SDK. + +A simple, local-only feature flag registry. Flags can be set +programmatically via :func:`configure` or overridden per-flag with +environment variables named ``UIPATH_FEATURE_``. + +Environment variables always take precedence over programmatic values. + +Example usage:: + + from uipath.core.feature_flags import configure, is_enabled, get + + # Programmatic configuration (e.g. from an upstream layer) + configure({"NewSerialization": True, "ModelOverride": "gpt-4"}) + + # Check a boolean flag + if is_enabled("NewSerialization"): + ... + + # Get an arbitrary value + model = get("ModelOverride", default="default-model") + + # Local override via environment variable + # $ export UIPATH_FEATURE_NewSerialization=false +""" + +import json +import os +from typing import Any + +_flags: dict[str, Any] = {} + + +def configure(flags: dict[str, Any]) -> None: + """Merge feature flag values into the registry. + + Args: + flags: Mapping of flag names to their values. Existing flags + with the same name are overwritten. + """ + _flags.update(flags) + + +def reset() -> None: + """Clear all configured flags. Mainly useful in tests.""" + _flags.clear() + + +def _parse_env_value(raw: str) -> Any: + """Convert an environment variable string to a Python value. + + Booleans are matched first (case-insensitive). For all other values + JSON decoding is attempted so that dicts, lists and numbers survive + the env-var round-trip. Plain strings that are not valid JSON are + returned as-is. + """ + lower = raw.lower() + if lower == "true": + return True + if lower == "false": + return False + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return raw + # Only promote structured types (dict/list); scalars stay as strings. + if isinstance(parsed, (dict, list)): + return parsed + return raw + + +def get(name: str, *, default: Any = None) -> Any: + """Return a flag value. + + Resolution order: + + 1. ``UIPATH_FEATURE_`` environment variable (highest priority) + 2. Value set via :func:`configure` + 3. *default* + + Args: + name: The feature flag name. + default: Fallback when the flag is not set anywhere. + """ + env_val = os.environ.get(f"UIPATH_FEATURE_{name}") + if env_val is not None: + return _parse_env_value(env_val) + return _flags.get(name, default) + + +def is_enabled(name: str, *, default: bool = False) -> bool: + """Check whether a boolean flag is enabled. + + Uses the same resolution order as :func:`get`. + + Args: + name: The feature flag name. + default: Fallback when the flag is not set anywhere. + """ + return bool(get(name, default=default)) diff --git a/tests/feature_flags/__init__.py b/tests/feature_flags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/feature_flags/test_feature_flags.py b/tests/feature_flags/test_feature_flags.py new file mode 100644 index 0000000..e726bf8 --- /dev/null +++ b/tests/feature_flags/test_feature_flags.py @@ -0,0 +1,160 @@ +"""Unit tests for the feature flags registry.""" + +from typing import TYPE_CHECKING + +from uipath.core.feature_flags import configure, get, is_enabled, reset +from uipath.core.feature_flags.feature_flags import _parse_env_value + +if TYPE_CHECKING: + from _pytest.monkeypatch import MonkeyPatch + + +class TestParseEnvValue: + """Tests for _parse_env_value.""" + + def test_true_string(self) -> None: + assert _parse_env_value("true") is True + + def test_true_uppercase(self) -> None: + assert _parse_env_value("TRUE") is True + + def test_true_mixed_case(self) -> None: + assert _parse_env_value("True") is True + + def test_false_string(self) -> None: + assert _parse_env_value("false") is False + + def test_false_uppercase(self) -> None: + assert _parse_env_value("FALSE") is False + + def test_string_passthrough(self) -> None: + assert _parse_env_value("gpt-4") == "gpt-4" + + def test_empty_string(self) -> None: + assert _parse_env_value("") == "" + + def test_numeric_string(self) -> None: + assert _parse_env_value("42") == "42" + + def test_json_dict(self) -> None: + result = _parse_env_value('{"model": "gpt-4", "enabled": true}') + assert result == {"model": "gpt-4", "enabled": True} + + def test_json_list(self) -> None: + result = _parse_env_value('["a", "b", "c"]') + assert result == ["a", "b", "c"] + + def test_json_nested_dict(self) -> None: + result = _parse_env_value('{"outer": {"inner": 1}}') + assert result == {"outer": {"inner": 1}} + + def test_float_string_stays_string(self) -> None: + assert _parse_env_value("3.14") == "3.14" + + def test_plain_string_not_json(self) -> None: + assert _parse_env_value("gpt-4") == "gpt-4" + + +class TestConfigure: + """Tests for configure / reset.""" + + def setup_method(self) -> None: + reset() + + def test_configure_sets_flags(self) -> None: + configure({"FeatureA": True, "FeatureB": "value"}) + assert get("FeatureA") is True + assert get("FeatureB") == "value" + + def test_configure_merges(self) -> None: + configure({"FeatureA": True}) + configure({"FeatureB": False}) + assert get("FeatureA") is True + assert get("FeatureB") is False + + def test_configure_overwrites(self) -> None: + configure({"FeatureA": True}) + configure({"FeatureA": False}) + assert get("FeatureA") is False + + def test_reset_clears_all(self) -> None: + configure({"FeatureA": True}) + reset() + assert get("FeatureA") is None + + +class TestGet: + """Tests for get.""" + + def setup_method(self) -> None: + reset() + + def test_returns_default_when_unset(self) -> None: + assert get("Missing") is None + + def test_returns_custom_default(self) -> None: + assert get("Missing", default="fallback") == "fallback" + + def test_returns_configured_value(self) -> None: + configure({"FeatureA": "hello"}) + assert get("FeatureA") == "hello" + + def test_env_var_overrides_configured(self, monkeypatch: "MonkeyPatch") -> None: + configure({"FeatureA": True}) + monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "false") + assert get("FeatureA") is False + + def test_env_var_overrides_default(self, monkeypatch: "MonkeyPatch") -> None: + monkeypatch.setenv("UIPATH_FEATURE_X", "custom") + assert get("X", default="other") == "custom" + + def test_env_var_string_value(self, monkeypatch: "MonkeyPatch") -> None: + monkeypatch.setenv("UIPATH_FEATURE_Model", "gpt-4-turbo") + assert get("Model") == "gpt-4-turbo" + + def test_env_var_json_dict(self, monkeypatch: "MonkeyPatch") -> None: + monkeypatch.setenv("UIPATH_FEATURE_Models", '{"gpt-4": true, "claude": false}') + assert get("Models") == {"gpt-4": True, "claude": False} + + def test_env_var_json_list(self, monkeypatch: "MonkeyPatch") -> None: + monkeypatch.setenv("UIPATH_FEATURE_AllowedModels", '["gpt-4", "claude"]') + assert get("AllowedModels") == ["gpt-4", "claude"] + + +class TestIsEnabled: + """Tests for is_enabled.""" + + def setup_method(self) -> None: + reset() + + def test_enabled_flag(self) -> None: + configure({"FeatureA": True}) + assert is_enabled("FeatureA") is True + + def test_disabled_flag(self) -> None: + configure({"FeatureA": False}) + assert is_enabled("FeatureA") is False + + def test_missing_flag_defaults_false(self) -> None: + assert is_enabled("Missing") is False + + def test_missing_flag_custom_default(self) -> None: + assert is_enabled("Missing", default=True) is True + + def test_truthy_string_is_enabled(self) -> None: + configure({"FeatureA": "some-value"}) + assert is_enabled("FeatureA") is True + + def test_none_is_disabled(self) -> None: + configure({"FeatureA": None}) + assert is_enabled("FeatureA") is False + + def test_env_override_disables(self, monkeypatch: "MonkeyPatch") -> None: + configure({"FeatureA": True}) + monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "false") + assert is_enabled("FeatureA") is False + + def test_env_override_enables(self, monkeypatch: "MonkeyPatch") -> None: + configure({"FeatureA": False}) + monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "true") + assert is_enabled("FeatureA") is True diff --git a/uv.lock b/uv.lock index 65aea69..c88bf4b 100644 --- a/uv.lock +++ b/uv.lock @@ -1007,7 +1007,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.4.1" +version = "0.4.2" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, From edd138b8e63de2bb3bd605a6f67f105c7640483f Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Wed, 18 Feb 2026 12:15:40 +0200 Subject: [PATCH 2/3] feat: address PR comments --- src/uipath/core/feature_flags/__init__.py | 8 +- .../core/feature_flags/feature_flags.py | 109 ++++++++++-------- tests/feature_flags/test_feature_flags.py | 109 ++++++++++-------- 3 files changed, 124 insertions(+), 102 deletions(-) diff --git a/src/uipath/core/feature_flags/__init__.py b/src/uipath/core/feature_flags/__init__.py index 7d7a64b..c112d4f 100644 --- a/src/uipath/core/feature_flags/__init__.py +++ b/src/uipath/core/feature_flags/__init__.py @@ -3,11 +3,9 @@ Local-only feature flag registry for the UiPath SDK. """ -from .feature_flags import configure, get, is_enabled, reset +from .feature_flags import FeatureFlags, FeatureFlagsManager __all__ = [ - "configure", - "get", - "is_enabled", - "reset", + "FeatureFlags", + "FeatureFlagsManager", ] diff --git a/src/uipath/core/feature_flags/feature_flags.py b/src/uipath/core/feature_flags/feature_flags.py index 719e945..18d245c 100644 --- a/src/uipath/core/feature_flags/feature_flags.py +++ b/src/uipath/core/feature_flags/feature_flags.py @@ -1,24 +1,25 @@ """Feature flags configuration for UiPath SDK. A simple, local-only feature flag registry. Flags can be set -programmatically via :func:`configure` or overridden per-flag with -environment variables named ``UIPATH_FEATURE_``. +programmatically via :meth:`FeatureFlagsManager.configure_flags` or +supplied via environment variables named ``UIPATH_FEATURE_`` +when nothing has been configured programmatically. -Environment variables always take precedence over programmatic values. +Programmatic values always take precedence over environment variables. Example usage:: - from uipath.core.feature_flags import configure, is_enabled, get + from uipath.core.feature_flags import FeatureFlags # Programmatic configuration (e.g. from an upstream layer) - configure({"NewSerialization": True, "ModelOverride": "gpt-4"}) + FeatureFlags.configure_flags({"NewSerialization": True, "ModelOverride": "gpt-4"}) # Check a boolean flag - if is_enabled("NewSerialization"): + if FeatureFlags.is_flag_enabled("NewSerialization"): ... # Get an arbitrary value - model = get("ModelOverride", default="default-model") + model = FeatureFlags.get_flag("ModelOverride", default="default-model") # Local override via environment variable # $ export UIPATH_FEATURE_NewSerialization=false @@ -28,23 +29,6 @@ import os from typing import Any -_flags: dict[str, Any] = {} - - -def configure(flags: dict[str, Any]) -> None: - """Merge feature flag values into the registry. - - Args: - flags: Mapping of flag names to their values. Existing flags - with the same name are overwritten. - """ - _flags.update(flags) - - -def reset() -> None: - """Clear all configured flags. Mainly useful in tests.""" - _flags.clear() - def _parse_env_value(raw: str) -> Any: """Convert an environment variable string to a Python value. @@ -69,32 +53,65 @@ def _parse_env_value(raw: str) -> Any: return raw -def get(name: str, *, default: Any = None) -> Any: - """Return a flag value. +class FeatureFlagsManager: + """Singleton registry for UiPath feature flags. - Resolution order: + Use the module-level :data:`FeatureFlags` instance rather than + instantiating this class directly. + """ - 1. ``UIPATH_FEATURE_`` environment variable (highest priority) - 2. Value set via :func:`configure` - 3. *default* + _instance: "FeatureFlagsManager | None" = None + _flags: dict[str, Any] - Args: - name: The feature flag name. - default: Fallback when the flag is not set anywhere. - """ - env_val = os.environ.get(f"UIPATH_FEATURE_{name}") - if env_val is not None: - return _parse_env_value(env_val) - return _flags.get(name, default) + def __new__(cls) -> "FeatureFlagsManager": + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._flags = {} + return cls._instance + def configure_flags(self, flags: dict[str, Any]) -> None: + """Merge feature flag values into the registry. -def is_enabled(name: str, *, default: bool = False) -> bool: - """Check whether a boolean flag is enabled. + Args: + flags: Mapping of flag names to their values. Existing flags + with the same name are overwritten. + """ + self._flags.update(flags) - Uses the same resolution order as :func:`get`. + def reset_flags(self) -> None: + """Clear all configured flags.""" + self._flags.clear() - Args: - name: The feature flag name. - default: Fallback when the flag is not set anywhere. - """ - return bool(get(name, default=default)) + def get_flag(self, name: str, *, default: Any = None) -> Any: + """Return a flag value. + + Resolution order: + + 1. Value set via :meth:`configure_flags` (highest priority) + 2. ``UIPATH_FEATURE_`` environment variable (fallback when nothing configured) + 3. *default* + + Args: + name: The feature flag name. + default: Fallback when the flag is not set anywhere. + """ + if name in self._flags: + return self._flags[name] + env_val = os.environ.get(f"UIPATH_FEATURE_{name}") + if env_val is not None: + return _parse_env_value(env_val) + return default + + def is_flag_enabled(self, name: str, *, default: bool = False) -> bool: + """Check whether a boolean flag is enabled. + + Uses the same resolution order as :meth:`get_flag`. + + Args: + name: The feature flag name. + default: Fallback when the flag is not set anywhere. + """ + return bool(self.get_flag(name, default=default)) + + +FeatureFlags = FeatureFlagsManager() diff --git a/tests/feature_flags/test_feature_flags.py b/tests/feature_flags/test_feature_flags.py index e726bf8..8193d74 100644 --- a/tests/feature_flags/test_feature_flags.py +++ b/tests/feature_flags/test_feature_flags.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from uipath.core.feature_flags import configure, get, is_enabled, reset +from uipath.core.feature_flags import FeatureFlags from uipath.core.feature_flags.feature_flags import _parse_env_value if TYPE_CHECKING: @@ -55,106 +55,113 @@ def test_plain_string_not_json(self) -> None: assert _parse_env_value("gpt-4") == "gpt-4" -class TestConfigure: - """Tests for configure / reset.""" +class TestConfigureFlags: + """Tests for configure_flags / reset_flags.""" def setup_method(self) -> None: - reset() + FeatureFlags.reset_flags() def test_configure_sets_flags(self) -> None: - configure({"FeatureA": True, "FeatureB": "value"}) - assert get("FeatureA") is True - assert get("FeatureB") == "value" + FeatureFlags.configure_flags({"FeatureA": True, "FeatureB": "value"}) + assert FeatureFlags.get_flag("FeatureA") is True + assert FeatureFlags.get_flag("FeatureB") == "value" def test_configure_merges(self) -> None: - configure({"FeatureA": True}) - configure({"FeatureB": False}) - assert get("FeatureA") is True - assert get("FeatureB") is False + FeatureFlags.configure_flags({"FeatureA": True}) + FeatureFlags.configure_flags({"FeatureB": False}) + assert FeatureFlags.get_flag("FeatureA") is True + assert FeatureFlags.get_flag("FeatureB") is False def test_configure_overwrites(self) -> None: - configure({"FeatureA": True}) - configure({"FeatureA": False}) - assert get("FeatureA") is False + FeatureFlags.configure_flags({"FeatureA": True}) + FeatureFlags.configure_flags({"FeatureA": False}) + assert FeatureFlags.get_flag("FeatureA") is False def test_reset_clears_all(self) -> None: - configure({"FeatureA": True}) - reset() - assert get("FeatureA") is None + FeatureFlags.configure_flags({"FeatureA": True}) + FeatureFlags.reset_flags() + assert FeatureFlags.get_flag("FeatureA") is None -class TestGet: - """Tests for get.""" +class TestGetFlag: + """Tests for get_flag.""" def setup_method(self) -> None: - reset() + FeatureFlags.reset_flags() def test_returns_default_when_unset(self) -> None: - assert get("Missing") is None + assert FeatureFlags.get_flag("Missing") is None def test_returns_custom_default(self) -> None: - assert get("Missing", default="fallback") == "fallback" + assert FeatureFlags.get_flag("Missing", default="fallback") == "fallback" def test_returns_configured_value(self) -> None: - configure({"FeatureA": "hello"}) - assert get("FeatureA") == "hello" + FeatureFlags.configure_flags({"FeatureA": "hello"}) + assert FeatureFlags.get_flag("FeatureA") == "hello" - def test_env_var_overrides_configured(self, monkeypatch: "MonkeyPatch") -> None: - configure({"FeatureA": True}) + def test_configured_value_takes_precedence_over_env_var( + self, monkeypatch: "MonkeyPatch" + ) -> None: + FeatureFlags.configure_flags({"FeatureA": True}) monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "false") - assert get("FeatureA") is False + assert FeatureFlags.get_flag("FeatureA") is True - def test_env_var_overrides_default(self, monkeypatch: "MonkeyPatch") -> None: + def test_env_var_used_when_nothing_configured( + self, monkeypatch: "MonkeyPatch" + ) -> None: monkeypatch.setenv("UIPATH_FEATURE_X", "custom") - assert get("X", default="other") == "custom" + assert FeatureFlags.get_flag("X", default="other") == "custom" def test_env_var_string_value(self, monkeypatch: "MonkeyPatch") -> None: monkeypatch.setenv("UIPATH_FEATURE_Model", "gpt-4-turbo") - assert get("Model") == "gpt-4-turbo" + assert FeatureFlags.get_flag("Model") == "gpt-4-turbo" def test_env_var_json_dict(self, monkeypatch: "MonkeyPatch") -> None: monkeypatch.setenv("UIPATH_FEATURE_Models", '{"gpt-4": true, "claude": false}') - assert get("Models") == {"gpt-4": True, "claude": False} + assert FeatureFlags.get_flag("Models") == {"gpt-4": True, "claude": False} def test_env_var_json_list(self, monkeypatch: "MonkeyPatch") -> None: monkeypatch.setenv("UIPATH_FEATURE_AllowedModels", '["gpt-4", "claude"]') - assert get("AllowedModels") == ["gpt-4", "claude"] + assert FeatureFlags.get_flag("AllowedModels") == ["gpt-4", "claude"] -class TestIsEnabled: - """Tests for is_enabled.""" +class TestIsFlagEnabled: + """Tests for is_flag_enabled.""" def setup_method(self) -> None: - reset() + FeatureFlags.reset_flags() def test_enabled_flag(self) -> None: - configure({"FeatureA": True}) - assert is_enabled("FeatureA") is True + FeatureFlags.configure_flags({"FeatureA": True}) + assert FeatureFlags.is_flag_enabled("FeatureA") is True def test_disabled_flag(self) -> None: - configure({"FeatureA": False}) - assert is_enabled("FeatureA") is False + FeatureFlags.configure_flags({"FeatureA": False}) + assert FeatureFlags.is_flag_enabled("FeatureA") is False def test_missing_flag_defaults_false(self) -> None: - assert is_enabled("Missing") is False + assert FeatureFlags.is_flag_enabled("Missing") is False def test_missing_flag_custom_default(self) -> None: - assert is_enabled("Missing", default=True) is True + assert FeatureFlags.is_flag_enabled("Missing", default=True) is True def test_truthy_string_is_enabled(self) -> None: - configure({"FeatureA": "some-value"}) - assert is_enabled("FeatureA") is True + FeatureFlags.configure_flags({"FeatureA": "some-value"}) + assert FeatureFlags.is_flag_enabled("FeatureA") is True def test_none_is_disabled(self) -> None: - configure({"FeatureA": None}) - assert is_enabled("FeatureA") is False + FeatureFlags.configure_flags({"FeatureA": None}) + assert FeatureFlags.is_flag_enabled("FeatureA") is False - def test_env_override_disables(self, monkeypatch: "MonkeyPatch") -> None: - configure({"FeatureA": True}) + def test_configured_value_takes_precedence_over_env_var( + self, monkeypatch: "MonkeyPatch" + ) -> None: + FeatureFlags.configure_flags({"FeatureA": True}) monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "false") - assert is_enabled("FeatureA") is False + assert FeatureFlags.is_flag_enabled("FeatureA") is True - def test_env_override_enables(self, monkeypatch: "MonkeyPatch") -> None: - configure({"FeatureA": False}) + def test_env_var_used_when_nothing_configured( + self, monkeypatch: "MonkeyPatch" + ) -> None: monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "true") - assert is_enabled("FeatureA") is True + assert FeatureFlags.is_flag_enabled("FeatureA") is True From 6af23fe866a482cacf1443874bdb84b95ca388a9 Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Wed, 18 Feb 2026 12:56:20 +0200 Subject: [PATCH 3/3] feat: address comm add future --- src/uipath/core/feature_flags/feature_flags.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/uipath/core/feature_flags/feature_flags.py b/src/uipath/core/feature_flags/feature_flags.py index 18d245c..53469d7 100644 --- a/src/uipath/core/feature_flags/feature_flags.py +++ b/src/uipath/core/feature_flags/feature_flags.py @@ -64,6 +64,7 @@ class FeatureFlagsManager: _flags: dict[str, Any] def __new__(cls) -> "FeatureFlagsManager": + """Return the singleton instance, creating it on first call.""" if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._flags = {}