From 2cd6b27b1ef76f5e5cd999721aa4290a8400a97a Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Sat, 14 Feb 2026 09:30:21 +0200 Subject: [PATCH] feat: add local-only feature flags registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a simple feature flags config module that allows flags to be set programmatically via configure() and overridden locally with UIPATH_FEATURE_ environment variables. No cloud service dependency — upstream layers (e.g. uipath-agents) can fetch from a remote API and push values into this registry. Co-Authored-By: Claude Opus 4.6 --- src/uipath/platform/feature_flags/__init__.py | 13 ++ .../platform/feature_flags/feature_flags.py | 86 +++++++++++ tests/sdk/services/test_feature_flags.py | 134 ++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 src/uipath/platform/feature_flags/__init__.py create mode 100644 src/uipath/platform/feature_flags/feature_flags.py create mode 100644 tests/sdk/services/test_feature_flags.py diff --git a/src/uipath/platform/feature_flags/__init__.py b/src/uipath/platform/feature_flags/__init__.py new file mode 100644 index 000000000..7d7a64b9e --- /dev/null +++ b/src/uipath/platform/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/platform/feature_flags/feature_flags.py b/src/uipath/platform/feature_flags/feature_flags.py new file mode 100644 index 000000000..41cb4f489 --- /dev/null +++ b/src/uipath/platform/feature_flags/feature_flags.py @@ -0,0 +1,86 @@ +"""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.platform.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 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.""" + lower = raw.lower() + if lower == "true": + return True + if lower == "false": + return False + 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/sdk/services/test_feature_flags.py b/tests/sdk/services/test_feature_flags.py new file mode 100644 index 000000000..10e8a6474 --- /dev/null +++ b/tests/sdk/services/test_feature_flags.py @@ -0,0 +1,134 @@ +"""Unit tests for the feature flags registry.""" + +from typing import TYPE_CHECKING + +from uipath.platform.feature_flags import configure, get, is_enabled, reset +from uipath.platform.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" + + +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" + + +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