Coverage for src/lexigram/features/backends/env.py: 27%
49 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 01:53 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 01:53 +0800
1"""Environment-variable-based flag provider.
3Reads feature flags from environment variables matching the pattern
4``{prefix}{FLAG_NAME}`` where the value encodes the flag type and its
5parameters. The parsed result is cached in-process; call
6:meth:`EnvProvider.invalidate` to force a re-read.
8Supported value formats:
10* ``true`` / ``false`` — :attr:`~lexigram.feature_flags.types.FlagType.BOOLEAN`
11* ``percentage:N`` — :attr:`~lexigram.feature_flags.types.FlagType.PERCENTAGE` rollout
12* ``users:user1,user2,...`` — :attr:`~lexigram.feature_flags.types.FlagType.USER_LIST`
13"""
15from __future__ import annotations
17import os
19from lexigram.features.backends.base import AbstractFlagProvider
20from lexigram.features.constants import ENV_PREFIX
21from lexigram.features.types import Flag, FlagContext, FlagEvaluation, FlagType
24class EnvProvider(AbstractFlagProvider):
25 """Feature flag provider that reads from environment variables.
27 Flag names are lowercased by stripping the prefix.
28 """
30 def __init__(self, prefix: str = ENV_PREFIX) -> None:
31 self.prefix = prefix
32 self._cache: dict[str, Flag] | None = None
34 # -- AbstractFlagProvider interface -------------------------------------
36 async def get_flag_definition(self, name: str) -> Flag | None:
37 """Return the parsed :class:`Flag` for *name*, or *None*."""
38 flags = await self.get_all_flags()
39 return flags.get(name)
41 async def get_all_flags(self) -> dict[str, Flag]:
42 """Return all flags parsed from the current environment."""
43 if self._cache is not None:
44 return self._cache
45 flags: dict[str, Flag] = {}
46 for key, value in os.environ.items():
47 if key.startswith(self.prefix):
48 flag_name = key[len(self.prefix) :].lower()
49 flag = self._parse_flag(flag_name, value.strip())
50 if flag is not None:
51 flags[flag_name] = flag
52 self._cache = flags
53 return flags
55 # -- Synchronous bridge -------------------------------------------------
57 def evaluate_sync(
58 self,
59 name: str,
60 context: FlagContext | None = None,
61 ) -> FlagEvaluation:
62 """Synchronous evaluation using the last-loaded env snapshot."""
63 flags = self._cache or {}
64 flag = flags.get(name)
65 if flag is None:
66 return FlagEvaluation(
67 flag_name=name,
68 enabled=False,
69 reason="flag_not_found",
70 value=False,
71 )
72 return self._evaluate_flag(flag, context or FlagContext())
74 def invalidate(self) -> None:
75 """Discard the cached snapshot so the next read re-reads env vars."""
76 self._cache = None
78 # -- Parsing ------------------------------------------------------------
80 def _parse_flag(self, name: str, raw: str) -> Flag | None:
81 """Parse a raw environment variable value into a :class:`Flag`.
83 Returns *None* for values that cannot be interpreted.
84 """
85 lower = raw.lower()
87 if lower in ("true", "1", "yes", "on"):
88 return Flag(name=name, type=FlagType.BOOLEAN, enabled=True)
90 if lower in ("false", "0", "no", "off"):
91 return Flag(name=name, type=FlagType.BOOLEAN, enabled=False)
93 if lower.startswith("percentage:"):
94 try:
95 pct = int(lower.split(":", 1)[1])
96 return Flag(name=name, type=FlagType.PERCENTAGE, percentage=pct)
97 except ValueError:
98 return None
100 if lower.startswith("users:"):
101 user_list = [
102 u.strip() for u in raw.split(":", 1)[1].split(",") if u.strip()
103 ]
104 return Flag(name=name, type=FlagType.USER_LIST, user_list=user_list)
106 return None
109__all__ = ["EnvProvider"]