foundry-mcp 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. foundry_mcp/__init__.py +7 -0
  2. foundry_mcp/cli/__init__.py +80 -0
  3. foundry_mcp/cli/__main__.py +9 -0
  4. foundry_mcp/cli/agent.py +96 -0
  5. foundry_mcp/cli/commands/__init__.py +37 -0
  6. foundry_mcp/cli/commands/cache.py +137 -0
  7. foundry_mcp/cli/commands/dashboard.py +148 -0
  8. foundry_mcp/cli/commands/dev.py +446 -0
  9. foundry_mcp/cli/commands/journal.py +377 -0
  10. foundry_mcp/cli/commands/lifecycle.py +274 -0
  11. foundry_mcp/cli/commands/modify.py +824 -0
  12. foundry_mcp/cli/commands/plan.py +633 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +652 -0
  15. foundry_mcp/cli/commands/session.py +479 -0
  16. foundry_mcp/cli/commands/specs.py +856 -0
  17. foundry_mcp/cli/commands/tasks.py +807 -0
  18. foundry_mcp/cli/commands/testing.py +676 -0
  19. foundry_mcp/cli/commands/validate.py +982 -0
  20. foundry_mcp/cli/config.py +98 -0
  21. foundry_mcp/cli/context.py +259 -0
  22. foundry_mcp/cli/flags.py +266 -0
  23. foundry_mcp/cli/logging.py +212 -0
  24. foundry_mcp/cli/main.py +44 -0
  25. foundry_mcp/cli/output.py +122 -0
  26. foundry_mcp/cli/registry.py +110 -0
  27. foundry_mcp/cli/resilience.py +178 -0
  28. foundry_mcp/cli/transcript.py +217 -0
  29. foundry_mcp/config.py +850 -0
  30. foundry_mcp/core/__init__.py +144 -0
  31. foundry_mcp/core/ai_consultation.py +1636 -0
  32. foundry_mcp/core/cache.py +195 -0
  33. foundry_mcp/core/capabilities.py +446 -0
  34. foundry_mcp/core/concurrency.py +898 -0
  35. foundry_mcp/core/context.py +540 -0
  36. foundry_mcp/core/discovery.py +1603 -0
  37. foundry_mcp/core/error_collection.py +728 -0
  38. foundry_mcp/core/error_store.py +592 -0
  39. foundry_mcp/core/feature_flags.py +592 -0
  40. foundry_mcp/core/health.py +749 -0
  41. foundry_mcp/core/journal.py +694 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1350 -0
  44. foundry_mcp/core/llm_patterns.py +510 -0
  45. foundry_mcp/core/llm_provider.py +1569 -0
  46. foundry_mcp/core/logging_config.py +374 -0
  47. foundry_mcp/core/metrics_persistence.py +584 -0
  48. foundry_mcp/core/metrics_registry.py +327 -0
  49. foundry_mcp/core/metrics_store.py +641 -0
  50. foundry_mcp/core/modifications.py +224 -0
  51. foundry_mcp/core/naming.py +123 -0
  52. foundry_mcp/core/observability.py +1216 -0
  53. foundry_mcp/core/otel.py +452 -0
  54. foundry_mcp/core/otel_stubs.py +264 -0
  55. foundry_mcp/core/pagination.py +255 -0
  56. foundry_mcp/core/progress.py +317 -0
  57. foundry_mcp/core/prometheus.py +577 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +546 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +511 -0
  61. foundry_mcp/core/prompts/plan_review.py +623 -0
  62. foundry_mcp/core/providers/__init__.py +225 -0
  63. foundry_mcp/core/providers/base.py +476 -0
  64. foundry_mcp/core/providers/claude.py +460 -0
  65. foundry_mcp/core/providers/codex.py +619 -0
  66. foundry_mcp/core/providers/cursor_agent.py +642 -0
  67. foundry_mcp/core/providers/detectors.py +488 -0
  68. foundry_mcp/core/providers/gemini.py +405 -0
  69. foundry_mcp/core/providers/opencode.py +616 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +302 -0
  71. foundry_mcp/core/providers/package-lock.json +24 -0
  72. foundry_mcp/core/providers/package.json +25 -0
  73. foundry_mcp/core/providers/registry.py +607 -0
  74. foundry_mcp/core/providers/test_provider.py +171 -0
  75. foundry_mcp/core/providers/validation.py +729 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/resilience.py +600 -0
  78. foundry_mcp/core/responses.py +934 -0
  79. foundry_mcp/core/review.py +366 -0
  80. foundry_mcp/core/security.py +438 -0
  81. foundry_mcp/core/spec.py +1650 -0
  82. foundry_mcp/core/task.py +1289 -0
  83. foundry_mcp/core/testing.py +450 -0
  84. foundry_mcp/core/validation.py +2081 -0
  85. foundry_mcp/dashboard/__init__.py +32 -0
  86. foundry_mcp/dashboard/app.py +119 -0
  87. foundry_mcp/dashboard/components/__init__.py +17 -0
  88. foundry_mcp/dashboard/components/cards.py +88 -0
  89. foundry_mcp/dashboard/components/charts.py +234 -0
  90. foundry_mcp/dashboard/components/filters.py +136 -0
  91. foundry_mcp/dashboard/components/tables.py +195 -0
  92. foundry_mcp/dashboard/data/__init__.py +11 -0
  93. foundry_mcp/dashboard/data/stores.py +433 -0
  94. foundry_mcp/dashboard/launcher.py +289 -0
  95. foundry_mcp/dashboard/views/__init__.py +12 -0
  96. foundry_mcp/dashboard/views/errors.py +217 -0
  97. foundry_mcp/dashboard/views/metrics.py +174 -0
  98. foundry_mcp/dashboard/views/overview.py +160 -0
  99. foundry_mcp/dashboard/views/providers.py +83 -0
  100. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  101. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  102. foundry_mcp/prompts/__init__.py +9 -0
  103. foundry_mcp/prompts/workflows.py +525 -0
  104. foundry_mcp/resources/__init__.py +9 -0
  105. foundry_mcp/resources/specs.py +591 -0
  106. foundry_mcp/schemas/__init__.py +38 -0
  107. foundry_mcp/schemas/sdd-spec-schema.json +386 -0
  108. foundry_mcp/server.py +164 -0
  109. foundry_mcp/tools/__init__.py +10 -0
  110. foundry_mcp/tools/unified/__init__.py +71 -0
  111. foundry_mcp/tools/unified/authoring.py +1487 -0
  112. foundry_mcp/tools/unified/context_helpers.py +98 -0
  113. foundry_mcp/tools/unified/documentation_helpers.py +198 -0
  114. foundry_mcp/tools/unified/environment.py +939 -0
  115. foundry_mcp/tools/unified/error.py +462 -0
  116. foundry_mcp/tools/unified/health.py +225 -0
  117. foundry_mcp/tools/unified/journal.py +841 -0
  118. foundry_mcp/tools/unified/lifecycle.py +632 -0
  119. foundry_mcp/tools/unified/metrics.py +777 -0
  120. foundry_mcp/tools/unified/plan.py +745 -0
  121. foundry_mcp/tools/unified/pr.py +294 -0
  122. foundry_mcp/tools/unified/provider.py +629 -0
  123. foundry_mcp/tools/unified/review.py +685 -0
  124. foundry_mcp/tools/unified/review_helpers.py +299 -0
  125. foundry_mcp/tools/unified/router.py +102 -0
  126. foundry_mcp/tools/unified/server.py +580 -0
  127. foundry_mcp/tools/unified/spec.py +808 -0
  128. foundry_mcp/tools/unified/task.py +2202 -0
  129. foundry_mcp/tools/unified/test.py +370 -0
  130. foundry_mcp/tools/unified/verification.py +520 -0
  131. foundry_mcp-0.3.3.dist-info/METADATA +337 -0
  132. foundry_mcp-0.3.3.dist-info/RECORD +135 -0
  133. foundry_mcp-0.3.3.dist-info/WHEEL +4 -0
  134. foundry_mcp-0.3.3.dist-info/entry_points.txt +3 -0
  135. foundry_mcp-0.3.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,592 @@
1
+ """
2
+ Feature flags infrastructure for foundry-mcp.
3
+
4
+ Provides feature flag management with lifecycle states, percentage rollouts,
5
+ client-specific overrides, and testing utilities.
6
+
7
+ See docs/mcp_best_practices/14-feature-flags.md for guidance.
8
+
9
+ Example:
10
+ from foundry_mcp.core.feature_flags import (
11
+ FlagState, FeatureFlag, FeatureFlagRegistry, feature_flag, flag_override
12
+ )
13
+
14
+ # Define flags
15
+ registry = FeatureFlagRegistry()
16
+ registry.register(FeatureFlag(
17
+ name="new_algorithm",
18
+ description="Use improved processing algorithm",
19
+ state=FlagState.BETA,
20
+ default_enabled=False,
21
+ ))
22
+
23
+ # Gate feature with decorator
24
+ @feature_flag("new_algorithm")
25
+ def process_data(data: dict) -> dict:
26
+ return improved_process(data)
27
+
28
+ # Test with override
29
+ with flag_override("new_algorithm", True):
30
+ result = process_data({"input": "test"})
31
+ """
32
+
33
+ import hashlib
34
+ import logging
35
+ from contextlib import contextmanager
36
+ from contextvars import ContextVar
37
+ from dataclasses import asdict, dataclass, field
38
+ from datetime import datetime, timezone
39
+ from enum import Enum
40
+ from functools import wraps
41
+ from typing import (
42
+ Any,
43
+ Callable,
44
+ Dict,
45
+ Generator,
46
+ Optional,
47
+ ParamSpec,
48
+ Set,
49
+ TypeVar,
50
+ )
51
+
52
+ from foundry_mcp.core.responses import error_response
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+ # Context variable for current client ID
57
+ current_client_id: ContextVar[str] = ContextVar("client_id", default="anonymous")
58
+
59
+
60
+ class FlagState(str, Enum):
61
+ """Lifecycle state for feature flags.
62
+
63
+ Feature flags progress through states as they mature:
64
+
65
+ EXPERIMENTAL: Early development, opt-in only, may change without notice, no SLA
66
+ BETA: Feature-complete but not fully validated, default off, opt-in available, some SLA
67
+ STABLE: Production-ready, default on, full SLA guarantees
68
+ DEPRECATED: Being phased out, warns on use, will be removed after expiration
69
+
70
+ Example:
71
+ >>> flag = FeatureFlag(name="my_feature", state=FlagState.EXPERIMENTAL)
72
+ >>> if flag.state == FlagState.DEPRECATED:
73
+ ... logger.warning("Feature is deprecated")
74
+ """
75
+
76
+ EXPERIMENTAL = "experimental"
77
+ BETA = "beta"
78
+ STABLE = "stable"
79
+ DEPRECATED = "deprecated"
80
+
81
+
82
+ @dataclass
83
+ class FeatureFlag:
84
+ """Definition of a feature flag with lifecycle and rollout configuration.
85
+
86
+ Attributes:
87
+ name: Unique identifier for the flag (used in code and config)
88
+ description: Human-readable description of what this flag controls
89
+ state: Current lifecycle state (experimental/beta/stable/deprecated)
90
+ default_enabled: Whether the flag is enabled by default
91
+ created_at: When the flag was created (defaults to now)
92
+ expires_at: Optional expiration date after which flag should be removed
93
+ owner: Team or individual responsible for this flag
94
+ percentage_rollout: Percentage of clients that should have flag enabled (0-100)
95
+ allowed_clients: If non-empty, only these clients can access the flag
96
+ blocked_clients: These clients are explicitly denied access
97
+ dependencies: Other flags that must be enabled for this flag to be enabled
98
+ metadata: Additional key-value metadata for the flag
99
+
100
+ Example:
101
+ >>> flag = FeatureFlag(
102
+ ... name="new_search",
103
+ ... description="Enable new search algorithm",
104
+ ... state=FlagState.BETA,
105
+ ... default_enabled=False,
106
+ ... percentage_rollout=25.0,
107
+ ... owner="search-team",
108
+ ... )
109
+ >>> flag.is_expired()
110
+ False
111
+ """
112
+
113
+ name: str
114
+ description: str
115
+ state: FlagState
116
+ default_enabled: bool
117
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
118
+ expires_at: Optional[datetime] = None
119
+ owner: str = ""
120
+ percentage_rollout: float = 100.0
121
+ allowed_clients: Set[str] = field(default_factory=set)
122
+ blocked_clients: Set[str] = field(default_factory=set)
123
+ dependencies: Set[str] = field(default_factory=set)
124
+ metadata: Dict[str, Any] = field(default_factory=dict)
125
+
126
+ def is_expired(self) -> bool:
127
+ """Check if the flag has passed its expiration date."""
128
+ if self.expires_at is None:
129
+ return False
130
+ return datetime.now(timezone.utc) > self.expires_at
131
+
132
+ def to_dict(self) -> Dict[str, Any]:
133
+ """Convert to dictionary for JSON serialization."""
134
+ return {
135
+ "name": self.name,
136
+ "description": self.description,
137
+ "state": self.state.value,
138
+ "default_enabled": self.default_enabled,
139
+ "created_at": self.created_at.isoformat(),
140
+ "expires_at": self.expires_at.isoformat() if self.expires_at else None,
141
+ "owner": self.owner,
142
+ "percentage_rollout": self.percentage_rollout,
143
+ "allowed_clients": list(self.allowed_clients),
144
+ "blocked_clients": list(self.blocked_clients),
145
+ "dependencies": list(self.dependencies),
146
+ "metadata": self.metadata,
147
+ }
148
+
149
+
150
+ class FeatureFlagRegistry:
151
+ """Registry for managing feature flags with evaluation logic.
152
+
153
+ Provides flag registration, client-specific evaluation, percentage rollouts,
154
+ and override support for testing.
155
+
156
+ Example:
157
+ >>> registry = FeatureFlagRegistry()
158
+ >>> registry.register(FeatureFlag(
159
+ ... name="new_feature",
160
+ ... description="Test feature",
161
+ ... state=FlagState.BETA,
162
+ ... default_enabled=False,
163
+ ... ))
164
+ >>> registry.is_enabled("new_feature", client_id="user123")
165
+ False
166
+ """
167
+
168
+ def __init__(self) -> None:
169
+ """Initialize an empty flag registry."""
170
+ self._flags: Dict[str, FeatureFlag] = {}
171
+ self._overrides: Dict[
172
+ str, Dict[str, bool]
173
+ ] = {} # client_id -> flag_name -> value
174
+
175
+ def register(self, flag: FeatureFlag) -> None:
176
+ """Register a feature flag.
177
+
178
+ Args:
179
+ flag: The feature flag to register
180
+
181
+ Raises:
182
+ ValueError: If a flag with the same name already exists
183
+ """
184
+ if flag.name in self._flags:
185
+ raise ValueError(f"Flag '{flag.name}' is already registered")
186
+ self._flags[flag.name] = flag
187
+ logger.debug(f"Registered feature flag: {flag.name} ({flag.state.value})")
188
+
189
+ def get(self, flag_name: str) -> Optional[FeatureFlag]:
190
+ """Get a flag by name.
191
+
192
+ Args:
193
+ flag_name: Name of the flag to retrieve
194
+
195
+ Returns:
196
+ The FeatureFlag if found, None otherwise
197
+ """
198
+ return self._flags.get(flag_name)
199
+
200
+ def is_enabled(
201
+ self,
202
+ flag_name: str,
203
+ client_id: Optional[str] = None,
204
+ default: bool = False,
205
+ ) -> bool:
206
+ """Check if a feature flag is enabled for a client.
207
+
208
+ Evaluation order:
209
+ 1. Check for client-specific override
210
+ 2. Check if flag exists
211
+ 3. Check if flag is expired
212
+ 4. Check client blocklist
213
+ 5. Check client allowlist
214
+ 6. Check flag dependencies
215
+ 7. Evaluate percentage rollout
216
+ 8. Return default_enabled value
217
+
218
+ Args:
219
+ flag_name: Name of the flag to check
220
+ client_id: Client ID for evaluation (defaults to context variable)
221
+ default: Value to return if flag doesn't exist
222
+
223
+ Returns:
224
+ True if the flag is enabled for this client, False otherwise
225
+ """
226
+ client_id = client_id or current_client_id.get()
227
+
228
+ # Check for client-specific override first
229
+ if client_id in self._overrides:
230
+ if flag_name in self._overrides[client_id]:
231
+ return self._overrides[client_id][flag_name]
232
+
233
+ # Check if flag exists
234
+ flag = self._flags.get(flag_name)
235
+ if not flag:
236
+ logger.warning(f"Unknown feature flag: {flag_name}")
237
+ return default
238
+
239
+ # Warn if deprecated
240
+ if flag.state == FlagState.DEPRECATED:
241
+ logger.warning(
242
+ f"Deprecated feature flag '{flag_name}' accessed",
243
+ extra={"client_id": client_id, "flag": flag_name},
244
+ )
245
+
246
+ # Check expiration
247
+ if flag.is_expired():
248
+ logger.warning(f"Expired feature flag: {flag_name}")
249
+ return default
250
+
251
+ # Check blocklist
252
+ if flag.blocked_clients and client_id in flag.blocked_clients:
253
+ return False
254
+
255
+ # Check allowlist (empty means all allowed)
256
+ if flag.allowed_clients and client_id not in flag.allowed_clients:
257
+ return False
258
+
259
+ # Check dependencies
260
+ for dep_flag in flag.dependencies:
261
+ if not self.is_enabled(dep_flag, client_id):
262
+ return False
263
+
264
+ # Evaluate percentage rollout
265
+ if not self._evaluate_percentage(flag, client_id):
266
+ return False
267
+
268
+ return flag.default_enabled
269
+
270
+ def _evaluate_percentage(self, flag: FeatureFlag, client_id: str) -> bool:
271
+ """Evaluate percentage-based rollout using deterministic hashing.
272
+
273
+ Args:
274
+ flag: The feature flag to evaluate
275
+ client_id: Client ID for bucket assignment
276
+
277
+ Returns:
278
+ True if client falls within rollout percentage, False otherwise
279
+ """
280
+ if flag.percentage_rollout >= 100.0:
281
+ return True
282
+
283
+ if flag.percentage_rollout <= 0.0:
284
+ return False
285
+
286
+ # Use consistent hashing for stable bucket assignment
287
+ hash_input = f"{flag.name}:{client_id}"
288
+ hash_value = int(hashlib.md5(hash_input.encode()).hexdigest(), 16)
289
+ bucket = (hash_value % 100) + 1
290
+
291
+ return bucket <= flag.percentage_rollout
292
+
293
+ def set_override(self, client_id: str, flag_name: str, enabled: bool) -> None:
294
+ """Set a client-specific override for a flag.
295
+
296
+ Args:
297
+ client_id: The client to override for
298
+ flag_name: Name of the flag to override
299
+ enabled: Override value
300
+ """
301
+ if client_id not in self._overrides:
302
+ self._overrides[client_id] = {}
303
+ self._overrides[client_id][flag_name] = enabled
304
+ logger.debug(f"Set override: {flag_name}={enabled} for client {client_id}")
305
+
306
+ def clear_override(self, client_id: str, flag_name: str) -> None:
307
+ """Clear a client-specific override.
308
+
309
+ Args:
310
+ client_id: The client to clear override for
311
+ flag_name: Name of the flag to clear
312
+ """
313
+ if client_id in self._overrides:
314
+ self._overrides[client_id].pop(flag_name, None)
315
+ if not self._overrides[client_id]:
316
+ del self._overrides[client_id]
317
+
318
+ def clear_all_overrides(self, client_id: Optional[str] = None) -> None:
319
+ """Clear all overrides, optionally for a specific client.
320
+
321
+ Args:
322
+ client_id: If provided, only clear overrides for this client
323
+ """
324
+ if client_id:
325
+ self._overrides.pop(client_id, None)
326
+ else:
327
+ self._overrides.clear()
328
+
329
+ def get_flags_for_capabilities(
330
+ self,
331
+ client_id: Optional[str] = None,
332
+ ) -> Dict[str, Dict[str, Any]]:
333
+ """Get flag status for capabilities endpoint.
334
+
335
+ Returns a dictionary suitable for including in a capabilities response,
336
+ showing each flag's enabled status, state, and description.
337
+
338
+ Args:
339
+ client_id: Client ID for evaluation (defaults to context variable)
340
+
341
+ Returns:
342
+ Dictionary mapping flag names to their status information
343
+ """
344
+ client_id = client_id or current_client_id.get()
345
+ result = {}
346
+
347
+ for name, flag in self._flags.items():
348
+ status = {
349
+ "enabled": self.is_enabled(name, client_id),
350
+ "state": flag.state.value,
351
+ "description": flag.description,
352
+ }
353
+
354
+ if flag.state == FlagState.DEPRECATED:
355
+ expires_str = (
356
+ flag.expires_at.isoformat() if flag.expires_at else "unspecified"
357
+ )
358
+ status["deprecation_notice"] = (
359
+ f"This feature is deprecated and will be removed after {expires_str}"
360
+ )
361
+
362
+ result[name] = status
363
+
364
+ return result
365
+
366
+ def list_flags(self) -> Dict[str, FeatureFlag]:
367
+ """Get all registered flags.
368
+
369
+ Returns:
370
+ Dictionary mapping flag names to FeatureFlag objects
371
+ """
372
+ return dict(self._flags)
373
+
374
+
375
+ # Global registry instance
376
+ _default_registry = FeatureFlagRegistry()
377
+
378
+
379
+ def _register_builtin_flags(registry: FeatureFlagRegistry) -> None:
380
+ """Register Foundry MCP built-in flags.
381
+
382
+ This keeps commonly-referenced flags discoverable and avoids noisy
383
+ "Unknown feature flag" warnings when running without env overrides.
384
+ """
385
+
386
+ builtin_flags = [
387
+ FeatureFlag(
388
+ name="unified_tools_phase2",
389
+ description="Enable unified routers for medium tool families",
390
+ state=FlagState.BETA,
391
+ default_enabled=False,
392
+ percentage_rollout=0.0,
393
+ owner="foundry-mcp core",
394
+ ),
395
+ FeatureFlag(
396
+ name="unified_tools_phase3",
397
+ description="Enable unified routers for large tool families",
398
+ state=FlagState.BETA,
399
+ default_enabled=False,
400
+ percentage_rollout=0.0,
401
+ owner="foundry-mcp core",
402
+ ),
403
+ FeatureFlag(
404
+ name="unified_manifest",
405
+ description="Expose only the 17 unified tools for discovery/token reduction",
406
+ state=FlagState.BETA,
407
+ default_enabled=False,
408
+ percentage_rollout=0.0,
409
+ owner="foundry-mcp core",
410
+ ),
411
+ ]
412
+
413
+ for flag in builtin_flags:
414
+ if registry.get(flag.name) is not None:
415
+ continue
416
+ try:
417
+ registry.register(flag)
418
+ except ValueError:
419
+ # Already registered (race or re-import)
420
+ continue
421
+
422
+
423
+ _register_builtin_flags(_default_registry)
424
+
425
+
426
+ def get_registry() -> FeatureFlagRegistry:
427
+ """Get the default feature flag registry."""
428
+ return _default_registry
429
+
430
+
431
+ # Alias for server.py compatibility
432
+ get_flag_service = get_registry
433
+
434
+
435
+ P = ParamSpec("P")
436
+ R = TypeVar("R")
437
+
438
+
439
+ def feature_flag(
440
+ flag_name: str,
441
+ *,
442
+ fallback: Optional[Callable[P, Any]] = None,
443
+ registry: Optional[FeatureFlagRegistry] = None,
444
+ ) -> Callable[[Callable[P, R]], Callable[P, Any]]:
445
+ """Decorator to gate function execution behind a feature flag.
446
+
447
+ When the feature flag is not enabled for the current client, the decorated
448
+ function will not execute. Instead, it returns a FEATURE_DISABLED error
449
+ response (or invokes the fallback if provided).
450
+
451
+ Args:
452
+ flag_name: Name of the feature flag to check
453
+ fallback: Optional fallback function to call when flag is disabled.
454
+ If provided, this function is called with the same arguments.
455
+ If not provided, returns a FEATURE_DISABLED error response.
456
+ registry: Optional registry to use. Defaults to the global registry.
457
+
458
+ Returns:
459
+ Decorated function that checks the flag before execution
460
+
461
+ Example:
462
+ >>> @feature_flag("new_algorithm")
463
+ ... def process_data(data: dict) -> dict:
464
+ ... return {"processed": True}
465
+ ...
466
+ >>> # When flag is disabled, returns error response
467
+ >>> result = process_data({"input": "test"})
468
+ >>> result["success"]
469
+ False
470
+ >>> result["data"]["error_code"]
471
+ 'FEATURE_DISABLED'
472
+
473
+ >>> # With fallback function
474
+ >>> @feature_flag("new_algorithm", fallback=legacy_process)
475
+ ... def process_data(data: dict) -> dict:
476
+ ... return {"processed": True, "algorithm": "new"}
477
+ ...
478
+ >>> # When flag is disabled, calls legacy_process instead
479
+ """
480
+
481
+ def decorator(func: Callable[P, R]) -> Callable[P, Any]:
482
+ @wraps(func)
483
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
484
+ reg = registry or get_registry()
485
+
486
+ if not reg.is_enabled(flag_name):
487
+ logger.debug(
488
+ f"Feature flag '{flag_name}' is disabled, "
489
+ f"blocking execution of {func.__name__}"
490
+ )
491
+
492
+ if fallback is not None:
493
+ return fallback(*args, **kwargs)
494
+
495
+ # Return FEATURE_DISABLED error response
496
+ response = error_response(
497
+ message=f"Feature '{flag_name}' is not enabled",
498
+ error_code="FEATURE_DISABLED",
499
+ error_type="feature_flag",
500
+ data={
501
+ "feature": flag_name,
502
+ },
503
+ remediation="Contact support to enable this feature or check feature flag configuration",
504
+ )
505
+ return asdict(response)
506
+
507
+ return func(*args, **kwargs)
508
+
509
+ return wrapper
510
+
511
+ return decorator
512
+
513
+
514
+ @contextmanager
515
+ def flag_override(
516
+ flag_name: str,
517
+ enabled: bool,
518
+ *,
519
+ client_id: Optional[str] = None,
520
+ registry: Optional[FeatureFlagRegistry] = None,
521
+ ) -> Generator[None, None, None]:
522
+ """Context manager for temporarily overriding a feature flag value.
523
+
524
+ Useful for testing code paths that depend on feature flags without
525
+ modifying the actual flag configuration. The override is automatically
526
+ cleaned up when the context exits, even if an exception occurs.
527
+
528
+ Args:
529
+ flag_name: Name of the feature flag to override
530
+ enabled: The override value (True to enable, False to disable)
531
+ client_id: Optional client ID for the override (defaults to current_client_id)
532
+ registry: Optional registry to use (defaults to global registry)
533
+
534
+ Yields:
535
+ None
536
+
537
+ Example:
538
+ >>> registry = FeatureFlagRegistry()
539
+ >>> registry.register(FeatureFlag(
540
+ ... name="new_feature",
541
+ ... description="Test",
542
+ ... state=FlagState.BETA,
543
+ ... default_enabled=False,
544
+ ... ))
545
+ >>> # Flag is disabled by default
546
+ >>> registry.is_enabled("new_feature")
547
+ False
548
+ >>> # Override to enable temporarily
549
+ >>> with flag_override("new_feature", True, registry=registry):
550
+ ... registry.is_enabled("new_feature")
551
+ True
552
+ >>> # Back to original state after context
553
+ >>> registry.is_enabled("new_feature")
554
+ False
555
+
556
+ >>> # Use in tests to force flag states
557
+ >>> with flag_override("new_feature", True):
558
+ ... result = my_flagged_function() # Runs with flag enabled
559
+ """
560
+ reg = registry or get_registry()
561
+ cid = client_id or current_client_id.get()
562
+
563
+ # Check if there's an existing override we need to restore
564
+ had_previous_override = False
565
+ previous_override_value = False
566
+
567
+ if cid in reg._overrides and flag_name in reg._overrides[cid]:
568
+ had_previous_override = True
569
+ previous_override_value = reg._overrides[cid][flag_name]
570
+
571
+ try:
572
+ reg.set_override(cid, flag_name, enabled)
573
+ yield
574
+ finally:
575
+ # Restore previous state
576
+ if had_previous_override:
577
+ reg.set_override(cid, flag_name, previous_override_value)
578
+ else:
579
+ reg.clear_override(cid, flag_name)
580
+
581
+
582
+ # Export all public symbols
583
+ __all__ = [
584
+ "FlagState",
585
+ "FeatureFlag",
586
+ "FeatureFlagRegistry",
587
+ "current_client_id",
588
+ "get_registry",
589
+ "get_flag_service",
590
+ "feature_flag",
591
+ "flag_override",
592
+ ]