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.
- hexastack_core/__init__.py +10 -0
- hexastack_core/adapters/__init__.py +43 -0
- hexastack_core/adapters/ai/__init__.py +11 -0
- hexastack_core/adapters/ai/in_memory.py +196 -0
- hexastack_core/adapters/cache/__init__.py +9 -0
- hexastack_core/adapters/cache/in_memory.py +90 -0
- hexastack_core/adapters/clock/__init__.py +9 -0
- hexastack_core/adapters/clock/in_memory.py +80 -0
- hexastack_core/adapters/feature_flags/__init__.py +7 -0
- hexastack_core/adapters/feature_flags/config.py +235 -0
- hexastack_core/adapters/feature_flags/in_memory.py +150 -0
- hexastack_core/adapters/logging/__init__.py +11 -0
- hexastack_core/adapters/logging/in_memory.py +161 -0
- hexastack_core/adapters/logging/standard.py +106 -0
- hexastack_core/adapters/repository/__init__.py +9 -0
- hexastack_core/adapters/repository/in_memory.py +142 -0
- hexastack_core/adapters/unit_of_work/__init__.py +9 -0
- hexastack_core/adapters/unit_of_work/in_memory.py +76 -0
- hexastack_core/domain/__init__.py +35 -0
- hexastack_core/domain/command.py +9 -0
- hexastack_core/domain/event.py +9 -0
- hexastack_core/domain/exceptions.py +74 -0
- hexastack_core/domain/feature_flags.py +81 -0
- hexastack_core/domain/generic.py +12 -0
- hexastack_core/domain/query.py +9 -0
- hexastack_core/domain/result.py +48 -0
- hexastack_core/infra/__init__.py +53 -0
- hexastack_core/infra/autodiscovery.py +80 -0
- hexastack_core/infra/bootstrap.py +223 -0
- hexastack_core/infra/config.py +66 -0
- hexastack_core/infra/decorators.py +78 -0
- hexastack_core/infra/registries/__init__.py +25 -0
- hexastack_core/infra/registries/config.py +79 -0
- hexastack_core/infra/registries/exception.py +24 -0
- hexastack_core/infra/registries/generic.py +239 -0
- hexastack_core/ports/__init__.py +35 -0
- hexastack_core/ports/ai.py +93 -0
- hexastack_core/ports/bootstrap.py +48 -0
- hexastack_core/ports/cache.py +101 -0
- hexastack_core/ports/clock.py +35 -0
- hexastack_core/ports/feature_flags.py +149 -0
- hexastack_core/ports/logging.py +54 -0
- hexastack_core/ports/presenter.py +22 -0
- hexastack_core/ports/repository.py +53 -0
- hexastack_core/ports/unit_of_work.py +93 -0
- hexastack_core/py.typed +0 -0
- hexastack_core/testing/__init__.py +43 -0
- hexastack_core/testing/architecture.py +88 -0
- hexastack_core/testing/flags.py +58 -0
- hexastack_core/testing/harness.py +82 -0
- hexastack_core/testing/hypothesis.py +89 -0
- hexastack_core/testing/isolation.py +43 -0
- hexastack_core/testing/synthetic.py +130 -0
- hexastack_core/utils/__init__.py +21 -0
- hexastack_core/utils/context.py +110 -0
- hexastack_core/utils/inspection.py +98 -0
- hexastack_core-0.0.0.dist-info/METADATA +162 -0
- hexastack_core-0.0.0.dist-info/RECORD +59 -0
- hexastack_core-0.0.0.dist-info/WHEEL +4 -0
|
@@ -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,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,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,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,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
|
+
]
|