Skip to content
Merged
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
11 changes: 11 additions & 0 deletions src/uipath/core/feature_flags/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
118 changes: 118 additions & 0 deletions src/uipath/core/feature_flags/feature_flags.py
Original file line number Diff line number Diff line change
@@ -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_<FlagName>``
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_<name>`` 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()
Empty file added tests/feature_flags/__init__.py
Empty file.
167 changes: 167 additions & 0 deletions tests/feature_flags/test_feature_flags.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.