cn-tool 0.7.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.
Files changed (89) hide show
  1. cn_tool/__init__.py +1 -0
  2. cn_tool/__main__.py +5 -0
  3. cn_tool/cn_buildstamp.py +104 -0
  4. cn_tool/core/__init__.py +0 -0
  5. cn_tool/core/background.py +130 -0
  6. cn_tool/core/base.py +210 -0
  7. cn_tool/core/event_bus.py +57 -0
  8. cn_tool/core/loader.py +164 -0
  9. cn_tool/main.py +1170 -0
  10. cn_tool/modules/__init__.py +0 -0
  11. cn_tool/modules/aruba_bssids.py +121 -0
  12. cn_tool/modules/bulk_ping.py +708 -0
  13. cn_tool/modules/bulk_resolve.py +242 -0
  14. cn_tool/modules/bulk_trace.py +156 -0
  15. cn_tool/modules/config_analyzer_module.py +254 -0
  16. cn_tool/modules/config_search.py +559 -0
  17. cn_tool/modules/delete_report.py +32 -0
  18. cn_tool/modules/device_query.py +265 -0
  19. cn_tool/modules/fqdn_request.py +464 -0
  20. cn_tool/modules/ip_request.py +569 -0
  21. cn_tool/modules/location_request.py +361 -0
  22. cn_tool/modules/manual_email.py +56 -0
  23. cn_tool/modules/setup.py +695 -0
  24. cn_tool/modules/subnet_request.py +1411 -0
  25. cn_tool/plugins/__init__.py +0 -0
  26. cn_tool/plugins/activedirectory_support.py +252 -0
  27. cn_tool/plugins/config_analyzer_settings.py +46 -0
  28. cn_tool/plugins/email_support.py +74 -0
  29. cn_tool/plugins/sdwan_yaml_search.py +408 -0
  30. cn_tool/sample.cn +122 -0
  31. cn_tool/utils/__init__.py +0 -0
  32. cn_tool/utils/ad_helper.py +196 -0
  33. cn_tool/utils/api.py +928 -0
  34. cn_tool/utils/app_lifecycle.py +94 -0
  35. cn_tool/utils/auth.py +540 -0
  36. cn_tool/utils/cache.py +110 -0
  37. cn_tool/utils/cache_helpers.py +1569 -0
  38. cn_tool/utils/cache_optimized.py +244 -0
  39. cn_tool/utils/cache_status.py +174 -0
  40. cn_tool/utils/cli_input.py +47 -0
  41. cn_tool/utils/color_schemes.py +164 -0
  42. cn_tool/utils/config.py +718 -0
  43. cn_tool/utils/config_history.py +218 -0
  44. cn_tool/utils/decorators.py +26 -0
  45. cn_tool/utils/dhcp_options.py +156 -0
  46. cn_tool/utils/diagnostics.py +133 -0
  47. cn_tool/utils/display.py +469 -0
  48. cn_tool/utils/email_helper.py +172 -0
  49. cn_tool/utils/file_io.py +731 -0
  50. cn_tool/utils/gpg.py +111 -0
  51. cn_tool/utils/hash.py +23 -0
  52. cn_tool/utils/infoblox_inheritance.py +202 -0
  53. cn_tool/utils/infoblox_safety.py +51 -0
  54. cn_tool/utils/infoblox_ux.py +14 -0
  55. cn_tool/utils/logging.py +88 -0
  56. cn_tool/utils/network_views.py +328 -0
  57. cn_tool/utils/oscompat.py +103 -0
  58. cn_tool/utils/parsers.py +639 -0
  59. cn_tool/utils/process_data.py +447 -0
  60. cn_tool/utils/render.py +123 -0
  61. cn_tool/utils/search_helpers.py +94 -0
  62. cn_tool/utils/ssh.py +98 -0
  63. cn_tool/utils/stats.py +1146 -0
  64. cn_tool/utils/user_input.py +411 -0
  65. cn_tool/utils/validation.py +241 -0
  66. cn_tool/version +4 -0
  67. cn_tool/wordlists/keywords.py +1200 -0
  68. cn_tool-0.7.0.dist-info/METADATA +951 -0
  69. cn_tool-0.7.0.dist-info/RECORD +89 -0
  70. cn_tool-0.7.0.dist-info/WHEEL +4 -0
  71. cn_tool-0.7.0.dist-info/entry_points.txt +3 -0
  72. cn_tool-0.7.0.dist-info/licenses/LICENSE +201 -0
  73. config_analyzer/__init__.py +4 -0
  74. config_analyzer/__main__.py +5 -0
  75. config_analyzer/app.py +129 -0
  76. config_analyzer/cli.py +109 -0
  77. config_analyzer/debug.py +97 -0
  78. config_analyzer/differ.py +134 -0
  79. config_analyzer/filter_mixin.py +36 -0
  80. config_analyzer/formatting.py +13 -0
  81. config_analyzer/keymap.py +94 -0
  82. config_analyzer/parser.py +196 -0
  83. config_analyzer/repo_browser.py +1455 -0
  84. config_analyzer/search.py +207 -0
  85. config_analyzer/tips.py +74 -0
  86. config_analyzer/tui.py +1000 -0
  87. config_analyzer/utils.py +135 -0
  88. config_analyzer/version.py +2 -0
  89. config_analyzer/widgets.py +360 -0
cn_tool/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """cn-tool: Infoblox lookups and network checks."""
cn_tool/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """``python -m cn_tool``: the same entry point as the ``cn`` command."""
2
+ from cn_tool.main import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,104 @@
1
+ """Shared build-stamp reader for cn-tool and its bundled CLIs.
2
+
3
+ The ``cn_tool/version`` file (next to this module) is the single CI-managed source of truth for the
4
+ *platform* version (``MAJOR.MINOR.BUILD``) plus the short git ``BUILD_HASH``.
5
+ It is rewritten on every release by ``.github/workflows/release-public.yml``.
6
+
7
+ Each bundled tool additionally carries its own
8
+ *component* ``__version__`` and renders a combined ``--version`` line through
9
+ this module, so the per-tool version and the shared package build stamp stay
10
+ consistent and the parsing logic lives in exactly one place.
11
+
12
+ Hybrid ``--version`` format::
13
+
14
+ cli-tool 0.2.1 (cn-tool 0.2.88, build 1b5d97f)
15
+ ^^^^^ ^^^^^^ ^^^^^^^
16
+ component package version git hash
17
+ (per tool) (version file) (version file)
18
+
19
+ When the ``version`` file is missing or unparseable, the platform suffix is
20
+ omitted and the component version is shown alone (never a misleading
21
+ ``unknown``).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from functools import lru_cache
27
+ from pathlib import Path
28
+ from typing import NamedTuple, Optional
29
+
30
+
31
+ class BuildStamp(NamedTuple):
32
+ package_version: str # e.g. "0.2.88"
33
+ build_hash: str # e.g. "1b5d97f" (may be "")
34
+
35
+
36
+ def parse_version_text(content: str) -> Optional[BuildStamp]:
37
+ """Parse the ``KEY=value`` body of a ``version`` file.
38
+
39
+ Returns ``None`` when the required ``MAJOR``/``MINOR`` keys are missing
40
+ or non-numeric (a hand-edited file must not yield a stamp like
41
+ ``"abc.2.88"``), so callers can fall back to a component-only version
42
+ line. A non-numeric optional ``BUILD`` degrades to ``MAJOR.MINOR``.
43
+ """
44
+ parts: dict[str, str] = {}
45
+ for line in content.splitlines():
46
+ key, sep, val = line.partition("=")
47
+ if sep:
48
+ parts[key.strip()] = val.strip()
49
+
50
+ major = parts.get("MAJOR", "")
51
+ minor = parts.get("MINOR", "")
52
+ build = parts.get("BUILD", "")
53
+ if not (major.isdigit() and minor.isdigit()):
54
+ return None
55
+ if not build.isdigit():
56
+ build = ""
57
+ build_hash = parts.get("BUILD_HASH", "")
58
+
59
+ package_version = f"{major}.{minor}.{build}" if build else f"{major}.{minor}"
60
+ return BuildStamp(package_version=package_version, build_hash=build_hash)
61
+
62
+
63
+ @lru_cache(maxsize=1)
64
+ def build_stamp() -> Optional[BuildStamp]:
65
+ """Parse the ``version`` file next to this module (cn_tool/version).
66
+
67
+ Returns ``None`` when the file is absent or unparseable so callers can fall
68
+ back to a component-only version line. Result is cached after first call.
69
+ """
70
+ try:
71
+ version_file = Path(__file__).resolve().parent / "version"
72
+ content = version_file.read_text(encoding="utf-8")
73
+ except OSError:
74
+ return None
75
+ return parse_version_text(content)
76
+
77
+
78
+ def package_version_string() -> str:
79
+ """Legacy combined string used by cn-tool: ``"0.2.88 hash 1b5d97f"``.
80
+
81
+ Falls back to ``"unknown"`` when no build stamp is available. This matches
82
+ the string the CI previously injected into ``main.py`` at build time.
83
+ """
84
+ stamp = build_stamp()
85
+ if stamp is None:
86
+ return "unknown"
87
+ if stamp.build_hash:
88
+ return f"{stamp.package_version} hash {stamp.build_hash}"
89
+ return stamp.package_version
90
+
91
+
92
+ def version_line(tool_name: str, component_version: str) -> str:
93
+ """Render a bundled tool's ``--version`` line in the hybrid format.
94
+
95
+ Example: ``cli-tool 0.2.1 (cn-tool 0.2.88, build 1b5d97f)``.
96
+ Without a build stamp the platform suffix is dropped: ``cli-tool 0.2.1``.
97
+ """
98
+ stamp = build_stamp()
99
+ if stamp is None:
100
+ return f"{tool_name} {component_version}"
101
+ suffix = f"cn-tool {stamp.package_version}"
102
+ if stamp.build_hash:
103
+ suffix += f", build {stamp.build_hash}"
104
+ return f"{tool_name} {component_version} ({suffix})"
File without changes
@@ -0,0 +1,130 @@
1
+ import threading
2
+ from time import time, sleep
3
+ from cn_tool.core.base import ScriptContext
4
+ from cn_tool.utils.cache import CacheManager
5
+ from cn_tool.utils.cache_helpers import mt_index_configurations
6
+
7
+
8
+ def start_background_tasks(ctx: ScriptContext) -> None:
9
+ """
10
+ Initializes and starts all non-blocking background processes for the application.
11
+ """
12
+ if ctx.cfg.get("cache_enabled", False):
13
+ # Initialize the CacheManager instance and attach it to the context
14
+ ctx.cache = CacheManager.get_instance(
15
+ directory=ctx.cfg["cache_directory"],
16
+ logger=ctx.logger,
17
+ cfg=ctx.cfg,
18
+ )
19
+ # Start the cache indexing in a separate thread
20
+ threading.Thread(target=_background_cache_init, args=[ctx], daemon=True).start()
21
+
22
+
23
+ def _background_cache_init(ctx: ScriptContext) -> None:
24
+ """
25
+ Checks the cache state and triggers a re-index if necessary.
26
+ This function is designed to be run in a background thread.
27
+ (Original `background_cache_init` logic)
28
+ """
29
+ logger = ctx.logger
30
+ if not ctx.cache:
31
+ logger.info("Index Cache - Cache does not exist, skipping.")
32
+ return
33
+
34
+ cache: CacheManager = ctx.cache
35
+
36
+ if cache.dc.get("indexing"):
37
+ logger.info("Index Cache - Another process is already indexing; will watch and recheck later.")
38
+ threading.Thread(target=_wait_for_indexing_and_recheck, args=[ctx], daemon=True).start()
39
+ return
40
+
41
+ updated_time = cache.dc.get("updated", 0)
42
+ if not isinstance(updated_time, (int, float)):
43
+ return
44
+
45
+ # Check if the cache was updated recently and if versions match
46
+ if (int(time()) - int(updated_time)) <= 30 and cache.dc.get("version") == ctx.cfg.get("cache_version", None):
47
+ logger.info("Index Cache - State is up-to-date, skipping checks.")
48
+ cache.log_stats("startup-check")
49
+ return
50
+
51
+ if cache.dc.get("version") != ctx.cfg["cache_version"]:
52
+ logger.info(f"Index Cache - New cache version {ctx.cfg.get('cache_version', 'Unspecified')} in config.")
53
+ # Mark resetting so UI doesn't briefly show Ready/0 devices
54
+ try:
55
+ cache.dc.set("indexing", True)
56
+ cache.dc.set("indexing_phase", "resetting")
57
+ cache.dc.set("indexing_started", int(time()))
58
+ cache.dc.set("indexing_last_update", int(time()))
59
+ except Exception:
60
+ pass
61
+ cache.reset_cache()
62
+
63
+ logger.info("Index Cache - Starting cache check and potential re-indexing.")
64
+ try:
65
+ mt_index_configurations(ctx)
66
+ except Exception as e:
67
+ logger.error(f"Index Cache - Error during background initialization: {e}", exc_info=True)
68
+ # Ensure the indexing flag is cleared on error
69
+ cache.dc.pop("indexing", None)
70
+
71
+
72
+ def _recheck_and_index_if_needed(ctx: ScriptContext) -> None:
73
+ """
74
+ Re-run the same readiness check and trigger indexing if needed.
75
+ Used when we started while another process was indexing.
76
+ """
77
+ logger = ctx.logger
78
+ cache: CacheManager = ctx.cache # type: ignore
79
+
80
+ if cache.dc.get("version") != ctx.cfg["cache_version"]:
81
+ logger.info("Index Cache - Detected version change during recheck; resetting cache.")
82
+ cache.reset_cache()
83
+
84
+ updated_time = cache.dc.get("updated", 0)
85
+ if not isinstance(updated_time, (int, float)):
86
+ updated_time = 0
87
+
88
+ if (int(time()) - int(updated_time)) <= 30 and cache.dc.get("version") == ctx.cfg.get("cache_version", None):
89
+ logger.info("Index Cache - State is up-to-date on recheck, skipping.")
90
+ cache.log_stats("post-foreign-index-recheck")
91
+ return
92
+
93
+ logger.info("Index Cache - Rechecking cache; starting (re-)index if necessary.")
94
+ try:
95
+ mt_index_configurations(ctx)
96
+ except Exception as e:
97
+ logger.error(f"Index Cache - Error during recheck: {e}", exc_info=True)
98
+ cache.dc.pop("indexing", None)
99
+
100
+
101
+ def _wait_for_indexing_and_recheck(ctx: ScriptContext) -> None:
102
+ """
103
+ Waits for an external indexing to complete or become stale, then rechecks and indexes if needed.
104
+ """
105
+ logger = ctx.logger
106
+ cache: CacheManager = ctx.cache # type: ignore
107
+ STALE_SECONDS = 180
108
+ POLL_SECONDS = 5
109
+
110
+ while True:
111
+ try:
112
+ # If an error was recorded during indexing, stop waiting and surface it
113
+ if cache.dc.get("indexing_error"):
114
+ logger.error("Index Cache - External indexing signaled an error. Rechecking will not start automatically.")
115
+ cache.dc.pop("indexing", None)
116
+ return
117
+ if not cache.dc.get("indexing"):
118
+ logger.info("Index Cache - External indexing finished (flag cleared). Rechecking.")
119
+ break
120
+ last = int(cache.dc.get("indexing_last_update", 0) or 0)
121
+ if (int(time()) - last) > STALE_SECONDS:
122
+ logger.warning("Index Cache - External indexing appears stale; clearing flag and rechecking.")
123
+ cache.dc.pop("indexing", None)
124
+ break
125
+ except Exception:
126
+ # Conservative: break and recheck if we cannot read the flag
127
+ break
128
+ sleep(POLL_SECONDS)
129
+
130
+ _recheck_and_index_if_needed(ctx)
cn_tool/core/base.py ADDED
@@ -0,0 +1,210 @@
1
+ # core/base.py
2
+ from abc import ABC, abstractmethod
3
+ import argparse
4
+ from dataclasses import dataclass, field
5
+ import logging
6
+ import sys
7
+ from time import perf_counter
8
+ from typing import Any, Callable, Dict, List, NamedTuple, Optional, TYPE_CHECKING
9
+
10
+ from .event_bus import EventBus
11
+
12
+
13
+ if TYPE_CHECKING:
14
+ from cn_tool.utils.display import ThemedConsole
15
+ from cn_tool.utils.cache import CacheManager
16
+ from cn_tool.utils.stats import StatsManager
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Credentials:
21
+ """One account: a user name, its password, and where the password came from (``source``).
22
+
23
+ The password is not part of ``repr``, so a log line that formats the object never shows it.
24
+ """
25
+ username: str
26
+ password: str = field(repr=False)
27
+ source: str = "" # "INFOBLOX_PW", "GPG file /home/alice/infoblox.gpg" or "prompt"
28
+
29
+
30
+ @dataclass
31
+ class ScriptContext:
32
+ """A container for shared objects used throughout the script."""
33
+ cfg: Dict[str, Any] = field(repr=False) # .cn as typed: it can hold an [email] password, or one pasted into [auth]
34
+ logger: logging.Logger
35
+ console: "ThemedConsole" # Use quotes for forward reference
36
+ cache: Optional["CacheManager"] # Use quotes for forward reference
37
+ event_bus: EventBus
38
+ username: str
39
+ password: str = field(repr=False) # the TACACS password: repr(ctx) never shows it
40
+ plugins: List["BasePlugin"]
41
+ stats: Optional["StatsManager"] = None
42
+ # Infoblox's own account once resolved; None while unknown, and always None when Infoblox uses the
43
+ # shared login (username/password above).
44
+ infoblox_credentials: Optional[Credentials] = None
45
+
46
+
47
+ class CliResult(NamedTuple):
48
+ """What ``BaseModule.run_cli`` hands back: the process exit code and the sections to render."""
49
+ exit_code: int
50
+ data: Dict[str, List[Dict[str, Any]]]
51
+
52
+
53
+ def cli_exit_code(found: bool, invalid: bool, failed: bool) -> int:
54
+ """Exit status of a command-line run: 3 incomplete, 2 usage error, 0 data, 1 no record.
55
+
56
+ Precedence is 3 > 2 > 0 > 1, so an Infoblox failure or unsaved report is never hidden
57
+ behind "found", and a usage error is never hidden behind data from the valid objects.
58
+ """
59
+ if failed:
60
+ return 3
61
+ if invalid:
62
+ return 2
63
+ return 0 if found else 1
64
+
65
+
66
+ class BaseModule(ABC):
67
+ """Abstract Base Class for all main menu modules."""
68
+
69
+ #: Command under which ``cn <name>`` dispatches to ``run_cli``; None keeps the module menu-only.
70
+ cli_name: Optional[str] = None
71
+
72
+ def __init__(self):
73
+ # Hooks are points where plugins can inject logic.
74
+ # The key is the hook name, the value is a list of callback functions.
75
+ self.hooks: Dict[str, List[Callable]] = {
76
+ 'pre_run': [], # Called at the absolute beginning of a module's run method.
77
+ 'process_data': [], # Called after initial data processing.
78
+ 'pre_render': [], # Called just before creating tables for display.
79
+ 'pre_save': [], # Called just before queueing data for saving.
80
+ 'post_run': [], # Called at the very end of a module's run method, for cleanup.
81
+ }
82
+
83
+ @property
84
+ def visibility_config_key(self) -> Optional[str]:
85
+ """
86
+ If a string is returned, this module will only be visible in the menu
87
+ if the corresponding key in the configuration is True.
88
+ e.g., return 'email_enabled'
89
+ """
90
+ return None
91
+
92
+ @property
93
+ def track_in_stats(self) -> bool:
94
+ """Whether this module should contribute to shared usage statistics."""
95
+ return True
96
+
97
+ @property
98
+ @abstractmethod
99
+ def menu_key(self) -> str:
100
+ """The character key to trigger this module from the menu (e.g., '1', 'b')."""
101
+ pass
102
+
103
+ @property
104
+ @abstractmethod
105
+ def menu_title(self) -> str:
106
+ """The title of the module to display in the menu."""
107
+ pass
108
+
109
+ def register_hook(self, hook_name: str, callback: Callable):
110
+ """Allows plugins to register a callback for a specific hook."""
111
+ if hook_name in self.hooks:
112
+ self.hooks[hook_name].append(callback)
113
+ else:
114
+ print(f"Warning: Attempted to register for unknown hook '{hook_name}' in {self.menu_title}", file=sys.stderr)
115
+
116
+ def execute_hook(self, hook_name: str, ctx: ScriptContext, data: Any) -> Any:
117
+ """Executes all registered callbacks for a hook, passing data through them."""
118
+ modified_data = data
119
+ for callback in self.hooks.get(hook_name, []):
120
+ try:
121
+ # Get the plugin's name from the bound method for clear logging
122
+ plugin_name = callback.__self__.name
123
+ except AttributeError:
124
+ plugin_name = "Unknown Plugin"
125
+
126
+ hook_start_time = perf_counter()
127
+ # Each callback receives the context and the data, and must return the (potentially modified) data.
128
+ modified_data = callback(ctx, modified_data)
129
+
130
+ hook_end_time = perf_counter()
131
+ duration = round(hook_end_time - hook_start_time, 3)
132
+
133
+ ctx.logger.info(f"Plugin '{plugin_name}' hook '{hook_name}' took {duration} seconds.")
134
+
135
+ return modified_data
136
+
137
+ @abstractmethod
138
+ def run(self, ctx: ScriptContext) -> None:
139
+ """The main entry point for the module's execution."""
140
+ pass
141
+
142
+ def run_cli(self, ctx: ScriptContext, args: argparse.Namespace) -> CliResult:
143
+ """Run once from ``cn <command>`` with the parsed options; the result is rendered by main."""
144
+ ctx.console.print(f"{self.menu_title} is not available from the command line.")
145
+ return CliResult(2, {})
146
+
147
+
148
+ class BasePlugin(ABC):
149
+ """Abstract Base Class for all plugins."""
150
+
151
+ @property
152
+ def user_configurable_settings(self) -> List[Dict[str, str]]:
153
+ """
154
+ A list of settings this plugin exposes to the user via the setup module.
155
+ Each dict should contain:
156
+ - 'key': The key in the config context (e.g., 'ad_user')
157
+ - 'prompt': The user-friendly prompt (e.g., 'Active Directory Username')
158
+ """
159
+ return []
160
+
161
+ @property
162
+ @abstractmethod
163
+ def name(self) -> str:
164
+ """A descriptive name for the plugin."""
165
+ pass
166
+
167
+ @property
168
+ @abstractmethod
169
+ def target_module_name(self) -> str:
170
+ """The filename of the module this plugin targets (e.g., 'ip_request')."""
171
+ pass
172
+
173
+ @property
174
+ def config_schema(self) -> Dict[str, Dict[str, Any]]:
175
+ """
176
+ Defines the configuration schema required by this plugin.
177
+ Each plugin can specify its own section, keys, types, and fallbacks.
178
+ Example:
179
+ return {
180
+ 'my_plugin_key': {'section': 'myplugin', 'ini_key': 'api_key', 'type': 'str', 'fallback': ''}
181
+ }
182
+ """
183
+ return {}
184
+
185
+ @property
186
+ def manages_global_connection(self) -> bool:
187
+ """
188
+ If True, this plugin manages a persistent, application-wide connection.
189
+ The main application will call connect() on startup and disconnect() on exit.
190
+ """
191
+ return False
192
+
193
+ def connect(self, ctx: ScriptContext) -> None:
194
+ """
195
+ Called once on application startup if manages_global_connection is True.
196
+ Used to initialize persistent resources like API connections.
197
+ """
198
+ pass
199
+
200
+ def disconnect(self, ctx: ScriptContext) -> None:
201
+ """
202
+ Called once on application exit if manages_global_connection is True.
203
+ Used to clean up persistent resources.
204
+ """
205
+ pass
206
+
207
+ @abstractmethod
208
+ def register(self, module: BaseModule) -> None:
209
+ """Called by the loader to register the plugin's hooks with its target module."""
210
+ pass
@@ -0,0 +1,57 @@
1
+ """Simple publish/subscribe event bus used for cross-module notifications."""
2
+ from __future__ import annotations
3
+
4
+ from collections import defaultdict
5
+ import itertools
6
+ import logging
7
+ import threading
8
+ from typing import Any, Callable, DefaultDict, List, Optional, Tuple
9
+
10
+ EventCallback = Callable[[Any], None]
11
+
12
+
13
+ class EventBus:
14
+ """Thread-safe in-process event dispatcher."""
15
+
16
+ def __init__(self, logger: Optional[logging.Logger] = None) -> None:
17
+ self._logger = logger
18
+ self._lock = threading.Lock()
19
+ self._subscribers: DefaultDict[str, List[Tuple[int, EventCallback]]] = defaultdict(list)
20
+ self._token_source = itertools.count(1)
21
+
22
+ def subscribe(self, event_name: str, callback: EventCallback) -> int:
23
+ """Register *callback* to be invoked when *event_name* is published."""
24
+ if not callable(callback): # pragma: no cover - defensive guard
25
+ raise TypeError("callback must be callable")
26
+ token = next(self._token_source)
27
+ with self._lock:
28
+ self._subscribers[event_name].append((token, callback))
29
+ return token
30
+
31
+ def unsubscribe(self, token: int) -> bool:
32
+ """Remove a previously registered callback using its subscription *token*."""
33
+ with self._lock:
34
+ for event_name, callbacks in list(self._subscribers.items()):
35
+ for index, (stored_token, _) in enumerate(callbacks):
36
+ if stored_token == token:
37
+ callbacks.pop(index)
38
+ if not callbacks:
39
+ self._subscribers.pop(event_name, None)
40
+ return True
41
+ return False
42
+
43
+ def publish(self, event_name: str, payload: Any | None = None) -> None:
44
+ """Invoke all listeners subscribed to *event_name* with *payload*."""
45
+ with self._lock:
46
+ callbacks = list(self._subscribers.get(event_name, ()))
47
+ for _, callback in callbacks:
48
+ try:
49
+ callback(payload)
50
+ except Exception: # pragma: no cover - logging guard
51
+ if self._logger:
52
+ self._logger.exception("Event handler failure for '%s'", event_name)
53
+
54
+ def clear(self) -> None:
55
+ """Remove every subscriber from the bus."""
56
+ with self._lock:
57
+ self._subscribers.clear()
cn_tool/core/loader.py ADDED
@@ -0,0 +1,164 @@
1
+ # core/loader.py
2
+ import os
3
+ import importlib
4
+ import inspect
5
+ import logging
6
+ from typing import Dict, Any
7
+ from cn_tool.core.base import BaseModule, BasePlugin
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def _merge_plugin_schema(schema: Dict[str, Any], plugin_schema: Dict[str, Any]) -> Dict[str, Any]:
14
+ """Merge plugin schema entries without discarding required base metadata."""
15
+ merged = schema.copy()
16
+ for key, plugin_spec in plugin_schema.items():
17
+ current_spec = merged.get(key)
18
+ if isinstance(current_spec, dict) and isinstance(plugin_spec, dict):
19
+ merged[key] = {**current_spec, **plugin_spec}
20
+ else:
21
+ merged[key] = plugin_spec
22
+ return merged
23
+
24
+
25
+ def collect_plugin_schemas(master_schema: Dict[str, Any]) -> Dict[str, Any]:
26
+ """Harvest config_schema from every plugin without registering hooks.
27
+
28
+ Returns ``master_schema`` extended with each plugin's contributed keys.
29
+ Used by the standalone CLIs bundled with cn-tool so they see the same schema surface as
30
+ cn-tool's main entry point without pulling in module loading or hook
31
+ registration. Plugin files are walked in ``sorted(os.listdir(...))`` order
32
+ -- the same order ``_load_and_register_plugins`` uses -- so merge precedence
33
+ matches between the two callers.
34
+
35
+ NOTE: this instantiates each ``BasePlugin`` subclass to read its
36
+ ``config_schema`` property. Plugin authors must keep ``__init__`` free of
37
+ I/O or thread/process creation, otherwise those CLIs' runtime builds incur
38
+ those side effects on every invocation.
39
+ """
40
+ plugins_path = os.path.join(os.path.dirname(__file__), "..", "plugins")
41
+ schema = master_schema.copy()
42
+ if not os.path.isdir(plugins_path):
43
+ return schema
44
+ for filename in sorted(os.listdir(plugins_path)):
45
+ if not filename.endswith(".py") or filename.startswith("__"):
46
+ continue
47
+ module_path = f"cn_tool.plugins.{filename[:-3]}"
48
+ try:
49
+ mod = importlib.import_module(module_path)
50
+ except Exception as exc:
51
+ logger.debug("collect_plugin_schemas: import failed %s: %s", module_path, exc)
52
+ continue
53
+ for _, obj in inspect.getmembers(mod):
54
+ if not inspect.isclass(obj) or obj is BasePlugin:
55
+ continue
56
+ try:
57
+ if not issubclass(obj, BasePlugin):
58
+ continue
59
+ except TypeError:
60
+ continue
61
+ try:
62
+ plugin_schema = obj().config_schema or {}
63
+ except Exception as exc:
64
+ logger.warning(
65
+ "collect_plugin_schemas: could not read schema from %s: %s",
66
+ obj.__name__,
67
+ exc,
68
+ )
69
+ continue
70
+ schema = _merge_plugin_schema(schema, plugin_schema)
71
+ return schema
72
+
73
+
74
+ def load_modules_and_plugins(master_schema: Dict[str, Any]) -> tuple[Dict[str, BaseModule], list[BasePlugin], Dict[str, Any]]:
75
+ """
76
+ Discovers, loads, and registers all modules and plugins.
77
+ It also collects configuration schemas from plugins and merges them.
78
+
79
+ Args:
80
+ master_schema: The base configuration schema of the application.
81
+
82
+ Returns:
83
+ A tuple containing:
84
+ - A dictionary of loaded and configured module instances.
85
+ - The updated master schema including plugin configurations.
86
+ """
87
+ # Start with a copy of the base schema
88
+ updated_schema = master_schema.copy()
89
+ all_plugins: list[BasePlugin] = []
90
+
91
+ modules_path = os.path.join(os.path.dirname(__file__), '..', 'modules')
92
+ plugins_path = os.path.join(os.path.dirname(__file__), '..', 'plugins')
93
+
94
+ modules = _load_modules(modules_path)
95
+ # The plugin loader now returns the updated schema
96
+ updated_schema = _load_and_register_plugins(plugins_path, modules, updated_schema, all_plugins)
97
+
98
+ # Return a dictionary keyed by menu_key for easy access in the main loop
99
+ modules_by_key = {mod.menu_key: mod for mod in modules.values()}
100
+
101
+ return modules_by_key, all_plugins, updated_schema
102
+
103
+
104
+ def _load_modules(path: str) -> Dict[str, BaseModule]:
105
+ """Loads all BaseModule subclasses from a given directory."""
106
+ loaded_modules: Dict[str, BaseModule] = {}
107
+ for filename in sorted(os.listdir(path)):
108
+ if filename.endswith('.py') and not filename.startswith('__'):
109
+ module_name = filename[:-3]
110
+ module_path = f"cn_tool.modules.{module_name}"
111
+ try:
112
+ module = importlib.import_module(module_path)
113
+ for name, obj in inspect.getmembers(module):
114
+ try:
115
+ if inspect.isclass(obj) and issubclass(obj, BaseModule) and obj is not BaseModule:
116
+ instance = obj()
117
+ logger.info(f" -> Loaded module: {instance.menu_title} ({module_name})")
118
+ loaded_modules[module_name] = instance # Instantiate the module
119
+ except TypeError:
120
+ continue
121
+ except ImportError as e:
122
+ logger.error("Error loading module %s: %s", module_path, e)
123
+ return loaded_modules
124
+
125
+
126
+ def _load_and_register_plugins(path: str, modules: Dict[str, BaseModule], schema: Dict[str, Any], plugin_list: list[BasePlugin]) -> Dict[str, Any]:
127
+ """
128
+ Loads plugins, registers their hooks, and merges their config schemas.
129
+ """
130
+ for filename in sorted(os.listdir(path)):
131
+ if filename.endswith('.py') and not filename.startswith('__'):
132
+ module_name = filename[:-3]
133
+ module_path = f"cn_tool.plugins.{module_name}"
134
+ try:
135
+ module = importlib.import_module(module_path)
136
+ for name, obj in inspect.getmembers(module):
137
+ if inspect.isclass(obj):
138
+ try:
139
+ if issubclass(obj, BasePlugin) and obj is not BasePlugin:
140
+ plugin = obj()
141
+ logger.info(f" -> Loaded plugin: {plugin.name}")
142
+ plugin_list.append(plugin)
143
+
144
+ plugin_schema = plugin.config_schema
145
+ if plugin_schema:
146
+ logger.info("Loading config schema from plugin '%s'", plugin.name)
147
+ schema = _merge_plugin_schema(schema, plugin_schema)
148
+
149
+ target_name = plugin.target_module_name
150
+ # Only attempt to register if the plugin specifies a target.
151
+ if target_name:
152
+ target_module = modules.get(target_name)
153
+ if target_module:
154
+ plugin.register(target_module)
155
+ else:
156
+ # This warning will now only show for plugins that
157
+ # SPECIFY a target that does not exist.
158
+ logger.warning("Plugin '%s' targets non-existent module '%s'", plugin.name, target_name)
159
+ # If target_name is empty, we do nothing. This is expected for lifecycle plugins.
160
+ except TypeError:
161
+ continue
162
+ except ImportError as e:
163
+ logger.error("Error loading plugin %s: %s", module_path, e)
164
+ return schema