hindsight-api 0.1.11__py3-none-any.whl → 0.1.13__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.
- hindsight_api/__init__.py +2 -0
- hindsight_api/alembic/env.py +24 -1
- hindsight_api/alembic/versions/d9f6a3b4c5e2_rename_bank_to_interactions.py +14 -4
- hindsight_api/alembic/versions/e0a1b2c3d4e5_disposition_to_3_traits.py +54 -13
- hindsight_api/alembic/versions/rename_personality_to_disposition.py +18 -7
- hindsight_api/api/http.py +253 -230
- hindsight_api/api/mcp.py +14 -3
- hindsight_api/config.py +11 -0
- hindsight_api/daemon.py +204 -0
- hindsight_api/engine/__init__.py +12 -1
- hindsight_api/engine/entity_resolver.py +38 -37
- hindsight_api/engine/interface.py +592 -0
- hindsight_api/engine/llm_wrapper.py +176 -6
- hindsight_api/engine/memory_engine.py +1092 -293
- hindsight_api/engine/retain/bank_utils.py +13 -12
- hindsight_api/engine/retain/chunk_storage.py +3 -2
- hindsight_api/engine/retain/fact_storage.py +10 -7
- hindsight_api/engine/retain/link_utils.py +17 -16
- hindsight_api/engine/retain/observation_regeneration.py +17 -16
- hindsight_api/engine/retain/orchestrator.py +2 -3
- hindsight_api/engine/retain/types.py +25 -8
- hindsight_api/engine/search/graph_retrieval.py +6 -5
- hindsight_api/engine/search/mpfp_retrieval.py +8 -7
- hindsight_api/engine/search/reranking.py +17 -0
- hindsight_api/engine/search/retrieval.py +12 -11
- hindsight_api/engine/search/think_utils.py +1 -1
- hindsight_api/engine/search/tracer.py +1 -1
- hindsight_api/engine/task_backend.py +32 -0
- hindsight_api/extensions/__init__.py +66 -0
- hindsight_api/extensions/base.py +81 -0
- hindsight_api/extensions/builtin/__init__.py +18 -0
- hindsight_api/extensions/builtin/tenant.py +33 -0
- hindsight_api/extensions/context.py +110 -0
- hindsight_api/extensions/http.py +89 -0
- hindsight_api/extensions/loader.py +125 -0
- hindsight_api/extensions/operation_validator.py +325 -0
- hindsight_api/extensions/tenant.py +63 -0
- hindsight_api/main.py +97 -17
- hindsight_api/mcp_local.py +7 -1
- hindsight_api/migrations.py +54 -10
- hindsight_api/models.py +15 -0
- hindsight_api/pg0.py +1 -1
- {hindsight_api-0.1.11.dist-info → hindsight_api-0.1.13.dist-info}/METADATA +1 -1
- hindsight_api-0.1.13.dist-info/RECORD +75 -0
- hindsight_api-0.1.11.dist-info/RECORD +0 -64
- {hindsight_api-0.1.11.dist-info → hindsight_api-0.1.13.dist-info}/WHEEL +0 -0
- {hindsight_api-0.1.11.dist-info → hindsight_api-0.1.13.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hindsight Extensions System.
|
|
3
|
+
|
|
4
|
+
Extensions allow customizing and extending Hindsight behavior without modifying core code.
|
|
5
|
+
Extensions are loaded via environment variables pointing to implementation classes.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator
|
|
9
|
+
HINDSIGHT_API_OPERATION_VALIDATOR_MAX_RETRIES=3
|
|
10
|
+
|
|
11
|
+
HINDSIGHT_API_HTTP_EXTENSION=mypackage.http:MyHttpExtension
|
|
12
|
+
HINDSIGHT_API_HTTP_SOME_CONFIG=value
|
|
13
|
+
|
|
14
|
+
Extensions receive an ExtensionContext that provides a controlled API for interacting
|
|
15
|
+
with the system (e.g., running migrations for tenant schemas).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from hindsight_api.extensions.base import Extension
|
|
19
|
+
from hindsight_api.extensions.builtin import ApiKeyTenantExtension
|
|
20
|
+
from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext
|
|
21
|
+
from hindsight_api.extensions.http import HttpExtension
|
|
22
|
+
from hindsight_api.extensions.loader import load_extension
|
|
23
|
+
from hindsight_api.extensions.operation_validator import (
|
|
24
|
+
OperationValidationError,
|
|
25
|
+
OperationValidatorExtension,
|
|
26
|
+
RecallContext,
|
|
27
|
+
RecallResult,
|
|
28
|
+
ReflectContext,
|
|
29
|
+
ReflectResultContext,
|
|
30
|
+
RetainContext,
|
|
31
|
+
RetainResult,
|
|
32
|
+
ValidationResult,
|
|
33
|
+
)
|
|
34
|
+
from hindsight_api.extensions.tenant import (
|
|
35
|
+
AuthenticationError,
|
|
36
|
+
TenantContext,
|
|
37
|
+
TenantExtension,
|
|
38
|
+
)
|
|
39
|
+
from hindsight_api.models import RequestContext
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
# Base
|
|
43
|
+
"Extension",
|
|
44
|
+
"load_extension",
|
|
45
|
+
# Context
|
|
46
|
+
"ExtensionContext",
|
|
47
|
+
"DefaultExtensionContext",
|
|
48
|
+
# HTTP Extension
|
|
49
|
+
"HttpExtension",
|
|
50
|
+
# Operation Validator
|
|
51
|
+
"OperationValidationError",
|
|
52
|
+
"OperationValidatorExtension",
|
|
53
|
+
"RecallContext",
|
|
54
|
+
"RecallResult",
|
|
55
|
+
"ReflectContext",
|
|
56
|
+
"ReflectResultContext",
|
|
57
|
+
"RetainContext",
|
|
58
|
+
"RetainResult",
|
|
59
|
+
"ValidationResult",
|
|
60
|
+
# Tenant/Auth
|
|
61
|
+
"ApiKeyTenantExtension",
|
|
62
|
+
"AuthenticationError",
|
|
63
|
+
"RequestContext",
|
|
64
|
+
"TenantContext",
|
|
65
|
+
"TenantExtension",
|
|
66
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Base Extension class for all Hindsight extensions."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from hindsight_api.extensions.context import ExtensionContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Extension(ABC):
|
|
11
|
+
"""
|
|
12
|
+
Base class for all Hindsight extensions.
|
|
13
|
+
|
|
14
|
+
Extensions are loaded via environment variables and receive configuration
|
|
15
|
+
from prefixed environment variables.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
HINDSIGHT_API_MY_EXTENSION=mypackage.ext:MyExtension
|
|
19
|
+
HINDSIGHT_API_MY_SOME_CONFIG=value
|
|
20
|
+
|
|
21
|
+
The extension receives: {"some_config": "value"}
|
|
22
|
+
|
|
23
|
+
Extensions also receive an ExtensionContext that provides a controlled API
|
|
24
|
+
for interacting with the system (e.g., running migrations for tenant schemas).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: dict[str, str]):
|
|
28
|
+
"""
|
|
29
|
+
Initialize the extension with configuration.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
config: Dictionary of configuration values from environment variables.
|
|
33
|
+
Keys are lowercased with the prefix stripped.
|
|
34
|
+
"""
|
|
35
|
+
self.config = config
|
|
36
|
+
self._context: "ExtensionContext | None" = None
|
|
37
|
+
|
|
38
|
+
def set_context(self, context: "ExtensionContext") -> None:
|
|
39
|
+
"""
|
|
40
|
+
Set the extension context.
|
|
41
|
+
|
|
42
|
+
Called by the extension loader after instantiation.
|
|
43
|
+
Extensions should not call this directly.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
context: The ExtensionContext providing system APIs.
|
|
47
|
+
"""
|
|
48
|
+
self._context = context
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def context(self) -> "ExtensionContext":
|
|
52
|
+
"""
|
|
53
|
+
Get the extension context.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The ExtensionContext providing system APIs.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
RuntimeError: If context has not been set yet.
|
|
60
|
+
"""
|
|
61
|
+
if self._context is None:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"Extension context not set. Context is available after the extension is loaded by the system."
|
|
64
|
+
)
|
|
65
|
+
return self._context
|
|
66
|
+
|
|
67
|
+
async def on_startup(self) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Called when the application starts.
|
|
70
|
+
|
|
71
|
+
Override to perform initialization tasks like connecting to external services.
|
|
72
|
+
"""
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
async def on_shutdown(self) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Called when the application shuts down.
|
|
78
|
+
|
|
79
|
+
Override to perform cleanup tasks like closing connections.
|
|
80
|
+
"""
|
|
81
|
+
pass
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Built-in extension implementations.
|
|
3
|
+
|
|
4
|
+
These are ready-to-use implementations of the extension interfaces.
|
|
5
|
+
They can be used directly or serve as examples for custom implementations.
|
|
6
|
+
|
|
7
|
+
Available built-in extensions:
|
|
8
|
+
- ApiKeyTenantExtension: Simple API key validation with public schema
|
|
9
|
+
|
|
10
|
+
Example usage:
|
|
11
|
+
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ApiKeyTenantExtension",
|
|
18
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Built-in tenant extension implementations."""
|
|
2
|
+
|
|
3
|
+
from hindsight_api.extensions.tenant import AuthenticationError, TenantContext, TenantExtension
|
|
4
|
+
from hindsight_api.models import RequestContext
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ApiKeyTenantExtension(TenantExtension):
|
|
8
|
+
"""
|
|
9
|
+
Built-in tenant extension that validates API key against an environment variable.
|
|
10
|
+
|
|
11
|
+
This is a simple implementation that:
|
|
12
|
+
1. Validates the API key matches HINDSIGHT_API_TENANT_API_KEY
|
|
13
|
+
2. Returns 'public' as the schema for all authenticated requests
|
|
14
|
+
|
|
15
|
+
Configuration:
|
|
16
|
+
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
|
17
|
+
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
|
|
18
|
+
|
|
19
|
+
For multi-tenant setups with separate schemas per tenant, implement a custom
|
|
20
|
+
TenantExtension that looks up the schema based on the API key or token claims.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, config: dict[str, str]):
|
|
24
|
+
super().__init__(config)
|
|
25
|
+
self.expected_api_key = config.get("api_key")
|
|
26
|
+
if not self.expected_api_key:
|
|
27
|
+
raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension")
|
|
28
|
+
|
|
29
|
+
async def authenticate(self, context: RequestContext) -> TenantContext:
|
|
30
|
+
"""Validate API key and return public schema context."""
|
|
31
|
+
if context.api_key != self.expected_api_key:
|
|
32
|
+
raise AuthenticationError("Invalid API key")
|
|
33
|
+
return TenantContext(schema_name="public")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Extension context providing a controlled API for extensions to interact with the system."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from hindsight_api.engine.interface import MemoryEngineInterface
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExtensionContext(ABC):
|
|
11
|
+
"""
|
|
12
|
+
Abstract context providing a controlled API for extensions.
|
|
13
|
+
|
|
14
|
+
Extensions receive this context instead of direct access to internal
|
|
15
|
+
components like MemoryEngine or database connections. This provides:
|
|
16
|
+
- A stable API that won't break when internals change
|
|
17
|
+
- Security by limiting what extensions can access
|
|
18
|
+
- Clear documentation of what extensions can do
|
|
19
|
+
|
|
20
|
+
Built-in implementation:
|
|
21
|
+
hindsight_api.extensions.builtin.context.DefaultExtensionContext
|
|
22
|
+
|
|
23
|
+
Example usage in an extension:
|
|
24
|
+
class MyTenantExtension(TenantExtension):
|
|
25
|
+
async def on_startup(self) -> None:
|
|
26
|
+
# Run migrations for a new tenant schema
|
|
27
|
+
await self.context.run_migration("tenant_acme")
|
|
28
|
+
|
|
29
|
+
class MyHttpExtension(HttpExtension):
|
|
30
|
+
def get_router(self, memory):
|
|
31
|
+
# Use memory engine for custom endpoints
|
|
32
|
+
engine = self.context.get_memory_engine()
|
|
33
|
+
...
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def run_migration(self, schema: str) -> None:
|
|
38
|
+
"""
|
|
39
|
+
Run database migrations for a specific schema.
|
|
40
|
+
|
|
41
|
+
This creates the schema if it doesn't exist and runs all pending
|
|
42
|
+
migrations. Uses advisory locks to coordinate between distributed workers.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
schema: PostgreSQL schema name (e.g., "tenant_acme").
|
|
46
|
+
The schema will be created if it doesn't exist.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
RuntimeError: If migrations fail to complete.
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
# Provision a new tenant schema
|
|
53
|
+
await context.run_migration("tenant_acme")
|
|
54
|
+
"""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def get_memory_engine(self) -> "MemoryEngineInterface":
|
|
59
|
+
"""
|
|
60
|
+
Get the memory engine interface.
|
|
61
|
+
|
|
62
|
+
Returns the MemoryEngineInterface for performing memory operations
|
|
63
|
+
like retain, recall, reflect, and entity/document management.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
MemoryEngineInterface instance.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
engine = context.get_memory_engine()
|
|
70
|
+
result = await engine.recall_async(bank_id, query)
|
|
71
|
+
"""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class DefaultExtensionContext(ExtensionContext):
|
|
76
|
+
"""
|
|
77
|
+
Default implementation of ExtensionContext.
|
|
78
|
+
|
|
79
|
+
Uses the system's database URL and migration infrastructure.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
database_url: str,
|
|
85
|
+
memory_engine: "MemoryEngineInterface | None" = None,
|
|
86
|
+
):
|
|
87
|
+
"""
|
|
88
|
+
Initialize the context.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
database_url: SQLAlchemy database URL for migrations.
|
|
92
|
+
memory_engine: Optional MemoryEngine instance for memory operations.
|
|
93
|
+
"""
|
|
94
|
+
self._database_url = database_url
|
|
95
|
+
self._memory_engine = memory_engine
|
|
96
|
+
|
|
97
|
+
async def run_migration(self, schema: str) -> None:
|
|
98
|
+
"""Run migrations for a specific schema."""
|
|
99
|
+
from hindsight_api.migrations import run_migrations
|
|
100
|
+
|
|
101
|
+
run_migrations(self._database_url, schema=schema)
|
|
102
|
+
|
|
103
|
+
def get_memory_engine(self) -> "MemoryEngineInterface":
|
|
104
|
+
"""Get the memory engine interface."""
|
|
105
|
+
if self._memory_engine is None:
|
|
106
|
+
raise RuntimeError(
|
|
107
|
+
"Memory engine not configured in ExtensionContext. "
|
|
108
|
+
"Ensure the context was created with a memory_engine parameter."
|
|
109
|
+
)
|
|
110
|
+
return self._memory_engine
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP Extension for adding custom endpoints to the Hindsight API.
|
|
3
|
+
|
|
4
|
+
This extension allows adding custom HTTP endpoints under the /ext/ path prefix.
|
|
5
|
+
The extension provides a FastAPI router that is mounted on the main application.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter
|
|
12
|
+
|
|
13
|
+
from hindsight_api.extensions.base import Extension
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from hindsight_api import MemoryEngine
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HttpExtension(Extension, ABC):
|
|
20
|
+
"""
|
|
21
|
+
Base class for HTTP extensions that add custom API endpoints.
|
|
22
|
+
|
|
23
|
+
HTTP extensions provide a FastAPI router that gets mounted under /ext/.
|
|
24
|
+
The extension has full control over the routes, request/response models, and handlers.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
```python
|
|
28
|
+
from fastapi import APIRouter
|
|
29
|
+
from hindsight_api.extensions import HttpExtension
|
|
30
|
+
|
|
31
|
+
class MyHttpExtension(HttpExtension):
|
|
32
|
+
def get_router(self, memory: MemoryEngine) -> APIRouter:
|
|
33
|
+
router = APIRouter()
|
|
34
|
+
|
|
35
|
+
@router.get("/hello")
|
|
36
|
+
async def hello():
|
|
37
|
+
return {"message": "Hello from extension!"}
|
|
38
|
+
|
|
39
|
+
@router.post("/custom/{bank_id}/action")
|
|
40
|
+
async def custom_action(bank_id: str):
|
|
41
|
+
# Access memory engine for database operations
|
|
42
|
+
pool = await memory._get_pool()
|
|
43
|
+
# ... custom logic
|
|
44
|
+
return {"status": "ok"}
|
|
45
|
+
|
|
46
|
+
return router
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The routes will be available at:
|
|
50
|
+
- GET /ext/hello
|
|
51
|
+
- POST /ext/custom/{bank_id}/action
|
|
52
|
+
|
|
53
|
+
Configuration via environment variables:
|
|
54
|
+
HINDSIGHT_API_HTTP_EXTENSION=mypackage.ext:MyHttpExtension
|
|
55
|
+
HINDSIGHT_API_HTTP_SOME_CONFIG=value
|
|
56
|
+
|
|
57
|
+
The extension receives config: {"some_config": "value"}
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def get_router(self, memory: "MemoryEngine") -> APIRouter:
|
|
62
|
+
"""
|
|
63
|
+
Return a FastAPI router with custom endpoints.
|
|
64
|
+
|
|
65
|
+
The router will be mounted at /ext/ on the main application.
|
|
66
|
+
All routes defined in the router will be prefixed with /ext/.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
memory: The MemoryEngine instance for database access and core operations.
|
|
70
|
+
Use this to access the connection pool, run queries, or call
|
|
71
|
+
memory operations like retain, recall, etc.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A FastAPI APIRouter with the custom endpoints defined.
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
```python
|
|
78
|
+
def get_router(self, memory: MemoryEngine) -> APIRouter:
|
|
79
|
+
router = APIRouter(tags=["My Extension"])
|
|
80
|
+
|
|
81
|
+
@router.get("/status")
|
|
82
|
+
async def status():
|
|
83
|
+
health = await memory.health_check()
|
|
84
|
+
return {"extension": "healthy", "memory": health}
|
|
85
|
+
|
|
86
|
+
return router
|
|
87
|
+
```
|
|
88
|
+
"""
|
|
89
|
+
pass
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Extension loader utilities."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
7
|
+
|
|
8
|
+
from hindsight_api.extensions.base import Extension
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from hindsight_api.extensions.context import ExtensionContext
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T", bound=Extension)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ExtensionLoadError(Exception):
|
|
19
|
+
"""Raised when an extension fails to load."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_extension(
|
|
25
|
+
prefix: str,
|
|
26
|
+
base_class: type[T],
|
|
27
|
+
env_prefix: str = "HINDSIGHT_API",
|
|
28
|
+
context: "ExtensionContext | None" = None,
|
|
29
|
+
) -> T | None:
|
|
30
|
+
"""
|
|
31
|
+
Load an extension from environment variable configuration.
|
|
32
|
+
|
|
33
|
+
The extension class is specified via {env_prefix}_{prefix}_EXTENSION environment
|
|
34
|
+
variable in the format "module.path:ClassName".
|
|
35
|
+
|
|
36
|
+
Configuration for the extension is collected from all environment variables
|
|
37
|
+
matching {env_prefix}_{prefix}_* (excluding the EXTENSION variable itself).
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
prefix: The extension prefix (e.g., "OPERATION_VALIDATOR").
|
|
41
|
+
base_class: The base class that the extension must inherit from.
|
|
42
|
+
env_prefix: The environment variable prefix (default: "HINDSIGHT_API").
|
|
43
|
+
context: Optional ExtensionContext to provide system APIs to the extension.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
An instance of the extension, or None if not configured.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ExtensionLoadError: If the extension fails to load or validate.
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator
|
|
53
|
+
HINDSIGHT_API_OPERATION_VALIDATOR_MAX_REQUESTS=100
|
|
54
|
+
|
|
55
|
+
ext = load_extension("OPERATION_VALIDATOR", OperationValidatorExtension)
|
|
56
|
+
# ext.config == {"max_requests": "100"}
|
|
57
|
+
"""
|
|
58
|
+
env_var = f"{env_prefix}_{prefix}_EXTENSION"
|
|
59
|
+
ext_path = os.getenv(env_var)
|
|
60
|
+
|
|
61
|
+
if not ext_path:
|
|
62
|
+
logger.debug(f"No extension configured for {env_var}")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
logger.info(f"Loading extension from {env_var}={ext_path}")
|
|
66
|
+
|
|
67
|
+
# Parse "module.path:ClassName"
|
|
68
|
+
if ":" not in ext_path:
|
|
69
|
+
raise ExtensionLoadError(f"Invalid extension path '{ext_path}'. Expected format: 'module.path:ClassName'")
|
|
70
|
+
|
|
71
|
+
module_path, class_name = ext_path.rsplit(":", 1)
|
|
72
|
+
|
|
73
|
+
# Import the module
|
|
74
|
+
try:
|
|
75
|
+
module = importlib.import_module(module_path)
|
|
76
|
+
except ImportError as e:
|
|
77
|
+
raise ExtensionLoadError(f"Failed to import extension module '{module_path}': {e}") from e
|
|
78
|
+
|
|
79
|
+
# Get the class
|
|
80
|
+
try:
|
|
81
|
+
ext_class = getattr(module, class_name)
|
|
82
|
+
except AttributeError as e:
|
|
83
|
+
raise ExtensionLoadError(f"Extension class '{class_name}' not found in module '{module_path}'") from e
|
|
84
|
+
|
|
85
|
+
# Validate inheritance
|
|
86
|
+
if not isinstance(ext_class, type) or not issubclass(ext_class, base_class):
|
|
87
|
+
raise ExtensionLoadError(f"Extension class '{ext_class.__name__}' must inherit from '{base_class.__name__}'")
|
|
88
|
+
|
|
89
|
+
# Collect configuration from environment variables
|
|
90
|
+
config = _collect_config(env_prefix, prefix)
|
|
91
|
+
|
|
92
|
+
logger.info(f"Loaded extension {ext_class.__name__} with config keys: {list(config.keys())}")
|
|
93
|
+
|
|
94
|
+
# Instantiate the extension
|
|
95
|
+
try:
|
|
96
|
+
extension = ext_class(config)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
raise ExtensionLoadError(f"Failed to instantiate extension '{ext_class.__name__}': {e}") from e
|
|
99
|
+
|
|
100
|
+
# Set the context if provided
|
|
101
|
+
if context is not None:
|
|
102
|
+
extension.set_context(context)
|
|
103
|
+
logger.debug(f"Set context on extension {ext_class.__name__}")
|
|
104
|
+
|
|
105
|
+
return extension
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _collect_config(env_prefix: str, prefix: str) -> dict[str, str]:
|
|
109
|
+
"""
|
|
110
|
+
Collect configuration from environment variables.
|
|
111
|
+
|
|
112
|
+
Collects all variables matching {env_prefix}_{prefix}_* except for
|
|
113
|
+
{env_prefix}_{prefix}_EXTENSION, strips the prefix, and lowercases keys.
|
|
114
|
+
"""
|
|
115
|
+
config = {}
|
|
116
|
+
full_prefix = f"{env_prefix}_{prefix}_"
|
|
117
|
+
extension_var = f"{full_prefix}EXTENSION"
|
|
118
|
+
|
|
119
|
+
for key, value in os.environ.items():
|
|
120
|
+
if key.startswith(full_prefix) and key != extension_var:
|
|
121
|
+
# Strip prefix and lowercase the key
|
|
122
|
+
config_key = key[len(full_prefix) :].lower()
|
|
123
|
+
config[config_key] = value
|
|
124
|
+
|
|
125
|
+
return config
|