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..c112d4f --- /dev/null +++ b/src/uipath/core/feature_flags/__init__.py @@ -0,0 +1,11 @@ +"""UiPath Feature Flags. + +Local-only feature flag registry for the UiPath SDK. +""" + +from .feature_flags import FeatureFlags, FeatureFlagsManager + +__all__ = [ + "FeatureFlags", + "FeatureFlagsManager", +] 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..53469d7 --- /dev/null +++ b/src/uipath/core/feature_flags/feature_flags.py @@ -0,0 +1,118 @@ +"""Feature flags configuration for UiPath SDK. + +A simple, local-only feature flag registry. Flags can be set +programmatically via :meth:`FeatureFlagsManager.configure_flags` or +supplied via environment variables named ``UIPATH_FEATURE_`` +when nothing has been configured programmatically. + +Programmatic values always take precedence over environment variables. + +Example usage:: + + from uipath.core.feature_flags import FeatureFlags + + # Programmatic configuration (e.g. from an upstream layer) + FeatureFlags.configure_flags({"NewSerialization": True, "ModelOverride": "gpt-4"}) + + # Check a boolean flag + if FeatureFlags.is_flag_enabled("NewSerialization"): + ... + + # Get an arbitrary value + model = FeatureFlags.get_flag("ModelOverride", default="default-model") + + # Local override via environment variable + # $ export UIPATH_FEATURE_NewSerialization=false +""" + +import json +import os +from typing import Any + + +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 + + +class FeatureFlagsManager: + """Singleton registry for UiPath feature flags. + + Use the module-level :data:`FeatureFlags` instance rather than + instantiating this class directly. + """ + + _instance: "FeatureFlagsManager | None" = None + _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 = {} + return cls._instance + + def configure_flags(self, 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. + """ + self._flags.update(flags) + + def reset_flags(self) -> None: + """Clear all configured flags.""" + self._flags.clear() + + 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/__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..8193d74 --- /dev/null +++ b/tests/feature_flags/test_feature_flags.py @@ -0,0 +1,167 @@ +"""Unit tests for the feature flags registry.""" + +from typing import TYPE_CHECKING + +from uipath.core.feature_flags import FeatureFlags +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 TestConfigureFlags: + """Tests for configure_flags / reset_flags.""" + + def setup_method(self) -> None: + FeatureFlags.reset_flags() + + def test_configure_sets_flags(self) -> None: + 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: + 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: + FeatureFlags.configure_flags({"FeatureA": True}) + FeatureFlags.configure_flags({"FeatureA": False}) + assert FeatureFlags.get_flag("FeatureA") is False + + def test_reset_clears_all(self) -> None: + FeatureFlags.configure_flags({"FeatureA": True}) + FeatureFlags.reset_flags() + assert FeatureFlags.get_flag("FeatureA") is None + + +class TestGetFlag: + """Tests for get_flag.""" + + def setup_method(self) -> None: + FeatureFlags.reset_flags() + + def test_returns_default_when_unset(self) -> None: + assert FeatureFlags.get_flag("Missing") is None + + def test_returns_custom_default(self) -> None: + assert FeatureFlags.get_flag("Missing", default="fallback") == "fallback" + + def test_returns_configured_value(self) -> None: + FeatureFlags.configure_flags({"FeatureA": "hello"}) + assert FeatureFlags.get_flag("FeatureA") == "hello" + + 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 FeatureFlags.get_flag("FeatureA") is True + + def test_env_var_used_when_nothing_configured( + self, monkeypatch: "MonkeyPatch" + ) -> None: + monkeypatch.setenv("UIPATH_FEATURE_X", "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 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 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 FeatureFlags.get_flag("AllowedModels") == ["gpt-4", "claude"] + + +class TestIsFlagEnabled: + """Tests for is_flag_enabled.""" + + def setup_method(self) -> None: + FeatureFlags.reset_flags() + + def test_enabled_flag(self) -> None: + FeatureFlags.configure_flags({"FeatureA": True}) + assert FeatureFlags.is_flag_enabled("FeatureA") is True + + def test_disabled_flag(self) -> None: + FeatureFlags.configure_flags({"FeatureA": False}) + assert FeatureFlags.is_flag_enabled("FeatureA") is False + + def test_missing_flag_defaults_false(self) -> None: + assert FeatureFlags.is_flag_enabled("Missing") is False + + def test_missing_flag_custom_default(self) -> None: + assert FeatureFlags.is_flag_enabled("Missing", default=True) is True + + def test_truthy_string_is_enabled(self) -> None: + FeatureFlags.configure_flags({"FeatureA": "some-value"}) + assert FeatureFlags.is_flag_enabled("FeatureA") is True + + def test_none_is_disabled(self) -> None: + FeatureFlags.configure_flags({"FeatureA": None}) + assert FeatureFlags.is_flag_enabled("FeatureA") is False + + 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 FeatureFlags.is_flag_enabled("FeatureA") is True + + def test_env_var_used_when_nothing_configured( + self, monkeypatch: "MonkeyPatch" + ) -> None: + monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "true") + assert FeatureFlags.is_flag_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" },