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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 02:04 +0800
1"""Type definitions for the feature-flag subsystem.
3All dataclasses, enums, type aliases, and TypeVars used throughout the
4feature-flag system live here. No business logic; data definitions only.
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"""
14from __future__ import annotations
16from dataclasses import dataclass, field
17from datetime import UTC, datetime
18from enum import StrEnum
19import hashlib
20from typing import Any
22from lexigram.serialization import dumps_str
25class FlagType(StrEnum):
26 """Evaluation strategy for a feature flag."""
28 BOOLEAN = "boolean"
29 """Simple on/off control."""
31 PERCENTAGE = "percentage"
32 """Percentage-based gradual rollout (0-100)."""
34 USER_LIST = "user_list"
35 """Enabled for an explicit list of user IDs."""
37 USER_ATTRIBUTE = "user_attribute"
38 """Enabled when user attributes match all required key/value pairs."""
40 TIME_BASED = "time_based"
41 """Active within an optional [start_time, end_time] window."""
43 VARIANT = "variant"
44 """A/B test or multi-variant rollout with weighted variants."""
47# Value of a flag evaluation — boolean or a variant name.
48FlagValue = bool | str
51@dataclass
52class Flag:
53 """Full definition of a feature flag including its evaluation strategy."""
55 name: str
56 """Unique flag identifier."""
58 type: FlagType = FlagType.BOOLEAN
59 """Evaluation strategy; defaults to BOOLEAN."""
61 enabled: bool = True
62 """Master switch — if False the flag is always off regardless of type."""
64 description: str = ""
65 """Human-readable description."""
67 # PERCENTAGE type
68 percentage: int = 0
69 """Rollout percentage (0-100), used when type is PERCENTAGE."""
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."""
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."""
79 # TIME_BASED type
80 start_time: datetime | None = None
81 """Window start; None means no lower bound."""
83 end_time: datetime | None = None
84 """Window end; None means no upper bound."""
86 # VARIANT type
87 variants: dict[str, int] = field(default_factory=dict)
88 """Variant name → weight mapping; weights must sum to 100."""
90 default_variant: str = ""
91 """Variant returned when no user context is available."""
93 # Generic metadata
94 metadata: dict[str, Any] = field(default_factory=dict)
96 created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
97 updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
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 )
112@dataclass
113class FlagContext:
114 """Evaluation context passed alongside a flag name.
116 Providers use the attributes here to resolve percentage rollouts, user
117 lists, attribute rules, and variant assignments deterministically.
118 """
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
127 def get_attribute(self, key: str, default: Any = None) -> Any:
128 """Return an attribute from user_attributes or custom, or default.
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
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 }
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]
157@dataclass
158class FlagEvaluation:
159 """Result of evaluating a feature flag for a given context.
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 """
166 flag_name: str
167 enabled: bool
168 reason: str
169 value: FlagValue | None = None
170 metadata: dict[str, Any] | None = None
172 def __post_init__(self) -> None:
173 if self.value is None:
174 self.value = self.enabled
177__all__ = [
178 "Flag",
179 "FlagContext",
180 "FlagEvaluation",
181 "FlagType",
182 "FlagValue",
183]