Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/uipath/platform/feature_flags/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
86 changes: 86 additions & 0 deletions src/uipath/platform/feature_flags/feature_flags.py
Original file line number Diff line number Diff line change
@@ -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_<FlagName>``.

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_<name>`` 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))
134 changes: 134 additions & 0 deletions tests/sdk/services/test_feature_flags.py
Original file line number Diff line number Diff line change
@@ -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