admina-framework 0.9.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.
- admina/__init__.py +34 -0
- admina/cli/__init__.py +14 -0
- admina/cli/commands/__init__.py +14 -0
- admina/cli/main.py +1522 -0
- admina/cli/templates/admina.yaml.j2 +77 -0
- admina/cli/templates/docker-compose.yml.j2 +254 -0
- admina/cli/templates/env.j2 +10 -0
- admina/cli/templates/main.py.j2 +95 -0
- admina/cli/templates/plugin.py.j2 +145 -0
- admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
- admina/cli/templates/plugin_readme.md.j2 +27 -0
- admina/cli/templates/plugin_test.py.j2 +48 -0
- admina/core/__init__.py +14 -0
- admina/core/config.py +497 -0
- admina/core/event_bus.py +112 -0
- admina/core/secrets.py +257 -0
- admina/core/types.py +146 -0
- admina/dashboard/__init__.py +8 -0
- admina/dashboard/static/heimdall.png +0 -0
- admina/dashboard/static/index.html +1045 -0
- admina/dashboard/static/vendor/alpinejs.min.js +5 -0
- admina/domains/__init__.py +14 -0
- admina/domains/agent_security/__init__.py +41 -0
- admina/domains/agent_security/firewall.py +634 -0
- admina/domains/agent_security/loop_breaker.py +176 -0
- admina/domains/ai_infra/__init__.py +79 -0
- admina/domains/ai_infra/llm_engine.py +477 -0
- admina/domains/ai_infra/rag.py +817 -0
- admina/domains/ai_infra/webui.py +292 -0
- admina/domains/compliance/__init__.py +109 -0
- admina/domains/compliance/cross_regulation.py +314 -0
- admina/domains/compliance/eu_ai_act.py +367 -0
- admina/domains/compliance/forensic.py +380 -0
- admina/domains/compliance/gdpr.py +331 -0
- admina/domains/compliance/nis2.py +258 -0
- admina/domains/compliance/oisg.py +658 -0
- admina/domains/compliance/otel.py +101 -0
- admina/domains/data_sovereignty/__init__.py +42 -0
- admina/domains/data_sovereignty/classification.py +102 -0
- admina/domains/data_sovereignty/pii.py +260 -0
- admina/domains/data_sovereignty/residency.py +121 -0
- admina/integrations/__init__.py +14 -0
- admina/integrations/_engines.py +63 -0
- admina/integrations/cheshirecat/__init__.py +13 -0
- admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
- admina/integrations/crewai/__init__.py +13 -0
- admina/integrations/crewai/callbacks.py +347 -0
- admina/integrations/langchain/__init__.py +13 -0
- admina/integrations/langchain/callbacks.py +341 -0
- admina/integrations/n8n/__init__.py +14 -0
- admina/integrations/openclaw/__init__.py +14 -0
- admina/plugins/__init__.py +49 -0
- admina/plugins/base.py +633 -0
- admina/plugins/builtin/__init__.py +14 -0
- admina/plugins/builtin/adapters/__init__.py +14 -0
- admina/plugins/builtin/adapters/ollama.py +120 -0
- admina/plugins/builtin/adapters/openai.py +138 -0
- admina/plugins/builtin/alerts/__init__.py +14 -0
- admina/plugins/builtin/alerts/log.py +66 -0
- admina/plugins/builtin/alerts/webhook.py +102 -0
- admina/plugins/builtin/auth/__init__.py +14 -0
- admina/plugins/builtin/auth/apikey.py +138 -0
- admina/plugins/builtin/compliance/__init__.py +14 -0
- admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
- admina/plugins/builtin/connectors/__init__.py +14 -0
- admina/plugins/builtin/connectors/chromadb.py +137 -0
- admina/plugins/builtin/connectors/filesystem.py +111 -0
- admina/plugins/builtin/forensic/__init__.py +14 -0
- admina/plugins/builtin/forensic/filesystem.py +163 -0
- admina/plugins/builtin/forensic/minio.py +180 -0
- admina/plugins/builtin/guards/__init__.py +0 -0
- admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
- admina/plugins/builtin/pii/__init__.py +14 -0
- admina/plugins/builtin/pii/spacy_regex.py +160 -0
- admina/plugins/builtin/transports/__init__.py +14 -0
- admina/plugins/builtin/transports/http_rest.py +97 -0
- admina/plugins/builtin/transports/mcp.py +173 -0
- admina/plugins/registry.py +356 -0
- admina/proxy/__init__.py +15 -0
- admina/proxy/api/__init__.py +17 -0
- admina/proxy/api/dashboard.py +925 -0
- admina/proxy/api/integration.py +153 -0
- admina/proxy/config.py +214 -0
- admina/proxy/engine_bridge.py +306 -0
- admina/proxy/governance.py +232 -0
- admina/proxy/main.py +1484 -0
- admina/proxy/multi_upstream.py +156 -0
- admina/proxy/state.py +97 -0
- admina/py.typed +0 -0
- admina/sdk/__init__.py +34 -0
- admina/sdk/_compat.py +43 -0
- admina/sdk/compliance_kit.py +359 -0
- admina/sdk/governed_agent.py +391 -0
- admina/sdk/governed_data.py +434 -0
- admina/sdk/governed_model.py +241 -0
- admina_framework-0.9.0.dist-info/METADATA +575 -0
- admina_framework-0.9.0.dist-info/RECORD +102 -0
- admina_framework-0.9.0.dist-info/WHEEL +5 -0
- admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
- admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
- admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
- admina_framework-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# Copyright © 2025–2026 Stefano Noferi & Admina contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Admina — MCP transport adapter.
|
|
16
|
+
|
|
17
|
+
Converts JSON-RPC 2.0 (MCP wire format) to/from the protocol-agnostic
|
|
18
|
+
:class:`GovernanceRequest` / :class:`GovernanceResponse` dataclasses.
|
|
19
|
+
|
|
20
|
+
The governance engine never sees JSON-RPC — this adapter is the only
|
|
21
|
+
place that knows about MCP framing.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from admina.core.types import GovernanceRequest, GovernanceResponse
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("admina.transport.mcp")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_request(
|
|
36
|
+
body: dict[str, Any],
|
|
37
|
+
*,
|
|
38
|
+
session_id: str | None = None,
|
|
39
|
+
agent_id: str | None = None,
|
|
40
|
+
) -> GovernanceRequest:
|
|
41
|
+
"""Convert a JSON-RPC 2.0 MCP request dict into a GovernanceRequest.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
body: The parsed JSON body of an incoming ``POST /mcp`` call.
|
|
45
|
+
session_id: Optional session identifier (from ``X-Session-Id`` header).
|
|
46
|
+
agent_id: Optional agent identifier (from ``X-Agent-Id`` header).
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
A populated :class:`GovernanceRequest`.
|
|
50
|
+
"""
|
|
51
|
+
method = body.get("method", "unknown")
|
|
52
|
+
params = body.get("params", {})
|
|
53
|
+
content_str = json.dumps(body, default=str)
|
|
54
|
+
|
|
55
|
+
return GovernanceRequest(
|
|
56
|
+
content=content_str,
|
|
57
|
+
method=method,
|
|
58
|
+
direction="inbound",
|
|
59
|
+
session_id=session_id,
|
|
60
|
+
agent_id=agent_id,
|
|
61
|
+
protocol="mcp",
|
|
62
|
+
metadata={
|
|
63
|
+
"jsonrpc_id": body.get("id"),
|
|
64
|
+
"params": params,
|
|
65
|
+
},
|
|
66
|
+
raw=body,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def format_block_response(
|
|
71
|
+
gov_response: GovernanceResponse,
|
|
72
|
+
original_body: dict[str, Any],
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
"""Format a BLOCK governance response as a JSON-RPC 2.0 error.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
gov_response: The governance engine's decision.
|
|
78
|
+
original_body: The original JSON-RPC request (for the ``id`` field).
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
A JSON-RPC 2.0 error response dict.
|
|
82
|
+
"""
|
|
83
|
+
return {
|
|
84
|
+
"jsonrpc": "2.0",
|
|
85
|
+
"id": original_body.get("id"),
|
|
86
|
+
"error": {
|
|
87
|
+
"code": -32600,
|
|
88
|
+
"message": "Request blocked by Admina governance",
|
|
89
|
+
"data": {
|
|
90
|
+
"event_id": gov_response.request_id,
|
|
91
|
+
"reason": "injection_detected",
|
|
92
|
+
"risk_level": gov_response.risk_level,
|
|
93
|
+
"governance_latency_us": round(gov_response.latency_us, 2),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def format_circuit_break_response(
|
|
100
|
+
gov_response: GovernanceResponse,
|
|
101
|
+
original_body: dict[str, Any],
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
"""Format a CIRCUIT_BREAK governance response as a JSON-RPC 2.0 error.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
gov_response: The governance engine's decision.
|
|
107
|
+
original_body: The original JSON-RPC request (for the ``id`` field).
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
A JSON-RPC 2.0 error response dict.
|
|
111
|
+
"""
|
|
112
|
+
return {
|
|
113
|
+
"jsonrpc": "2.0",
|
|
114
|
+
"id": original_body.get("id"),
|
|
115
|
+
"error": {
|
|
116
|
+
"code": -32000,
|
|
117
|
+
"message": "Circuit breaker activated: reasoning loop detected",
|
|
118
|
+
"data": {
|
|
119
|
+
"event_id": gov_response.request_id,
|
|
120
|
+
"reason": "reasoning_loop",
|
|
121
|
+
"similarity": gov_response.metadata.get("similarity"),
|
|
122
|
+
"governance_latency_us": round(gov_response.latency_us, 2),
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def format_allow_headers(
|
|
129
|
+
gov_response: GovernanceResponse,
|
|
130
|
+
*,
|
|
131
|
+
forensic_hash: str | None = None,
|
|
132
|
+
) -> dict[str, str]:
|
|
133
|
+
"""Build the extra HTTP headers added to a successful proxy response.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
gov_response: The governance engine's ALLOW decision.
|
|
137
|
+
forensic_hash: Optional truncated forensic record hash.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
A dict of HTTP header name → value.
|
|
141
|
+
"""
|
|
142
|
+
headers: dict[str, str] = {
|
|
143
|
+
"X-Admina-Event-Id": gov_response.request_id,
|
|
144
|
+
"X-Admina-Governance-Action": gov_response.action,
|
|
145
|
+
"X-Admina-Latency-Us": str(round(gov_response.latency_us, 2)),
|
|
146
|
+
}
|
|
147
|
+
if forensic_hash:
|
|
148
|
+
headers["X-Admina-Forensic-Hash"] = forensic_hash
|
|
149
|
+
return headers
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def extract_text_fields(obj: Any, depth: int = 0) -> list[str]:
|
|
153
|
+
"""Recursively extract all string values from a dict/list.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
obj: A parsed JSON value (dict, list, or scalar).
|
|
157
|
+
depth: Current recursion depth (capped at 5).
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
A flat list of string values found.
|
|
161
|
+
"""
|
|
162
|
+
if depth > 5:
|
|
163
|
+
return []
|
|
164
|
+
texts: list[str] = []
|
|
165
|
+
if isinstance(obj, str):
|
|
166
|
+
texts.append(obj)
|
|
167
|
+
elif isinstance(obj, dict):
|
|
168
|
+
for v in obj.values():
|
|
169
|
+
texts.extend(extract_text_fields(v, depth + 1))
|
|
170
|
+
elif isinstance(obj, list):
|
|
171
|
+
for item in obj:
|
|
172
|
+
texts.extend(extract_text_fields(item, depth + 1))
|
|
173
|
+
return texts
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# Copyright © 2025–2026 Stefano Noferi & Admina contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Admina — Plugin registry with discovery and validation.
|
|
16
|
+
|
|
17
|
+
The registry scans three locations for plugins:
|
|
18
|
+
|
|
19
|
+
1. ``plugins/builtin/`` — shipped with Admina.
|
|
20
|
+
2. ``~/.admina/plugins/`` — user-installed plugins.
|
|
21
|
+
3. ``admina.yaml`` ``plugins:`` list — explicit module paths.
|
|
22
|
+
|
|
23
|
+
Each discovered module is imported, its classes are validated against the
|
|
24
|
+
9 base classes, and matching plugins are registered for runtime lookup.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import importlib
|
|
30
|
+
import inspect
|
|
31
|
+
import logging
|
|
32
|
+
import sys
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from types import ModuleType
|
|
35
|
+
|
|
36
|
+
from admina.plugins.base import (
|
|
37
|
+
BaseAlertChannel,
|
|
38
|
+
BaseAuthProvider,
|
|
39
|
+
BaseComplianceTemplate,
|
|
40
|
+
BaseDataConnector,
|
|
41
|
+
BaseForensicStore,
|
|
42
|
+
BaseGovernanceGuard,
|
|
43
|
+
BaseModelAdapter,
|
|
44
|
+
BasePIIEngine,
|
|
45
|
+
BaseTransportAdapter,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
# Canonical mapping from plugin type key to ABC.
|
|
51
|
+
PLUGIN_TYPES: dict[str, type] = {
|
|
52
|
+
"model_adapter": BaseModelAdapter,
|
|
53
|
+
"data_connector": BaseDataConnector,
|
|
54
|
+
"governance_guard": BaseGovernanceGuard,
|
|
55
|
+
"compliance_template": BaseComplianceTemplate,
|
|
56
|
+
"transport_adapter": BaseTransportAdapter,
|
|
57
|
+
"forensic_store": BaseForensicStore,
|
|
58
|
+
"auth_provider": BaseAuthProvider,
|
|
59
|
+
"pii_engine": BasePIIEngine,
|
|
60
|
+
"alert_channel": BaseAlertChannel,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Reverse lookup: ABC → type key.
|
|
64
|
+
_BASE_TO_TYPE: dict[type, str] = {v: k for k, v in PLUGIN_TYPES.items()}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _plugin_type_for_class(cls: type) -> str | None:
|
|
68
|
+
"""Return the plugin type key for *cls*, or ``None`` if not a plugin."""
|
|
69
|
+
for base, key in _BASE_TO_TYPE.items():
|
|
70
|
+
if issubclass(cls, base):
|
|
71
|
+
return key
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PluginRegistry:
|
|
76
|
+
"""Central registry for all Admina plugins.
|
|
77
|
+
|
|
78
|
+
Usage::
|
|
79
|
+
|
|
80
|
+
registry = PluginRegistry()
|
|
81
|
+
registry.discover() # scan default locations
|
|
82
|
+
adapter = registry.get("model_adapter", "ollama")
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self) -> None:
|
|
86
|
+
# {type_key: {name: class}}
|
|
87
|
+
self._plugins: dict[str, dict[str, type]] = {key: {} for key in PLUGIN_TYPES}
|
|
88
|
+
|
|
89
|
+
# ── Public API ──────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def register(self, cls: type) -> None:
|
|
92
|
+
"""Register a single plugin class.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
cls: A concrete subclass of one of the 9 base classes.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
TypeError: If *cls* is not a concrete subclass of a known base.
|
|
99
|
+
ValueError: If the plugin name is already registered for its type.
|
|
100
|
+
"""
|
|
101
|
+
if inspect.isabstract(cls):
|
|
102
|
+
raise TypeError(f"Cannot register abstract class {cls.__name__!r}")
|
|
103
|
+
|
|
104
|
+
type_key = _plugin_type_for_class(cls)
|
|
105
|
+
if type_key is None:
|
|
106
|
+
raise TypeError(f"{cls.__name__!r} does not extend any known plugin base class")
|
|
107
|
+
|
|
108
|
+
name = self._extract_name(cls, type_key)
|
|
109
|
+
bucket = self._plugins[type_key]
|
|
110
|
+
|
|
111
|
+
if name in bucket:
|
|
112
|
+
logger.warning(
|
|
113
|
+
"Plugin %s/%s already registered — overwriting with %s",
|
|
114
|
+
type_key,
|
|
115
|
+
name,
|
|
116
|
+
cls.__name__,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
bucket[name] = cls
|
|
120
|
+
logger.debug("Registered plugin %s/%s → %s", type_key, name, cls.__name__)
|
|
121
|
+
|
|
122
|
+
def get(self, type_key: str, name: str) -> type | None:
|
|
123
|
+
"""Look up a registered plugin class by type and name.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
type_key: One of the keys in :data:`PLUGIN_TYPES`
|
|
127
|
+
(e.g. ``"model_adapter"``).
|
|
128
|
+
name: The plugin name (e.g. ``"ollama"``).
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The plugin class, or ``None`` if not found.
|
|
132
|
+
"""
|
|
133
|
+
return self._plugins.get(type_key, {}).get(name)
|
|
134
|
+
|
|
135
|
+
def list(self, type_key: str) -> dict[str, type]:
|
|
136
|
+
"""Return all registered plugins for a given type.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
type_key: One of the keys in :data:`PLUGIN_TYPES`.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
A dict mapping name → class.
|
|
143
|
+
"""
|
|
144
|
+
return dict(self._plugins.get(type_key, {}))
|
|
145
|
+
|
|
146
|
+
def list_all(self) -> dict[str, dict[str, type]]:
|
|
147
|
+
"""Return every registered plugin, grouped by type."""
|
|
148
|
+
return {k: dict(v) for k, v in self._plugins.items()}
|
|
149
|
+
|
|
150
|
+
def discover(
|
|
151
|
+
self,
|
|
152
|
+
*,
|
|
153
|
+
builtin_path: Path | None = None,
|
|
154
|
+
user_path: Path | None = None,
|
|
155
|
+
extra_modules: list[str] | None = None,
|
|
156
|
+
entry_point_group: str = "admina.plugins",
|
|
157
|
+
) -> int:
|
|
158
|
+
"""Scan plugin sources and register all found plugins.
|
|
159
|
+
|
|
160
|
+
Sources scanned, in order:
|
|
161
|
+
|
|
162
|
+
1. Builtin (``plugins/builtin/``)
|
|
163
|
+
2. User (``~/.admina/plugins/``)
|
|
164
|
+
3. Explicit module paths from ``admina.yaml`` ``plugins:``
|
|
165
|
+
4. Python entry-points group ``admina.plugins`` — third-party
|
|
166
|
+
packages register plugins via their pyproject.toml without
|
|
167
|
+
being on the filesystem. Example::
|
|
168
|
+
|
|
169
|
+
[project.entry-points."admina.plugins"]
|
|
170
|
+
my_adapter = "mypkg.module:MyAdapter"
|
|
171
|
+
|
|
172
|
+
or pointing at a module to pick up every concrete plugin
|
|
173
|
+
class it defines::
|
|
174
|
+
|
|
175
|
+
[project.entry-points."admina.plugins"]
|
|
176
|
+
my_pack = "mypkg.plugins"
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
builtin_path: Override for ``plugins/builtin/``.
|
|
180
|
+
user_path: Override for ``~/.admina/plugins/``.
|
|
181
|
+
extra_modules: Dotted module paths from admina.yaml.
|
|
182
|
+
entry_point_group: Entry-points group name to scan.
|
|
183
|
+
Default ``admina.plugins``. Empty string disables it.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Total number of plugins registered during this call.
|
|
187
|
+
"""
|
|
188
|
+
count = 0
|
|
189
|
+
|
|
190
|
+
# 1. Built-in plugins — locate the top-level `plugins.builtin`
|
|
191
|
+
# package via import, so editable installs and installed wheels
|
|
192
|
+
# both resolve correctly.
|
|
193
|
+
if builtin_path is None:
|
|
194
|
+
try:
|
|
195
|
+
import admina.plugins.builtin as _builtin_pkg
|
|
196
|
+
|
|
197
|
+
builtin_path = Path(next(iter(_builtin_pkg.__path__)))
|
|
198
|
+
except (ImportError, StopIteration):
|
|
199
|
+
builtin_path = Path(__file__).parent / "builtin"
|
|
200
|
+
count += self._scan_directory(builtin_path)
|
|
201
|
+
|
|
202
|
+
# 2. User plugins
|
|
203
|
+
if user_path is None:
|
|
204
|
+
user_path = Path.home() / ".admina" / "plugins"
|
|
205
|
+
count += self._scan_directory(user_path)
|
|
206
|
+
|
|
207
|
+
# 3. Explicit module paths from admina.yaml
|
|
208
|
+
for mod_path in extra_modules or []:
|
|
209
|
+
count += self._load_module_path(mod_path)
|
|
210
|
+
|
|
211
|
+
# 4. Entry-points (third-party pip-installed packages)
|
|
212
|
+
if entry_point_group:
|
|
213
|
+
count += self._scan_entry_points(entry_point_group)
|
|
214
|
+
|
|
215
|
+
logger.info("Discovery complete — %d plugin(s) registered", count)
|
|
216
|
+
return count
|
|
217
|
+
|
|
218
|
+
def _scan_entry_points(self, group: str) -> int:
|
|
219
|
+
"""Discover and register plugins exposed via Python entry-points."""
|
|
220
|
+
count = 0
|
|
221
|
+
try:
|
|
222
|
+
from importlib.metadata import entry_points
|
|
223
|
+
except ImportError: # pragma: no cover (Python < 3.8)
|
|
224
|
+
return 0
|
|
225
|
+
try:
|
|
226
|
+
eps = entry_points(group=group)
|
|
227
|
+
except TypeError:
|
|
228
|
+
# Python 3.9 entry_points() returns a dict
|
|
229
|
+
eps = entry_points().get(group, []) # type: ignore[assignment]
|
|
230
|
+
for ep in eps:
|
|
231
|
+
try:
|
|
232
|
+
obj = ep.load()
|
|
233
|
+
except Exception: # noqa: BLE001 — third-party code, isolate
|
|
234
|
+
logger.warning(
|
|
235
|
+
"Failed to load entry-point plugin %s from %s",
|
|
236
|
+
ep.name,
|
|
237
|
+
ep.value,
|
|
238
|
+
exc_info=True,
|
|
239
|
+
)
|
|
240
|
+
continue
|
|
241
|
+
if inspect.isclass(obj):
|
|
242
|
+
try:
|
|
243
|
+
self.register(obj)
|
|
244
|
+
count += 1
|
|
245
|
+
except (TypeError, ValueError):
|
|
246
|
+
logger.warning(
|
|
247
|
+
"Entry-point %s exposes %s but it is not a valid plugin",
|
|
248
|
+
ep.name,
|
|
249
|
+
obj.__name__,
|
|
250
|
+
)
|
|
251
|
+
elif inspect.ismodule(obj):
|
|
252
|
+
count += self._register_from_module(obj)
|
|
253
|
+
else:
|
|
254
|
+
logger.warning(
|
|
255
|
+
"Entry-point %s loaded %r — expected a class or module",
|
|
256
|
+
ep.name,
|
|
257
|
+
obj,
|
|
258
|
+
)
|
|
259
|
+
return count
|
|
260
|
+
|
|
261
|
+
# ── Internal helpers ────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
def _scan_directory(self, directory: Path) -> int:
|
|
264
|
+
"""Import all ``.py`` modules under *directory* and register plugins."""
|
|
265
|
+
if not directory.is_dir():
|
|
266
|
+
logger.debug("Plugin directory does not exist: %s", directory)
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
count = 0
|
|
270
|
+
for py_file in sorted(directory.rglob("*.py")):
|
|
271
|
+
if py_file.name == "__init__.py":
|
|
272
|
+
continue
|
|
273
|
+
count += self._load_file(py_file)
|
|
274
|
+
|
|
275
|
+
return count
|
|
276
|
+
|
|
277
|
+
def _load_file(self, py_file: Path) -> int:
|
|
278
|
+
"""Import a single ``.py`` file and register all plugin classes."""
|
|
279
|
+
mod_name = f"_admina_plugin_{py_file.stem}_{id(py_file)}"
|
|
280
|
+
try:
|
|
281
|
+
spec = importlib.util.spec_from_file_location(mod_name, str(py_file))
|
|
282
|
+
if spec is None or spec.loader is None:
|
|
283
|
+
return 0
|
|
284
|
+
mod = importlib.util.module_from_spec(spec)
|
|
285
|
+
sys.modules[mod_name] = mod
|
|
286
|
+
spec.loader.exec_module(mod)
|
|
287
|
+
except (ImportError, AttributeError, RuntimeError):
|
|
288
|
+
logger.warning("Failed to import plugin file %s", py_file, exc_info=True)
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
return self._register_from_module(mod)
|
|
292
|
+
|
|
293
|
+
def _load_module_path(self, mod_path: str) -> int:
|
|
294
|
+
"""Import a module by dotted path and register all plugin classes."""
|
|
295
|
+
try:
|
|
296
|
+
mod = importlib.import_module(mod_path)
|
|
297
|
+
except ImportError:
|
|
298
|
+
logger.warning("Failed to import plugin module %r", mod_path, exc_info=True)
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
return self._register_from_module(mod)
|
|
302
|
+
|
|
303
|
+
def _register_from_module(self, mod: ModuleType) -> int:
|
|
304
|
+
"""Find and register all concrete plugin classes in *mod*."""
|
|
305
|
+
count = 0
|
|
306
|
+
for _attr_name, obj in inspect.getmembers(mod, inspect.isclass):
|
|
307
|
+
# Skip the ABCs themselves and classes not defined in this module
|
|
308
|
+
if obj.__module__ != mod.__name__:
|
|
309
|
+
continue
|
|
310
|
+
if inspect.isabstract(obj):
|
|
311
|
+
continue
|
|
312
|
+
if _plugin_type_for_class(obj) is not None:
|
|
313
|
+
self.register(obj)
|
|
314
|
+
count += 1
|
|
315
|
+
return count
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def _extract_name(cls: type, type_key: str) -> str:
|
|
319
|
+
"""Extract the canonical name from a plugin class.
|
|
320
|
+
|
|
321
|
+
Tries the property/attribute that matches the base class contract
|
|
322
|
+
(``name``, ``protocol_name``, ``store_name``, etc.). Falls back
|
|
323
|
+
to the lower-cased class name.
|
|
324
|
+
"""
|
|
325
|
+
# Map type_key → property name on the ABC
|
|
326
|
+
name_attrs = {
|
|
327
|
+
"model_adapter": "name",
|
|
328
|
+
"data_connector": "name",
|
|
329
|
+
"governance_guard": "name",
|
|
330
|
+
"compliance_template": "framework_name",
|
|
331
|
+
"transport_adapter": "protocol_name",
|
|
332
|
+
"forensic_store": "store_name",
|
|
333
|
+
"auth_provider": "provider_name",
|
|
334
|
+
"pii_engine": "supported_languages",
|
|
335
|
+
"alert_channel": "channel_name",
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
attr = name_attrs.get(type_key, "name")
|
|
339
|
+
|
|
340
|
+
# For pii_engine, the identifier is not a name property — use class name
|
|
341
|
+
if type_key == "pii_engine":
|
|
342
|
+
return cls.__name__.lower()
|
|
343
|
+
|
|
344
|
+
# Try to get from a class-level attribute (not an instance property)
|
|
345
|
+
# Instantiation-free: check if the class defines the attr as a plain value
|
|
346
|
+
for klass in cls.__mro__:
|
|
347
|
+
if attr in klass.__dict__:
|
|
348
|
+
val = klass.__dict__[attr]
|
|
349
|
+
# If it's a plain value (str), use it
|
|
350
|
+
if isinstance(val, str):
|
|
351
|
+
return val
|
|
352
|
+
# If it's a property, we can't call it without an instance
|
|
353
|
+
break
|
|
354
|
+
|
|
355
|
+
# Fallback: lower-cased class name
|
|
356
|
+
return cls.__name__.lower()
|
admina/proxy/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright © 2025–2026 Stefano Noferi & Admina contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# Admina Governance Proxy
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright © 2025–2026 Stefano Noferi & Admina contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Admina proxy API sub-package — dashboard and integration endpoints."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|