Coverage for src/lexigram/features/types.py: 100%

88 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 02:04 +0800

1"""Type definitions for the feature-flag subsystem. 

2 

3All dataclasses, enums, type aliases, and TypeVars used throughout the 

4feature-flag system live here. No business logic; data definitions only. 

5 

6Types: 

7 FlagType: Evaluation strategy enum for a feature flag. 

8 FlagValue: Type alias for boolean or variant-name evaluation result. 

9 Flag: Full flag definition including evaluation rules. 

10 FlagContext: Evaluation context (user, session, attributes). 

11 FlagEvaluation: Result of evaluating a flag for a given context. 

12""" 

13 

14from __future__ import annotations 

15 

16from dataclasses import dataclass, field 

17from datetime import UTC, datetime 

18from enum import StrEnum 

19import hashlib 

20from typing import Any 

21 

22from lexigram.serialization import dumps_str 

23 

24 

25class FlagType(StrEnum): 

26 """Evaluation strategy for a feature flag.""" 

27 

28 BOOLEAN = "boolean" 

29 """Simple on/off control.""" 

30 

31 PERCENTAGE = "percentage" 

32 """Percentage-based gradual rollout (0-100).""" 

33 

34 USER_LIST = "user_list" 

35 """Enabled for an explicit list of user IDs.""" 

36 

37 USER_ATTRIBUTE = "user_attribute" 

38 """Enabled when user attributes match all required key/value pairs.""" 

39 

40 TIME_BASED = "time_based" 

41 """Active within an optional [start_time, end_time] window.""" 

42 

43 VARIANT = "variant" 

44 """A/B test or multi-variant rollout with weighted variants.""" 

45 

46 

47# Value of a flag evaluation — boolean or a variant name. 

48FlagValue = bool | str 

49 

50 

51@dataclass 

52class Flag: 

53 """Full definition of a feature flag including its evaluation strategy.""" 

54 

55 name: str 

56 """Unique flag identifier.""" 

57 

58 type: FlagType = FlagType.BOOLEAN 

59 """Evaluation strategy; defaults to BOOLEAN.""" 

60 

61 enabled: bool = True 

62 """Master switch — if False the flag is always off regardless of type.""" 

63 

64 description: str = "" 

65 """Human-readable description.""" 

66 

67 # PERCENTAGE type 

68 percentage: int = 0 

69 """Rollout percentage (0-100), used when type is PERCENTAGE.""" 

70 

71 # USER_LIST type 

72 user_list: list[str] = field(default_factory=list) 

73 """Explicit list of user IDs to enable, used when type is USER_LIST.""" 

74 

75 # USER_ATTRIBUTE type 

76 user_attributes: dict[str, Any] = field(default_factory=dict) 

77 """Required user attribute key/value pairs, used when type is USER_ATTRIBUTE.""" 

78 

79 # TIME_BASED type 

80 start_time: datetime | None = None 

81 """Window start; None means no lower bound.""" 

82 

83 end_time: datetime | None = None 

84 """Window end; None means no upper bound.""" 

85 

86 # VARIANT type 

87 variants: dict[str, int] = field(default_factory=dict) 

88 """Variant name → weight mapping; weights must sum to 100.""" 

89 

90 default_variant: str = "" 

91 """Variant returned when no user context is available.""" 

92 

93 # Generic metadata 

94 metadata: dict[str, Any] = field(default_factory=dict) 

95 

96 created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) 

97 updated_at: datetime = field(default_factory=lambda: datetime.now(UTC)) 

98 

99 def __post_init__(self) -> None: 

100 if self.type == FlagType.PERCENTAGE and not (0 <= self.percentage <= 100): 

101 raise ValueError( 

102 f"Percentage must be between 0 and 100, got {self.percentage}", 

103 ) 

104 if self.type == FlagType.VARIANT and self.variants: 

105 total = sum(self.variants.values()) 

106 if total != 100: 

107 raise ValueError( 

108 f"Variant weights must sum to 100, got {total}", 

109 ) 

110 

111 

112@dataclass 

113class FlagContext: 

114 """Evaluation context passed alongside a flag name. 

115 

116 Providers use the attributes here to resolve percentage rollouts, user 

117 lists, attribute rules, and variant assignments deterministically. 

118 """ 

119 

120 user_id: str | None = None 

121 user_attributes: dict[str, Any] | None = None 

122 session_id: str | None = None 

123 request_id: str | None = None 

124 timestamp: float | None = None 

125 custom: dict[str, Any] | None = None 

126 

127 def get_attribute(self, key: str, default: Any = None) -> Any: 

128 """Return an attribute from user_attributes or custom, or default. 

129 

130 Checks user_attributes first, then custom, then returns default. 

131 """ 

132 if self.user_attributes and key in self.user_attributes: 

133 return self.user_attributes[key] 

134 if self.custom and key in self.custom: 

135 return self.custom[key] 

136 return default 

137 

138 def as_dict(self) -> dict[str, Any]: 

139 """Serialise to a plain dict for cache key computation.""" 

140 return { 

141 "user_id": self.user_id, 

142 "user_attributes": self.user_attributes, 

143 "session_id": self.session_id, 

144 "request_id": self.request_id, 

145 "custom": self.custom, 

146 } 

147 

148 def context_hash(self) -> str: 

149 """Return a deterministic 12-char hex hash of the non-empty context fields.""" 

150 filtered = {k: v for k, v in self.as_dict().items() if v not in (None, {}, [])} 

151 if not filtered: 

152 return "" 

153 ctx_str = dumps_str(filtered, sort_keys=True) 

154 return hashlib.sha256(ctx_str.encode()).hexdigest()[:12] 

155 

156 

157@dataclass 

158class FlagEvaluation: 

159 """Result of evaluating a feature flag for a given context. 

160 

161 ``enabled`` is the boolean result; ``value`` holds the full evaluation 

162 value — a bool for boolean/percentage/list/attribute/time flags, or a 

163 variant name string for VARIANT flags. 

164 """ 

165 

166 flag_name: str 

167 enabled: bool 

168 reason: str 

169 value: FlagValue | None = None 

170 metadata: dict[str, Any] | None = None 

171 

172 def __post_init__(self) -> None: 

173 if self.value is None: 

174 self.value = self.enabled 

175 

176 

177__all__ = [ 

178 "Flag", 

179 "FlagContext", 

180 "FlagEvaluation", 

181 "FlagType", 

182 "FlagValue", 

183]