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 ADDED
@@ -0,0 +1,3 @@
1
+ """xpl-core — Shared schemas, policies and registry for XPL-Suite."""
2
+
3
+ __version__ = "0.3.0"
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()