xpl-core 0.3.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.
- xpl_core/__init__.py +3 -0
- xpl_core/config.py +74 -0
- xpl_core/credential.py +112 -0
- xpl_core/dispatcher.py +98 -0
- xpl_core/module.py +168 -0
- xpl_core/pool.py +87 -0
- xpl_core/py.typed +0 -0
- xpl_core/registry.py +127 -0
- xpl_core/schemas/__init__.py +81 -0
- xpl_core/schemas/xpl_finding_v1.json +105 -0
- xpl_core/session.py +173 -0
- xpl_core/shells/__init__.py +85 -0
- xpl_core/shells/dns_tunnel_shell.py +275 -0
- xpl_core/shells/handler.py +214 -0
- xpl_core/shells/http_poll_shell.py +252 -0
- xpl_core/shells/icmp_covert_shell.py +226 -0
- xpl_core/shells/internal_shell.py +296 -0
- xpl_core/shells/listener.py +204 -0
- xpl_core/shells/meterpreter_bridge.py +245 -0
- xpl_core/shells/mqtt_shell.py +219 -0
- xpl_core/shells/raw_tcp_shell.py +180 -0
- xpl_core/shells/raw_udp_shell.py +252 -0
- xpl_core/shells/session_manager.py +299 -0
- xpl_core/shells/shell_engine.py +230 -0
- xpl_core/shells/stager.py +357 -0
- xpl_core-0.3.0.dist-info/METADATA +24 -0
- xpl_core-0.3.0.dist-info/RECORD +30 -0
- xpl_core-0.3.0.dist-info/WHEEL +5 -0
- xpl_core-0.3.0.dist-info/licenses/LICENSE +123 -0
- xpl_core-0.3.0.dist-info/top_level.txt +1 -0
xpl_core/__init__.py
ADDED
xpl_core/config.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Canonical JSON-based config loader for XPL-Suite forges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_DEFAULT_CONFIG_DIR = Path.home() / ".config" / "xpl-suite"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ForgeConfig:
|
|
13
|
+
"""Per-forge JSON configuration with defaults and persistence."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
forge_name: str,
|
|
18
|
+
config_dir: str | Path | None = None,
|
|
19
|
+
defaults: dict[str, Any] | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
self._forge = forge_name
|
|
22
|
+
base = Path(config_dir) if config_dir else _DEFAULT_CONFIG_DIR
|
|
23
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
self._path = base / f"{forge_name}.json"
|
|
25
|
+
self._defaults: dict[str, Any] = defaults or {}
|
|
26
|
+
self._data: dict[str, Any] = {}
|
|
27
|
+
self._load()
|
|
28
|
+
|
|
29
|
+
def _load(self) -> None:
|
|
30
|
+
if self._path.exists():
|
|
31
|
+
try:
|
|
32
|
+
self._data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
33
|
+
except (json.JSONDecodeError, OSError):
|
|
34
|
+
self._data = {}
|
|
35
|
+
else:
|
|
36
|
+
self._data = {}
|
|
37
|
+
|
|
38
|
+
def _save(self) -> None:
|
|
39
|
+
self._path.write_text(json.dumps(self._data, indent=2), encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
42
|
+
return self._data.get(key, self._defaults.get(key, default))
|
|
43
|
+
|
|
44
|
+
def set_val(self, key: str, value: Any) -> None:
|
|
45
|
+
self._data[key] = value
|
|
46
|
+
self._save()
|
|
47
|
+
|
|
48
|
+
def delete(self, key: str) -> bool:
|
|
49
|
+
if key in self._data:
|
|
50
|
+
del self._data[key]
|
|
51
|
+
self._save()
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
def reset(self) -> None:
|
|
56
|
+
self._data = {}
|
|
57
|
+
self._save()
|
|
58
|
+
|
|
59
|
+
def all_keys(self) -> list[str]:
|
|
60
|
+
keys = set(self._defaults.keys()) | set(self._data.keys())
|
|
61
|
+
return sorted(keys)
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, Any]:
|
|
64
|
+
merged = dict(self._defaults)
|
|
65
|
+
merged.update(self._data)
|
|
66
|
+
return merged
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def path(self) -> Path:
|
|
70
|
+
return self._path
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def forge_name(self) -> str:
|
|
74
|
+
return self._forge
|
xpl_core/credential.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Canonical credential models for XPL-Suite forges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class BaseCredential:
|
|
11
|
+
"""Minimal credential shared by all forge types."""
|
|
12
|
+
|
|
13
|
+
username: str | None = None
|
|
14
|
+
password: str | None = None
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def has_password(self) -> bool:
|
|
18
|
+
return self.password is not None and len(self.password) > 0
|
|
19
|
+
|
|
20
|
+
def masked(self) -> str:
|
|
21
|
+
user = self.username or "(none)"
|
|
22
|
+
pwd = "****" if self.has_password else "(empty)"
|
|
23
|
+
return f"{user}:{pwd}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ADCredential(BaseCredential):
|
|
28
|
+
"""Active Directory credential with hash/ticket support."""
|
|
29
|
+
|
|
30
|
+
domain: str = ""
|
|
31
|
+
lm_hash: str = ""
|
|
32
|
+
nt_hash: str = ""
|
|
33
|
+
aes_key: str = ""
|
|
34
|
+
ticket: str = ""
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_args(cls, args: Any) -> ADCredential:
|
|
38
|
+
return cls(
|
|
39
|
+
username=getattr(args, "username", None),
|
|
40
|
+
password=getattr(args, "password", None),
|
|
41
|
+
domain=getattr(args, "domain", "") or "",
|
|
42
|
+
lm_hash=getattr(args, "lm_hash", "") or "",
|
|
43
|
+
nt_hash=getattr(args, "nt_hash", "") or "",
|
|
44
|
+
aes_key=getattr(args, "aes_key", "") or "",
|
|
45
|
+
ticket=getattr(args, "ticket", "") or "",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def has_hash(self) -> bool:
|
|
50
|
+
return bool(self.lm_hash or self.nt_hash)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def impacket_hash(self) -> str:
|
|
54
|
+
lm = self.lm_hash or "aad3b435b51404eeaad3b435b51404ee"
|
|
55
|
+
nt = self.nt_hash or "31d6cfe0d16ae931b73c59d7e0c089c0"
|
|
56
|
+
return f"{lm}:{nt}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class CloudCredential(BaseCredential):
|
|
61
|
+
"""Cloud provider credential with key/token support."""
|
|
62
|
+
|
|
63
|
+
provider: str = ""
|
|
64
|
+
identity: str = ""
|
|
65
|
+
credential_type: str = "unknown"
|
|
66
|
+
access_key: str | None = None
|
|
67
|
+
secret_key: str | None = None
|
|
68
|
+
token: str | None = None
|
|
69
|
+
profile: str | None = None
|
|
70
|
+
region: str | None = None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def masked_secret(self) -> str:
|
|
74
|
+
if not self.secret_key:
|
|
75
|
+
return "(none)"
|
|
76
|
+
if len(self.secret_key) <= 8:
|
|
77
|
+
return "****"
|
|
78
|
+
return self.secret_key[:4] + "****" + self.secret_key[-4:]
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict[str, Any]:
|
|
81
|
+
return {
|
|
82
|
+
"provider": self.provider,
|
|
83
|
+
"identity": self.identity,
|
|
84
|
+
"credential_type": self.credential_type,
|
|
85
|
+
"access_key": self.access_key,
|
|
86
|
+
"secret_key": self.masked_secret,
|
|
87
|
+
"token": "****" if self.token else None,
|
|
88
|
+
"profile": self.profile,
|
|
89
|
+
"region": self.region,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class NetworkCredential:
|
|
95
|
+
"""Generic network credential for embedded/industrial/wireless forges."""
|
|
96
|
+
|
|
97
|
+
username: str = ""
|
|
98
|
+
password: str = ""
|
|
99
|
+
protocol: str = ""
|
|
100
|
+
port: int = 0
|
|
101
|
+
community: str = ""
|
|
102
|
+
private_key: str | None = None
|
|
103
|
+
tags: list[str] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def has_password(self) -> bool:
|
|
107
|
+
return len(self.password) > 0
|
|
108
|
+
|
|
109
|
+
def masked(self) -> str:
|
|
110
|
+
user = self.username or "(none)"
|
|
111
|
+
pwd = "****" if self.has_password else "(empty)"
|
|
112
|
+
return f"{user}:{pwd}@{self.protocol}:{self.port}"
|
xpl_core/dispatcher.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Dispatcher — route commands to registered modules.
|
|
2
|
+
|
|
3
|
+
Generic dispatcher compatible with both function-based modules
|
|
4
|
+
(ADXPL pattern: def run(args) -> int) and class-based modules
|
|
5
|
+
(BaseModule subclasses). Works with PluginRegistry for discovery.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from xpl_core.module import BaseModule, FunctionModule, ModuleResult
|
|
14
|
+
from xpl_core.registry import PluginRegistry, RegisteredModule
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DispatchError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Dispatcher:
|
|
22
|
+
"""Route category/name commands to the appropriate module."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, registry: PluginRegistry | None = None) -> None:
|
|
25
|
+
self._registry = registry or PluginRegistry()
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def registry(self) -> PluginRegistry:
|
|
29
|
+
return self._registry
|
|
30
|
+
|
|
31
|
+
def dispatch(self, category: str, name: str, **kwargs: Any) -> ModuleResult:
|
|
32
|
+
"""Dispatch to a registered module by category and name."""
|
|
33
|
+
entry = self._registry.get(category, name)
|
|
34
|
+
if entry is None:
|
|
35
|
+
raise DispatchError(f"Unknown module: {category}/{name}")
|
|
36
|
+
|
|
37
|
+
mod = self._load_module(entry)
|
|
38
|
+
return self._execute(mod, **kwargs)
|
|
39
|
+
|
|
40
|
+
def dispatch_args(self, args: Any) -> int:
|
|
41
|
+
"""Dispatch using an argparse Namespace (ADXPL compatibility).
|
|
42
|
+
|
|
43
|
+
Expects args.command (category) and args.module (name).
|
|
44
|
+
Returns an integer exit code.
|
|
45
|
+
"""
|
|
46
|
+
command: str = getattr(args, "command", "")
|
|
47
|
+
module_name: str = getattr(args, "module", "")
|
|
48
|
+
|
|
49
|
+
entry = self._registry.get(command, module_name)
|
|
50
|
+
if entry is None:
|
|
51
|
+
print(
|
|
52
|
+
f"[!] Unknown module: {command}/{module_name}",
|
|
53
|
+
file=sys.stderr,
|
|
54
|
+
)
|
|
55
|
+
return 1
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
loaded = entry.load()
|
|
59
|
+
except ImportError as exc:
|
|
60
|
+
print(f"[!] Failed to load {entry.import_path}: {exc}", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
if hasattr(loaded, "run") and callable(loaded.run):
|
|
64
|
+
return int(loaded.run(args))
|
|
65
|
+
return 1
|
|
66
|
+
|
|
67
|
+
def _load_module(self, entry: RegisteredModule) -> BaseModule:
|
|
68
|
+
loaded = entry.load()
|
|
69
|
+
|
|
70
|
+
if isinstance(loaded, type) and issubclass(loaded, BaseModule):
|
|
71
|
+
return loaded()
|
|
72
|
+
|
|
73
|
+
module_cls: Any = getattr(loaded, "Module", None)
|
|
74
|
+
if module_cls is not None and isinstance(module_cls, type) and issubclass(module_cls, BaseModule):
|
|
75
|
+
instance: BaseModule = module_cls()
|
|
76
|
+
return instance
|
|
77
|
+
|
|
78
|
+
run_func = getattr(loaded, "run", None)
|
|
79
|
+
manifest_data = getattr(loaded, "MANIFEST", {})
|
|
80
|
+
if callable(run_func):
|
|
81
|
+
manifest_data.setdefault("name", entry.name)
|
|
82
|
+
manifest_data.setdefault("capability_id", f"{entry.category}.{entry.name}")
|
|
83
|
+
return FunctionModule(manifest_data, run_func)
|
|
84
|
+
|
|
85
|
+
raise DispatchError(
|
|
86
|
+
f"Module {entry.import_path} has no BaseModule class, "
|
|
87
|
+
f"Module attribute, or run() function"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _execute(self, module: BaseModule, **kwargs: Any) -> ModuleResult:
|
|
91
|
+
return module.execute(**kwargs)
|
|
92
|
+
|
|
93
|
+
def list_commands(self) -> dict[str, list[str]]:
|
|
94
|
+
"""Return a dict of category -> module names."""
|
|
95
|
+
result: dict[str, list[str]] = {}
|
|
96
|
+
for cat in self._registry.list_categories():
|
|
97
|
+
result[cat] = [m.name for m in self._registry.list_modules(cat)]
|
|
98
|
+
return result
|
xpl_core/module.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""BaseModule ABC — standard interface for all XPL-Suite forge modules.
|
|
2
|
+
|
|
3
|
+
Every module in the suite (enumeration, attack, exploit, recon, etc.)
|
|
4
|
+
inherits from BaseModule. This ensures a consistent lifecycle:
|
|
5
|
+
configure → check → run → cleanup
|
|
6
|
+
|
|
7
|
+
Forges that use function-based modules (def run(args)) can wrap them
|
|
8
|
+
with FunctionModule for compatibility with the dispatcher.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SafetyClass(Enum):
|
|
21
|
+
PASSIVE = "PASSIVE"
|
|
22
|
+
ACTIVE = "ACTIVE"
|
|
23
|
+
INTRUSIVE = "INTRUSIVE"
|
|
24
|
+
DESTRUCTIVE = "DESTRUCTIVE"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ModuleManifest:
|
|
29
|
+
capability_id: str
|
|
30
|
+
name: str
|
|
31
|
+
description: str = ""
|
|
32
|
+
safety_class: SafetyClass = SafetyClass.ACTIVE
|
|
33
|
+
requires_authorization: bool = True
|
|
34
|
+
side_effects: list[str] = field(default_factory=list)
|
|
35
|
+
output_schema: str = "xpl.finding.v1"
|
|
36
|
+
mitre: list[str] = field(default_factory=list)
|
|
37
|
+
cve: list[str] = field(default_factory=list)
|
|
38
|
+
authors: list[str] = field(default_factory=list)
|
|
39
|
+
tags: list[str] = field(default_factory=list)
|
|
40
|
+
forge: str = ""
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"capability_id": self.capability_id,
|
|
45
|
+
"name": self.name,
|
|
46
|
+
"description": self.description,
|
|
47
|
+
"safety_class": self.safety_class.value,
|
|
48
|
+
"requires_authorization": self.requires_authorization,
|
|
49
|
+
"side_effects": self.side_effects,
|
|
50
|
+
"output_schema": self.output_schema,
|
|
51
|
+
"mitre": self.mitre,
|
|
52
|
+
"cve": self.cve,
|
|
53
|
+
"authors": self.authors,
|
|
54
|
+
"tags": self.tags,
|
|
55
|
+
"forge": self.forge,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ModuleStatus(Enum):
|
|
60
|
+
PENDING = "pending"
|
|
61
|
+
RUNNING = "running"
|
|
62
|
+
SUCCESS = "success"
|
|
63
|
+
FAILURE = "failure"
|
|
64
|
+
SKIPPED = "skipped"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class ModuleResult:
|
|
69
|
+
status: ModuleStatus = ModuleStatus.PENDING
|
|
70
|
+
exit_code: int = 0
|
|
71
|
+
findings: list[Any] = field(default_factory=list)
|
|
72
|
+
output: str = ""
|
|
73
|
+
error: str = ""
|
|
74
|
+
elapsed_s: float = 0.0
|
|
75
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class BaseModule(ABC):
|
|
79
|
+
"""Abstract base class for XPL-Suite modules."""
|
|
80
|
+
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
self._result = ModuleResult()
|
|
83
|
+
self._start_time: float = 0.0
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
@abstractmethod
|
|
87
|
+
def manifest(self) -> ModuleManifest:
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
def configure(self, **kwargs: Any) -> None:
|
|
91
|
+
"""Configure module parameters before execution."""
|
|
92
|
+
for key, value in kwargs.items():
|
|
93
|
+
if hasattr(self, key):
|
|
94
|
+
setattr(self, key, value)
|
|
95
|
+
|
|
96
|
+
def check(self) -> bool | None:
|
|
97
|
+
"""Pre-flight check. Return True if target is likely vulnerable,
|
|
98
|
+
False if not, None if check is not implemented."""
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def run(self) -> ModuleResult:
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
def cleanup(self) -> None: # noqa: B027
|
|
106
|
+
"""Post-execution cleanup. Override if the module allocates resources."""
|
|
107
|
+
|
|
108
|
+
def execute(self, **kwargs: Any) -> ModuleResult:
|
|
109
|
+
"""Full lifecycle: configure → check → run → cleanup."""
|
|
110
|
+
self.configure(**kwargs)
|
|
111
|
+
self._start_time = time.monotonic()
|
|
112
|
+
self._result = ModuleResult(status=ModuleStatus.RUNNING)
|
|
113
|
+
try:
|
|
114
|
+
self._result = self.run()
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
self._result = ModuleResult(
|
|
117
|
+
status=ModuleStatus.FAILURE,
|
|
118
|
+
exit_code=1,
|
|
119
|
+
error=str(exc),
|
|
120
|
+
)
|
|
121
|
+
finally:
|
|
122
|
+
self._result.elapsed_s = time.monotonic() - self._start_time
|
|
123
|
+
self.cleanup()
|
|
124
|
+
return self._result
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def result(self) -> ModuleResult:
|
|
128
|
+
return self._result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class FunctionModule(BaseModule):
|
|
132
|
+
"""Adapter that wraps a function-based module (def run(args) -> int)
|
|
133
|
+
into the BaseModule interface."""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
manifest_data: dict[str, Any],
|
|
138
|
+
run_func: Any,
|
|
139
|
+
) -> None:
|
|
140
|
+
super().__init__()
|
|
141
|
+
self._manifest = ModuleManifest(
|
|
142
|
+
capability_id=manifest_data.get("capability_id", "unknown"),
|
|
143
|
+
name=manifest_data.get("name", "unnamed"),
|
|
144
|
+
description=manifest_data.get("description", ""),
|
|
145
|
+
safety_class=SafetyClass(manifest_data.get("safety_class", "ACTIVE")),
|
|
146
|
+
requires_authorization=manifest_data.get("requires_authorization", True),
|
|
147
|
+
side_effects=manifest_data.get("side_effects", []),
|
|
148
|
+
output_schema=manifest_data.get("output_schema", "xpl.finding.v1"),
|
|
149
|
+
mitre=manifest_data.get("mitre", []),
|
|
150
|
+
cve=manifest_data.get("cve", []),
|
|
151
|
+
authors=manifest_data.get("authors", []),
|
|
152
|
+
tags=manifest_data.get("tags", []),
|
|
153
|
+
forge=manifest_data.get("forge", ""),
|
|
154
|
+
)
|
|
155
|
+
self._run_func = run_func
|
|
156
|
+
self._args: Any = None
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def manifest(self) -> ModuleManifest:
|
|
160
|
+
return self._manifest
|
|
161
|
+
|
|
162
|
+
def configure(self, **kwargs: Any) -> None:
|
|
163
|
+
self._args = kwargs.get("args")
|
|
164
|
+
|
|
165
|
+
def run(self) -> ModuleResult:
|
|
166
|
+
exit_code = self._run_func(self._args)
|
|
167
|
+
status = ModuleStatus.SUCCESS if exit_code == 0 else ModuleStatus.FAILURE
|
|
168
|
+
return ModuleResult(status=status, exit_code=exit_code)
|
xpl_core/pool.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Canonical adaptive thread/process pool for XPL-Suite forges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from concurrent.futures import Future, ProcessPoolExecutor, ThreadPoolExecutor
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Callable, TypeVar
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
R = TypeVar("R")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PoolStrategy(Enum):
|
|
18
|
+
THREADS = "threads"
|
|
19
|
+
PROCESSES = "processes"
|
|
20
|
+
AUTO = "auto"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SmartPool:
|
|
24
|
+
"""Adaptive pool that picks threads vs processes based on workload heuristics."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
max_workers: int = 0,
|
|
29
|
+
strategy: PoolStrategy = PoolStrategy.AUTO,
|
|
30
|
+
thread_name_prefix: str = "xpl-pool",
|
|
31
|
+
) -> None:
|
|
32
|
+
if max_workers <= 0:
|
|
33
|
+
max_workers = min(32, (os.cpu_count() or 4) + 4)
|
|
34
|
+
self._max_workers = max_workers
|
|
35
|
+
self._strategy = strategy
|
|
36
|
+
self._prefix = thread_name_prefix
|
|
37
|
+
self._executor: ThreadPoolExecutor | ProcessPoolExecutor | None = None
|
|
38
|
+
|
|
39
|
+
def _get_executor(self, io_bound: bool = True) -> ThreadPoolExecutor | ProcessPoolExecutor:
|
|
40
|
+
if self._executor is not None:
|
|
41
|
+
return self._executor
|
|
42
|
+
if self._strategy == PoolStrategy.THREADS or (
|
|
43
|
+
self._strategy == PoolStrategy.AUTO and io_bound
|
|
44
|
+
):
|
|
45
|
+
self._executor = ThreadPoolExecutor(
|
|
46
|
+
max_workers=self._max_workers,
|
|
47
|
+
thread_name_prefix=self._prefix,
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
self._executor = ProcessPoolExecutor(max_workers=self._max_workers)
|
|
51
|
+
return self._executor
|
|
52
|
+
|
|
53
|
+
def submit(self, fn: Callable[..., R], *args: Any, **kwargs: Any) -> Future[R]:
|
|
54
|
+
return self._get_executor().submit(fn, *args, **kwargs)
|
|
55
|
+
|
|
56
|
+
def map_unordered(
|
|
57
|
+
self,
|
|
58
|
+
fn: Callable[[T], R],
|
|
59
|
+
items: Sequence[T],
|
|
60
|
+
timeout_per_item: float = 0,
|
|
61
|
+
on_result: Callable[[R], None] | None = None,
|
|
62
|
+
on_error: Callable[[Exception], None] | None = None,
|
|
63
|
+
) -> list[R]:
|
|
64
|
+
results: list[R] = []
|
|
65
|
+
futures = {self.submit(fn, item): item for item in items}
|
|
66
|
+
for future in futures:
|
|
67
|
+
try:
|
|
68
|
+
timeout = timeout_per_item if timeout_per_item > 0 else None
|
|
69
|
+
result = future.result(timeout=timeout)
|
|
70
|
+
results.append(result)
|
|
71
|
+
if on_result:
|
|
72
|
+
on_result(result)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
if on_error:
|
|
75
|
+
on_error(exc)
|
|
76
|
+
return results
|
|
77
|
+
|
|
78
|
+
def shutdown(self, wait: bool = False) -> None:
|
|
79
|
+
if self._executor is not None:
|
|
80
|
+
self._executor.shutdown(wait=wait)
|
|
81
|
+
self._executor = None
|
|
82
|
+
|
|
83
|
+
def __enter__(self) -> SmartPool:
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def __exit__(self, *exc: Any) -> None:
|
|
87
|
+
self.shutdown(wait=True)
|
xpl_core/py.typed
ADDED
|
File without changes
|
xpl_core/registry.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Plugin registry — dynamic module discovery for XPL-Suite.
|
|
2
|
+
|
|
3
|
+
Supports three discovery mechanisms:
|
|
4
|
+
1. entry_points (pip-installed forges advertise modules via pyproject.toml)
|
|
5
|
+
2. module_map (static dict like ADXPL's _MODULE_MAP for import-based routing)
|
|
6
|
+
3. scan (filesystem scan for modules with MANIFEST dicts)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class RegisteredModule:
|
|
18
|
+
name: str
|
|
19
|
+
category: str
|
|
20
|
+
import_path: str
|
|
21
|
+
forge: str = ""
|
|
22
|
+
manifest: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
_loaded: Any = field(default=None, repr=False)
|
|
24
|
+
|
|
25
|
+
def load(self) -> Any:
|
|
26
|
+
if self._loaded is None:
|
|
27
|
+
self._loaded = importlib.import_module(self.import_path)
|
|
28
|
+
return self._loaded
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PluginRegistry:
|
|
32
|
+
"""Central registry for all XPL-Suite modules across forges."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self._modules: dict[str, RegisteredModule] = {}
|
|
36
|
+
self._categories: dict[str, list[str]] = {}
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def count(self) -> int:
|
|
40
|
+
return len(self._modules)
|
|
41
|
+
|
|
42
|
+
def register(
|
|
43
|
+
self,
|
|
44
|
+
name: str,
|
|
45
|
+
category: str,
|
|
46
|
+
import_path: str,
|
|
47
|
+
forge: str = "",
|
|
48
|
+
manifest: dict[str, Any] | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
key = f"{category}/{name}"
|
|
51
|
+
mod = RegisteredModule(
|
|
52
|
+
name=name,
|
|
53
|
+
category=category,
|
|
54
|
+
import_path=import_path,
|
|
55
|
+
forge=forge,
|
|
56
|
+
manifest=manifest or {},
|
|
57
|
+
)
|
|
58
|
+
self._modules[key] = mod
|
|
59
|
+
self._categories.setdefault(category, []).append(key)
|
|
60
|
+
|
|
61
|
+
def get(self, category: str, name: str) -> RegisteredModule | None:
|
|
62
|
+
return self._modules.get(f"{category}/{name}")
|
|
63
|
+
|
|
64
|
+
def list_categories(self) -> list[str]:
|
|
65
|
+
return sorted(self._categories.keys())
|
|
66
|
+
|
|
67
|
+
def list_modules(self, category: str | None = None) -> list[RegisteredModule]:
|
|
68
|
+
if category is not None:
|
|
69
|
+
keys = self._categories.get(category, [])
|
|
70
|
+
return [self._modules[k] for k in keys]
|
|
71
|
+
return list(self._modules.values())
|
|
72
|
+
|
|
73
|
+
def search(self, query: str) -> list[RegisteredModule]:
|
|
74
|
+
q = query.lower()
|
|
75
|
+
return [
|
|
76
|
+
m for m in self._modules.values()
|
|
77
|
+
if q in m.name.lower()
|
|
78
|
+
or q in m.category.lower()
|
|
79
|
+
or q in m.import_path.lower()
|
|
80
|
+
or any(q in str(v).lower() for v in m.manifest.values())
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
def load_from_map(
|
|
84
|
+
self,
|
|
85
|
+
module_map: dict[str, dict[str, str]],
|
|
86
|
+
forge: str = "",
|
|
87
|
+
) -> int:
|
|
88
|
+
"""Load modules from a static category->name->import_path map
|
|
89
|
+
(compatible with ADXPL's _MODULE_MAP pattern)."""
|
|
90
|
+
count = 0
|
|
91
|
+
for category, modules in module_map.items():
|
|
92
|
+
for name, import_path in modules.items():
|
|
93
|
+
self.register(name, category, import_path, forge=forge)
|
|
94
|
+
count += 1
|
|
95
|
+
return count
|
|
96
|
+
|
|
97
|
+
def load_from_entry_points(self, group: str = "xpl_suite.modules") -> int:
|
|
98
|
+
"""Discover modules from pip entry_points (PEP 621)."""
|
|
99
|
+
count = 0
|
|
100
|
+
from importlib.metadata import entry_points
|
|
101
|
+
eps: Any = entry_points(group=group)
|
|
102
|
+
|
|
103
|
+
for ep in eps:
|
|
104
|
+
parts = ep.name.split(".", 1)
|
|
105
|
+
category = parts[0] if len(parts) > 1 else "default"
|
|
106
|
+
name = parts[-1]
|
|
107
|
+
self.register(
|
|
108
|
+
name=name,
|
|
109
|
+
category=category,
|
|
110
|
+
import_path=ep.value if isinstance(ep.value, str) else f"{ep.value}",
|
|
111
|
+
forge=ep.group if hasattr(ep, "group") else "",
|
|
112
|
+
)
|
|
113
|
+
count += 1
|
|
114
|
+
return count
|
|
115
|
+
|
|
116
|
+
def unregister(self, category: str, name: str) -> bool:
|
|
117
|
+
key = f"{category}/{name}"
|
|
118
|
+
if key in self._modules:
|
|
119
|
+
del self._modules[key]
|
|
120
|
+
if key in self._categories.get(category, []):
|
|
121
|
+
self._categories[category].remove(key)
|
|
122
|
+
return True
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def clear(self) -> None:
|
|
126
|
+
self._modules.clear()
|
|
127
|
+
self._categories.clear()
|