hexastack-core 0.0.0__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 (59) hide show
  1. hexastack_core/__init__.py +10 -0
  2. hexastack_core/adapters/__init__.py +43 -0
  3. hexastack_core/adapters/ai/__init__.py +11 -0
  4. hexastack_core/adapters/ai/in_memory.py +196 -0
  5. hexastack_core/adapters/cache/__init__.py +9 -0
  6. hexastack_core/adapters/cache/in_memory.py +90 -0
  7. hexastack_core/adapters/clock/__init__.py +9 -0
  8. hexastack_core/adapters/clock/in_memory.py +80 -0
  9. hexastack_core/adapters/feature_flags/__init__.py +7 -0
  10. hexastack_core/adapters/feature_flags/config.py +235 -0
  11. hexastack_core/adapters/feature_flags/in_memory.py +150 -0
  12. hexastack_core/adapters/logging/__init__.py +11 -0
  13. hexastack_core/adapters/logging/in_memory.py +161 -0
  14. hexastack_core/adapters/logging/standard.py +106 -0
  15. hexastack_core/adapters/repository/__init__.py +9 -0
  16. hexastack_core/adapters/repository/in_memory.py +142 -0
  17. hexastack_core/adapters/unit_of_work/__init__.py +9 -0
  18. hexastack_core/adapters/unit_of_work/in_memory.py +76 -0
  19. hexastack_core/domain/__init__.py +35 -0
  20. hexastack_core/domain/command.py +9 -0
  21. hexastack_core/domain/event.py +9 -0
  22. hexastack_core/domain/exceptions.py +74 -0
  23. hexastack_core/domain/feature_flags.py +81 -0
  24. hexastack_core/domain/generic.py +12 -0
  25. hexastack_core/domain/query.py +9 -0
  26. hexastack_core/domain/result.py +48 -0
  27. hexastack_core/infra/__init__.py +53 -0
  28. hexastack_core/infra/autodiscovery.py +80 -0
  29. hexastack_core/infra/bootstrap.py +223 -0
  30. hexastack_core/infra/config.py +66 -0
  31. hexastack_core/infra/decorators.py +78 -0
  32. hexastack_core/infra/registries/__init__.py +25 -0
  33. hexastack_core/infra/registries/config.py +79 -0
  34. hexastack_core/infra/registries/exception.py +24 -0
  35. hexastack_core/infra/registries/generic.py +239 -0
  36. hexastack_core/ports/__init__.py +35 -0
  37. hexastack_core/ports/ai.py +93 -0
  38. hexastack_core/ports/bootstrap.py +48 -0
  39. hexastack_core/ports/cache.py +101 -0
  40. hexastack_core/ports/clock.py +35 -0
  41. hexastack_core/ports/feature_flags.py +149 -0
  42. hexastack_core/ports/logging.py +54 -0
  43. hexastack_core/ports/presenter.py +22 -0
  44. hexastack_core/ports/repository.py +53 -0
  45. hexastack_core/ports/unit_of_work.py +93 -0
  46. hexastack_core/py.typed +0 -0
  47. hexastack_core/testing/__init__.py +43 -0
  48. hexastack_core/testing/architecture.py +88 -0
  49. hexastack_core/testing/flags.py +58 -0
  50. hexastack_core/testing/harness.py +82 -0
  51. hexastack_core/testing/hypothesis.py +89 -0
  52. hexastack_core/testing/isolation.py +43 -0
  53. hexastack_core/testing/synthetic.py +130 -0
  54. hexastack_core/utils/__init__.py +21 -0
  55. hexastack_core/utils/context.py +110 -0
  56. hexastack_core/utils/inspection.py +98 -0
  57. hexastack_core-0.0.0.dist-info/METADATA +162 -0
  58. hexastack_core-0.0.0.dist-info/RECORD +59 -0
  59. hexastack_core-0.0.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,10 @@
1
+ from hexastack_core import adapters, domain, infra, ports, testing, utils
2
+
3
+ __all__ = [
4
+ "adapters",
5
+ "domain",
6
+ "infra",
7
+ "ports",
8
+ "testing",
9
+ "utils",
10
+ ]
@@ -0,0 +1,43 @@
1
+ from hexastack_core.adapters.ai import (
2
+ InMemoryLlmProvider,
3
+ InMemoryVectorStore,
4
+ LlmCallRecord,
5
+ )
6
+ from hexastack_core.adapters.cache import (
7
+ AsyncInMemoryCache,
8
+ InMemoryCache,
9
+ )
10
+ from hexastack_core.adapters.clock import (
11
+ FrozenClock,
12
+ InMemoryClock,
13
+ )
14
+ from hexastack_core.adapters.logging import (
15
+ InMemoryLogger,
16
+ LogEntry,
17
+ StandardLogger,
18
+ )
19
+ from hexastack_core.adapters.repository import (
20
+ AsyncInMemoryRepository,
21
+ InMemoryRepository,
22
+ )
23
+ from hexastack_core.adapters.unit_of_work import (
24
+ AsyncInMemoryUnitOfWork,
25
+ InMemoryUnitOfWork,
26
+ )
27
+
28
+ __all__ = [
29
+ "AsyncInMemoryCache",
30
+ "AsyncInMemoryRepository",
31
+ "AsyncInMemoryUnitOfWork",
32
+ "FrozenClock",
33
+ "InMemoryCache",
34
+ "InMemoryClock",
35
+ "InMemoryLlmProvider",
36
+ "InMemoryLogger",
37
+ "InMemoryRepository",
38
+ "InMemoryUnitOfWork",
39
+ "InMemoryVectorStore",
40
+ "LlmCallRecord",
41
+ "LogEntry",
42
+ "StandardLogger",
43
+ ]
@@ -0,0 +1,11 @@
1
+ from hexastack_core.adapters.ai.in_memory import (
2
+ InMemoryLlmProvider,
3
+ InMemoryVectorStore,
4
+ LlmCallRecord,
5
+ )
6
+
7
+ __all__ = [
8
+ "InMemoryLlmProvider",
9
+ "InMemoryVectorStore",
10
+ "LlmCallRecord",
11
+ ]
@@ -0,0 +1,196 @@
1
+ import math
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from hexastack_core.ports.ai import LlmProviderPort, Metadata, VectorStorePort
8
+
9
+
10
+ @dataclass
11
+ class LlmCallRecord:
12
+ """Record of an invocation made to InMemoryLlmProvider."""
13
+
14
+ prompt: str
15
+ system_prompt: str | None = None
16
+ response_schema: type[BaseModel] | None = None
17
+ response: Any = None
18
+
19
+
20
+ class InMemoryLlmProvider(LlmProviderPort):
21
+ """In-memory LLM provider adapter for unit testing and local development.
22
+
23
+ Notes/Architectural Intent:
24
+ Implements LlmProviderPort, allowing tests to mock text and structured responses,
25
+ inspect invocation history, and simulate errors without API keys or external services.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ default_text: str = "Mock LLM text response",
31
+ default_structured: BaseModel | None = None,
32
+ ) -> None:
33
+ """Initialize InMemoryLlmProvider with optional default responses."""
34
+ self._default_text = default_text
35
+ self._default_structured = default_structured
36
+ self._text_responses: dict[str, str] = {}
37
+ self._structured_responses: dict[type[BaseModel], BaseModel] = {}
38
+ self._simulated_error: Exception | None = None
39
+ self.history: list[LlmCallRecord] = []
40
+
41
+ def _synthesize_mock_instance(self, response_schema: type[BaseModel]) -> BaseModel:
42
+ """Synthesize mock field values for a required Pydantic model schema."""
43
+ try:
44
+ return response_schema()
45
+ except Exception: # noqa: BLE001
46
+ init_data = {}
47
+ for name, field_info in response_schema.model_fields.items():
48
+ if field_info.annotation is str:
49
+ init_data[name] = f"Mock {name}"
50
+ elif field_info.annotation in (int, float):
51
+ init_data[name] = 1
52
+ elif field_info.annotation is bool:
53
+ init_data[name] = True
54
+ else:
55
+ init_data[name] = None
56
+ return response_schema.model_validate(init_data)
57
+
58
+ def add_structured_response(
59
+ self, schema_cls: type[BaseModel], response: BaseModel
60
+ ) -> None:
61
+ """Map a response schema class to a specific model response instance."""
62
+ self._structured_responses[schema_cls] = response
63
+
64
+ def add_text_response(self, prompt_substring: str, response: str) -> None:
65
+ """Map a prompt substring to a specific text response."""
66
+ self._text_responses[prompt_substring] = response
67
+
68
+ def clear(self) -> None:
69
+ """Reset all mock history and mappings."""
70
+ self.history.clear()
71
+ self._text_responses.clear()
72
+ self._structured_responses.clear()
73
+ self._simulated_error = None
74
+
75
+ def generate_structured(
76
+ self,
77
+ prompt: str,
78
+ response_schema: type[BaseModel],
79
+ system_prompt: str | None = None,
80
+ ) -> BaseModel:
81
+ """Generate a structured Pydantic model response."""
82
+ if self._simulated_error is not None:
83
+ raise self._simulated_error
84
+
85
+ res: BaseModel
86
+ if response_schema in self._structured_responses:
87
+ res = self._structured_responses[response_schema]
88
+ elif self._default_structured is not None and isinstance(
89
+ self._default_structured, response_schema
90
+ ):
91
+ res = self._default_structured
92
+ else:
93
+ res = self._synthesize_mock_instance(response_schema)
94
+
95
+ record = LlmCallRecord(
96
+ prompt=prompt,
97
+ response_schema=response_schema,
98
+ response=res,
99
+ )
100
+ self.history.append(record)
101
+ return res
102
+
103
+ def generate_text(self, prompt: str, system_prompt: str | None = None) -> str:
104
+ """Generate text from prompt or match configured mock responses."""
105
+ if self._simulated_error is not None:
106
+ raise self._simulated_error
107
+
108
+ # Check explicit prompt substring mappings
109
+ response_text = self._default_text
110
+ for sub, resp in self._text_responses.items():
111
+ if sub in prompt:
112
+ response_text = resp
113
+ break
114
+
115
+ record = LlmCallRecord(
116
+ prompt=prompt,
117
+ system_prompt=system_prompt,
118
+ response=response_text,
119
+ )
120
+ self.history.append(record)
121
+ return response_text
122
+
123
+ def set_default_structured(self, model: BaseModel) -> None:
124
+ """Set the fallback structured model response."""
125
+ self._default_structured = model
126
+
127
+ def set_default_text(self, text: str) -> None:
128
+ """Set the fallback text response."""
129
+ self._default_text = text
130
+
131
+ def set_error(self, error: Exception | None) -> None:
132
+ """Set a simulated exception to raise on subsequent generation calls."""
133
+ self._simulated_error = error
134
+
135
+
136
+ class InMemoryVectorStore(VectorStorePort):
137
+ """In-memory vector store adapter computing cosine similarity.
138
+
139
+ Notes/Architectural Intent:
140
+ Implements VectorStorePort with dictionary-backed in-memory vector storage
141
+ and exact cosine similarity search for local development and testing.
142
+ """
143
+
144
+ def __init__(self) -> None:
145
+ """Initialize empty in-memory vector store."""
146
+ self._vectors: dict[str, tuple[list[float], Metadata]] = {}
147
+
148
+ def clear(self) -> None:
149
+ """Clear all stored vectors."""
150
+ self._vectors.clear()
151
+
152
+ def delete(self, vector_id: str) -> bool:
153
+ """Delete a vector by ID."""
154
+ return self._vectors.pop(vector_id, None) is not None
155
+
156
+ def get(self, vector_id: str) -> tuple[list[float], Metadata] | None:
157
+ """Retrieve stored embedding and metadata for a vector ID."""
158
+ return self._vectors.get(vector_id)
159
+
160
+ def search(self, query_embedding: list[float], limit: int = 5) -> list[Metadata]:
161
+ """Search for top similar vectors using cosine similarity."""
162
+ if not self._vectors:
163
+ return []
164
+
165
+ def cosine_similarity(v1: list[float], v2: list[float]) -> float:
166
+ dot = sum(a * b for a, b in zip(v1, v2, strict=False))
167
+ norm1 = math.sqrt(sum(a * a for a in v1))
168
+ norm2 = math.sqrt(sum(b * b for b in v2))
169
+ if norm1 == 0.0 or norm2 == 0.0:
170
+ return 0.0
171
+ return dot / (norm1 * norm2)
172
+
173
+ scored = []
174
+ for vid, (emb, meta) in self._vectors.items():
175
+ sim = cosine_similarity(query_embedding, emb)
176
+ meta_with_id = dict(meta)
177
+ meta_with_id["_id"] = vid
178
+ meta_with_id["_score"] = sim
179
+ scored.append((sim, meta_with_id))
180
+
181
+ # Sort descending by similarity score
182
+ scored.sort(key=lambda item: item[0], reverse=True)
183
+ return [meta for _, meta in scored[:limit]]
184
+
185
+ def upsert(
186
+ self, vector_id: str, embedding: list[float], metadata: Metadata
187
+ ) -> None:
188
+ """Store or update vector embedding and metadata."""
189
+ self._vectors[vector_id] = (list(embedding), dict(metadata))
190
+
191
+
192
+ __all__ = [
193
+ "InMemoryLlmProvider",
194
+ "InMemoryVectorStore",
195
+ "LlmCallRecord",
196
+ ]
@@ -0,0 +1,9 @@
1
+ from hexastack_core.adapters.cache.in_memory import (
2
+ AsyncInMemoryCache,
3
+ InMemoryCache,
4
+ )
5
+
6
+ __all__ = [
7
+ "AsyncInMemoryCache",
8
+ "InMemoryCache",
9
+ ]
@@ -0,0 +1,90 @@
1
+ import time
2
+ from typing import Any
3
+
4
+ from hexastack_core.ports.cache import AsyncCachePort, CachePort
5
+ from hexastack_core.ports.clock import ClockPort
6
+
7
+
8
+ class InMemoryCache(CachePort):
9
+ """In-memory dictionary cache adapter with TTL expiration support.
10
+
11
+ Notes/Architectural Intent:
12
+ Implements CachePort for local development, caching middleware tests,
13
+ and unit testing without requiring Redis. Accepts an optional ClockPort
14
+ for deterministic time testing.
15
+ """
16
+
17
+ def __init__(self, clock: ClockPort | None = None) -> None:
18
+ """Initialize empty in-memory cache.
19
+
20
+ Args:
21
+ clock: Optional ClockPort instance for time measurement.
22
+ """
23
+ self._store: dict[str, tuple[Any, float | None]] = {}
24
+ self._clock = clock
25
+
26
+ def _now(self) -> float:
27
+ return self._clock.timestamp() if self._clock else time.time()
28
+
29
+ def clear(self) -> None:
30
+ """Clear all keys from the cache."""
31
+ self._store.clear()
32
+
33
+ def delete(self, key: str) -> bool:
34
+ """Delete a key from the cache."""
35
+ return self._store.pop(key, None) is not None
36
+
37
+ def get(self, key: str, default: Any = None) -> Any:
38
+ """Retrieve a cached value if present and not expired."""
39
+ if key not in self._store:
40
+ return default
41
+ val, expiry = self._store[key]
42
+ if expiry is not None and self._now() > expiry:
43
+ del self._store[key]
44
+ return default
45
+ return val
46
+
47
+ def has(self, key: str) -> bool:
48
+ """Check if a key exists and has not expired."""
49
+ if key not in self._store:
50
+ return False
51
+ _, expiry = self._store[key]
52
+ if expiry is not None and self._now() > expiry:
53
+ del self._store[key]
54
+ return False
55
+ return True
56
+
57
+ def set(self, key: str, value: Any, ttl_seconds: float | None = None) -> None:
58
+ """Store a cached value with optional TTL expiration."""
59
+ expiry = self._now() + ttl_seconds if ttl_seconds is not None else None
60
+ self._store[key] = (value, expiry)
61
+
62
+
63
+ class AsyncInMemoryCache(AsyncCachePort):
64
+ """Asynchronous in-memory cache adapter."""
65
+
66
+ def __init__(self, clock: ClockPort | None = None) -> None:
67
+ self._sync_cache = InMemoryCache(clock=clock)
68
+
69
+ async def clear_async(self) -> None:
70
+ self._sync_cache.clear()
71
+
72
+ async def delete_async(self, key: str) -> bool:
73
+ return self._sync_cache.delete(key)
74
+
75
+ async def get_async(self, key: str, default: Any = None) -> Any:
76
+ return self._sync_cache.get(key, default)
77
+
78
+ async def has_async(self, key: str) -> bool:
79
+ return self._sync_cache.has(key)
80
+
81
+ async def set_async(
82
+ self, key: str, value: Any, ttl_seconds: float | None = None
83
+ ) -> None:
84
+ self._sync_cache.set(key, value, ttl_seconds)
85
+
86
+
87
+ __all__ = [
88
+ "AsyncInMemoryCache",
89
+ "InMemoryCache",
90
+ ]
@@ -0,0 +1,9 @@
1
+ from hexastack_core.adapters.clock.in_memory import (
2
+ FrozenClock,
3
+ InMemoryClock,
4
+ )
5
+
6
+ __all__ = [
7
+ "FrozenClock",
8
+ "InMemoryClock",
9
+ ]
@@ -0,0 +1,80 @@
1
+ from datetime import UTC, datetime, timedelta
2
+
3
+ from hexastack_core.ports.clock import ClockPort
4
+
5
+
6
+ class InMemoryClock(ClockPort):
7
+ """Real-time clock adapter reading system UTC time.
8
+
9
+ Notes/Architectural Intent:
10
+ Default production/local adapter providing real-time clock access.
11
+ """
12
+
13
+ def now_utc(self) -> datetime:
14
+ """Return the current UTC datetime."""
15
+ return datetime.now(UTC)
16
+
17
+ def timestamp(self) -> float:
18
+ """Return the current POSIX timestamp."""
19
+ return datetime.now(UTC).timestamp()
20
+
21
+
22
+ class FrozenClock(ClockPort):
23
+ """Deterministic simulated clock supporting time freezing and manual advancement.
24
+
25
+ Notes/Architectural Intent:
26
+ Allows test cases to freeze time at a fixed point and advance it explicitly
27
+ (e.g., clock.advance(minutes=10)) to verify TTL expiration and timeout logic.
28
+ """
29
+
30
+ def __init__(self, initial_time: datetime | None = None) -> None:
31
+ """Initialize FrozenClock.
32
+
33
+ Args:
34
+ initial_time: Optional datetime to start at. Defaults to current UTC time.
35
+ """
36
+ self._current_time: datetime = (
37
+ initial_time.astimezone(UTC)
38
+ if initial_time is not None
39
+ else datetime.now(UTC)
40
+ )
41
+
42
+ def advance(
43
+ self,
44
+ seconds: float = 0,
45
+ minutes: float = 0,
46
+ hours: float = 0,
47
+ days: float = 0,
48
+ ) -> datetime:
49
+ """Advance the frozen clock forward by a specified duration.
50
+
51
+ Args:
52
+ seconds: Seconds to advance.
53
+ minutes: Minutes to advance.
54
+ hours: Hours to advance.
55
+ days: Days to advance.
56
+
57
+ Returns:
58
+ The new current UTC datetime after advancement.
59
+ """
60
+ delta = timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
61
+ self._current_time += delta
62
+ return self._current_time
63
+
64
+ def now_utc(self) -> datetime:
65
+ """Return the frozen UTC datetime."""
66
+ return self._current_time
67
+
68
+ def set_time(self, new_time: datetime) -> None:
69
+ """Set the frozen clock to an explicit datetime."""
70
+ self._current_time = new_time.astimezone(UTC)
71
+
72
+ def timestamp(self) -> float:
73
+ """Return the frozen POSIX timestamp."""
74
+ return self._current_time.timestamp()
75
+
76
+
77
+ __all__ = [
78
+ "FrozenClock",
79
+ "InMemoryClock",
80
+ ]
@@ -0,0 +1,7 @@
1
+ from hexastack_core.adapters.feature_flags.config import ConfigFeatureFlagAdapter
2
+ from hexastack_core.adapters.feature_flags.in_memory import InMemoryFeatureFlagAdapter
3
+
4
+ __all__ = [
5
+ "ConfigFeatureFlagAdapter",
6
+ "InMemoryFeatureFlagAdapter",
7
+ ]
@@ -0,0 +1,235 @@
1
+ import importlib.util
2
+ from collections.abc import Mapping
3
+ from typing import Any
4
+
5
+ from hexastack_core.domain.feature_flags import (
6
+ EvaluationContext,
7
+ FlagEvaluationDetails,
8
+ FlagEvaluationReason,
9
+ )
10
+ from hexastack_core.infra.config import HexastackConfig
11
+ from hexastack_core.ports.feature_flags import FeatureFlagPort
12
+
13
+
14
+ class ConfigFeatureFlagAdapter(FeatureFlagPort):
15
+ """Feature flag adapter backed by HexastackConfig and static package inspection.
16
+
17
+ Notes/Architectural Intent:
18
+ Evaluates flags against loaded application configuration (`HexastackConfig`),
19
+ environment overrides, and static optional package installation checks
20
+ (via `importlib.util.find_spec`).
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ config: HexastackConfig | None = None,
26
+ overrides: Mapping[str, Any] | None = None,
27
+ ) -> None:
28
+ """Initialize adapter with optional configuration and static overrides.
29
+
30
+ Args:
31
+ config: Optional HexastackConfig instance.
32
+ overrides: Optional runtime dictionary overrides.
33
+ """
34
+ self._config = config
35
+ self._overrides: dict[str, Any] = dict(overrides or {})
36
+
37
+ def get_all_flags(self) -> dict[str, Any]:
38
+ """Return a dictionary of all active flags and overrides for UI introspection.
39
+
40
+ Returns:
41
+ Dictionary mapping flag keys to their configured values.
42
+ """
43
+ flags: dict[str, Any] = dict(self._overrides)
44
+ if self._config is not None and hasattr(self._config, "_core"):
45
+ for attr in dir(self._config._core):
46
+ if not attr.startswith("_"):
47
+ val = getattr(self._config._core, attr)
48
+ if isinstance(val, (bool, str, int, float)):
49
+ flags[f"core.{attr}"] = val
50
+ return flags
51
+
52
+ def _lookup_config_path(self, path: str) -> Any:
53
+ """Lookup nested attribute or dictionary key in config."""
54
+ if self._config is None:
55
+ return None
56
+
57
+ # Check in _core first if top-level attribute
58
+ if hasattr(self._config, "_core"):
59
+ if hasattr(self._config._core, path):
60
+ return getattr(self._config._core, path)
61
+ if hasattr(self._config, "_sections"):
62
+ parts = path.split(".", 1)
63
+ if len(parts) == 2 and parts[0] in self._config._sections:
64
+ section = self._config._sections[parts[0]]
65
+ return getattr(section, parts[1], None)
66
+
67
+ parts = path.split(".")
68
+ current: Any = self._config
69
+ for part in parts:
70
+ if current is None:
71
+ return None
72
+ if isinstance(current, dict):
73
+ current = current.get(part)
74
+ elif hasattr(current, part):
75
+ current = getattr(current, part)
76
+ else:
77
+ return None
78
+ return current
79
+
80
+ def get_boolean_details(
81
+ self,
82
+ flag_key: str,
83
+ default: bool = False,
84
+ context: EvaluationContext | None = None,
85
+ ) -> FlagEvaluationDetails[bool]:
86
+ """Evaluate boolean flag with resolution reason."""
87
+ if flag_key in self._overrides:
88
+ val = self._overrides[flag_key]
89
+ if isinstance(val, bool):
90
+ return FlagEvaluationDetails(
91
+ flag_key=flag_key,
92
+ value=val,
93
+ reason=FlagEvaluationReason.STATIC,
94
+ )
95
+
96
+ if flag_key.startswith("features.lib."):
97
+ lib_name = flag_key.removeprefix("features.lib.")
98
+ found = importlib.util.find_spec(lib_name) is not None
99
+ return FlagEvaluationDetails(
100
+ flag_key=flag_key,
101
+ value=found,
102
+ reason=FlagEvaluationReason.STATIC,
103
+ )
104
+
105
+ if self._config is not None:
106
+ val = self._lookup_config_path(flag_key)
107
+ if isinstance(val, bool):
108
+ return FlagEvaluationDetails(
109
+ flag_key=flag_key,
110
+ value=val,
111
+ reason=FlagEvaluationReason.STATIC,
112
+ )
113
+
114
+ return FlagEvaluationDetails(
115
+ flag_key=flag_key,
116
+ value=default,
117
+ reason=FlagEvaluationReason.DEFAULT,
118
+ )
119
+
120
+ def get_boolean_value(
121
+ self,
122
+ flag_key: str,
123
+ default: bool = False,
124
+ context: EvaluationContext | None = None,
125
+ ) -> bool:
126
+ """Evaluate a boolean feature flag."""
127
+ return self.is_enabled(flag_key, default=default, context=context)
128
+
129
+ def get_float_value(
130
+ self,
131
+ flag_key: str,
132
+ default: float,
133
+ context: EvaluationContext | None = None,
134
+ ) -> float:
135
+ """Evaluate a floating-point feature flag."""
136
+ if flag_key in self._overrides:
137
+ val = self._overrides[flag_key]
138
+ if isinstance(val, (int, float)) and not isinstance(val, bool):
139
+ return float(val)
140
+
141
+ if self._config is not None:
142
+ val = self._lookup_config_path(flag_key)
143
+ if isinstance(val, (int, float)) and not isinstance(val, bool):
144
+ return float(val)
145
+
146
+ return default
147
+
148
+ def get_integer_value(
149
+ self,
150
+ flag_key: str,
151
+ default: int,
152
+ context: EvaluationContext | None = None,
153
+ ) -> int:
154
+ """Evaluate an integer feature flag."""
155
+ if flag_key in self._overrides:
156
+ val = self._overrides[flag_key]
157
+ if isinstance(val, int) and not isinstance(val, bool):
158
+ return val
159
+
160
+ if self._config is not None:
161
+ val = self._lookup_config_path(flag_key)
162
+ if isinstance(val, int) and not isinstance(val, bool):
163
+ return val
164
+
165
+ return default
166
+
167
+ def get_object_value(
168
+ self,
169
+ flag_key: str,
170
+ default: Mapping[str, Any],
171
+ context: EvaluationContext | None = None,
172
+ ) -> Mapping[str, Any]:
173
+ """Evaluate a structured JSON/dictionary feature flag."""
174
+ if flag_key in self._overrides:
175
+ val = self._overrides[flag_key]
176
+ if isinstance(val, Mapping):
177
+ return val
178
+
179
+ if self._config is not None:
180
+ val = self._lookup_config_path(flag_key)
181
+ if isinstance(val, Mapping):
182
+ return val
183
+
184
+ return default
185
+
186
+ def get_string_value(
187
+ self,
188
+ flag_key: str,
189
+ default: str,
190
+ context: EvaluationContext | None = None,
191
+ ) -> str:
192
+ """Evaluate a string feature flag."""
193
+ if flag_key in self._overrides:
194
+ val = self._overrides[flag_key]
195
+ if isinstance(val, str):
196
+ return val
197
+
198
+ if self._config is not None:
199
+ val = self._lookup_config_path(flag_key)
200
+ if isinstance(val, str):
201
+ return val
202
+
203
+ return default
204
+
205
+ def is_enabled(
206
+ self,
207
+ flag_key: str,
208
+ default: bool = False,
209
+ context: EvaluationContext | None = None,
210
+ ) -> bool:
211
+ """Evaluate a boolean feature flag against overrides, config, and package checks."""
212
+ # 1. Overrides take precedence
213
+ if flag_key in self._overrides:
214
+ val = self._overrides[flag_key]
215
+ if isinstance(val, bool):
216
+ return val
217
+
218
+ # 2. Check for dynamic library presence flags (e.g., 'features.lib.<pkg>')
219
+ if flag_key.startswith("features.lib."):
220
+ lib_name = flag_key.removeprefix("features.lib.")
221
+ return importlib.util.find_spec(lib_name) is not None
222
+
223
+ # 3. Check loaded configuration dict if available
224
+ if self._config is not None:
225
+ # Match top-level or dotted section attributes
226
+ val = self._lookup_config_path(flag_key)
227
+ if isinstance(val, bool):
228
+ return val
229
+
230
+ return default
231
+
232
+
233
+ __all__ = [
234
+ "ConfigFeatureFlagAdapter",
235
+ ]