mxx-tool 0.1.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.
- mxx/__init__.py +0 -0
- mxx/cfg_tool/__main__.py +14 -0
- mxx/cfg_tool/app.py +117 -0
- mxx/cfg_tool/cfg.py +184 -0
- mxx/cfg_tool/registry.py +118 -0
- mxx/client/__init__.py +9 -0
- mxx/client/client.py +316 -0
- mxx/runner/builtins/__init__.py +18 -0
- mxx/runner/builtins/app_launcher.py +121 -0
- mxx/runner/builtins/lifetime.py +114 -0
- mxx/runner/builtins/mxxrun.py +158 -0
- mxx/runner/builtins/mxxset.py +171 -0
- mxx/runner/builtins/os_exec.py +78 -0
- mxx/runner/core/callstack.py +45 -0
- mxx/runner/core/config_loader.py +84 -0
- mxx/runner/core/enums.py +11 -0
- mxx/runner/core/plugin.py +23 -0
- mxx/runner/core/registry.py +101 -0
- mxx/runner/core/runner.py +128 -0
- mxx/server/__init__.py +7 -0
- mxx/server/flask_runner.py +114 -0
- mxx/server/registry.py +229 -0
- mxx/server/routes.py +370 -0
- mxx/server/schedule.py +107 -0
- mxx/server/scheduler.py +355 -0
- mxx/server/server.py +188 -0
- mxx/utils/__init__.py +7 -0
- mxx/utils/nested.py +148 -0
- mxx_tool-0.1.0.dist-info/METADATA +22 -0
- mxx_tool-0.1.0.dist-info/RECORD +34 -0
- mxx_tool-0.1.0.dist-info/WHEEL +5 -0
- mxx_tool-0.1.0.dist-info/entry_points.txt +4 -0
- mxx_tool-0.1.0.dist-info/licenses/LICENSE +21 -0
- mxx_tool-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
MXX App Runner plugin for launching registered applications.
|
|
6
|
+
|
|
7
|
+
This plugin loads applications from the MXX app registry and launches them
|
|
8
|
+
with proper configuration override handling.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from mxx.runner.core.plugin import MxxPlugin, hook
|
|
15
|
+
from mxx.cfg_tool.registry import get_app_by_name, load_json_config, save_json_config
|
|
16
|
+
from mxx.utils.nested import nested_set
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MxxRun(MxxPlugin):
|
|
20
|
+
"""
|
|
21
|
+
Registered application launcher plugin.
|
|
22
|
+
|
|
23
|
+
Loads applications from the MXX app registry (~/.mxx/apps) and launches them
|
|
24
|
+
with configuration overrides applied. Supports automatic config modification
|
|
25
|
+
and application termination.
|
|
26
|
+
|
|
27
|
+
Config Key: "mxxrun"
|
|
28
|
+
|
|
29
|
+
Example Configuration:
|
|
30
|
+
```toml
|
|
31
|
+
[mxxrun]
|
|
32
|
+
app_name = "myapp" # Name/alias from registry
|
|
33
|
+
temp_config = true # Optional: use temporary config copy
|
|
34
|
+
```
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__cmdname__ = "mxxrun"
|
|
38
|
+
|
|
39
|
+
def __init__(self, app_name: str = None, temp_config: bool = False, **kwargs):
|
|
40
|
+
super().__init__()
|
|
41
|
+
self.app_name = app_name
|
|
42
|
+
self.temp_config = temp_config
|
|
43
|
+
self.app_info = None
|
|
44
|
+
self.executable_path = None
|
|
45
|
+
self.config_path = None
|
|
46
|
+
self.original_config_path = None
|
|
47
|
+
|
|
48
|
+
# Validation
|
|
49
|
+
if not self.app_name:
|
|
50
|
+
raise ValueError("app_name must be specified for MxxRun configuration.")
|
|
51
|
+
|
|
52
|
+
@hook("pre_action")
|
|
53
|
+
def load_and_prepare_app(self, runner):
|
|
54
|
+
"""
|
|
55
|
+
Load app from registry and prepare configuration with overrides.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If app not found in registry
|
|
59
|
+
FileNotFoundError: If app executable or config not found
|
|
60
|
+
"""
|
|
61
|
+
# Load app from registry
|
|
62
|
+
self.app_info = get_app_by_name(self.app_name)
|
|
63
|
+
if not self.app_info:
|
|
64
|
+
raise ValueError(f"Application '{self.app_name}' not found in MXX registry")
|
|
65
|
+
|
|
66
|
+
app_config = self.app_info["config"]
|
|
67
|
+
|
|
68
|
+
# Resolve paths from registry
|
|
69
|
+
app_path = Path(app_config["path"])
|
|
70
|
+
self.executable_path = app_path / app_config["app"]
|
|
71
|
+
self.original_config_path = app_path / app_config["cfgroute"]
|
|
72
|
+
|
|
73
|
+
# Validate paths exist
|
|
74
|
+
if not self.executable_path.exists():
|
|
75
|
+
raise FileNotFoundError(f"App executable not found at {self.executable_path}")
|
|
76
|
+
if not self.original_config_path.exists():
|
|
77
|
+
raise FileNotFoundError(f"App config folder not found at {self.original_config_path}")
|
|
78
|
+
|
|
79
|
+
# Apply config overrides if any exist
|
|
80
|
+
if app_config.get("cfgow"):
|
|
81
|
+
self._apply_config_overrides(app_config["cfgow"])
|
|
82
|
+
|
|
83
|
+
def _apply_config_overrides(self, overrides):
|
|
84
|
+
"""
|
|
85
|
+
Apply configuration overrides to all JSON files in config folder.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
overrides: Dictionary of nested key->value overrides
|
|
89
|
+
"""
|
|
90
|
+
import shutil
|
|
91
|
+
import tempfile
|
|
92
|
+
|
|
93
|
+
if self.temp_config:
|
|
94
|
+
# Create temporary config copy
|
|
95
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="mxx_config_"))
|
|
96
|
+
shutil.copytree(self.original_config_path, temp_dir / "config")
|
|
97
|
+
self.config_path = temp_dir / "config"
|
|
98
|
+
else:
|
|
99
|
+
# Modify original config in place
|
|
100
|
+
self.config_path = self.original_config_path
|
|
101
|
+
|
|
102
|
+
# Apply overrides to all JSON files
|
|
103
|
+
for json_file in self.config_path.rglob("*.json"):
|
|
104
|
+
try:
|
|
105
|
+
config_data = load_json_config(json_file)
|
|
106
|
+
|
|
107
|
+
# Apply each override
|
|
108
|
+
for override_key, override_value in overrides.items():
|
|
109
|
+
nested_set(config_data, override_key, override_value)
|
|
110
|
+
|
|
111
|
+
# Save modified config
|
|
112
|
+
save_json_config(json_file, config_data)
|
|
113
|
+
|
|
114
|
+
except Exception as e:
|
|
115
|
+
print(f"Warning: Could not apply overrides to {json_file.name}: {e}")
|
|
116
|
+
|
|
117
|
+
@hook("action")
|
|
118
|
+
def launch_application(self, runner):
|
|
119
|
+
"""
|
|
120
|
+
Launch the registered application in detached mode.
|
|
121
|
+
"""
|
|
122
|
+
self._open_detached([str(self.executable_path)])
|
|
123
|
+
|
|
124
|
+
@hook("post_action")
|
|
125
|
+
def cleanup(self, runner):
|
|
126
|
+
"""
|
|
127
|
+
Clean up temporary files and terminate the application.
|
|
128
|
+
"""
|
|
129
|
+
# Clean up temporary config if used
|
|
130
|
+
if self.temp_config and self.config_path and self.config_path != self.original_config_path:
|
|
131
|
+
import shutil
|
|
132
|
+
try:
|
|
133
|
+
shutil.rmtree(self.config_path.parent)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
print(f"Warning: Could not clean up temporary config: {e}")
|
|
136
|
+
|
|
137
|
+
# Terminate the application using executable name from registry
|
|
138
|
+
if self.app_info:
|
|
139
|
+
executable_name = Path(self.app_info["config"]["app"]).name
|
|
140
|
+
os.system(f"taskkill /IM {executable_name} /F")
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _open_detached(args):
|
|
144
|
+
"""
|
|
145
|
+
Open a process in detached mode (doesn't block).
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
args: List of command arguments, first element is the executable path
|
|
149
|
+
"""
|
|
150
|
+
DETACHED_PROCESS = 0x00000008
|
|
151
|
+
subprocess.Popen(
|
|
152
|
+
args,
|
|
153
|
+
creationflags=DETACHED_PROCESS,
|
|
154
|
+
stdout=subprocess.DEVNULL,
|
|
155
|
+
stderr=subprocess.DEVNULL,
|
|
156
|
+
stdin=subprocess.DEVNULL
|
|
157
|
+
)
|
|
158
|
+
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MXX Config Set plugin for importing configurations to registered applications.
|
|
3
|
+
|
|
4
|
+
This plugin imports configuration folders/files to registered applications,
|
|
5
|
+
similar to the mxx cfg import command but usable within runner workflows.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from mxx.runner.core.plugin import MxxPlugin, hook
|
|
10
|
+
from mxx.cfg_tool.registry import get_app_by_name, load_json_config, save_json_config
|
|
11
|
+
from mxx.utils.nested import nested_get, nested_set
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MxxSet(MxxPlugin):
|
|
15
|
+
"""
|
|
16
|
+
Configuration import plugin for registered applications.
|
|
17
|
+
|
|
18
|
+
Imports and merges configuration folders for registered applications,
|
|
19
|
+
preserving excluded keys and applying overrides. This provides the same
|
|
20
|
+
functionality as `mxx cfg import` but within runner workflows.
|
|
21
|
+
|
|
22
|
+
Config Key: "mxxset"
|
|
23
|
+
|
|
24
|
+
Example Configuration:
|
|
25
|
+
```toml
|
|
26
|
+
[mxxset]
|
|
27
|
+
app_name = "myapp" # Name/alias from registry
|
|
28
|
+
import_source = "backup1" # Import folder (relative to exports or absolute)
|
|
29
|
+
```
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
__cmdname__ = "mxxset"
|
|
33
|
+
|
|
34
|
+
def __init__(self, app_name: str = None, import_source: str = None, **kwargs):
|
|
35
|
+
super().__init__()
|
|
36
|
+
self.app_name = app_name
|
|
37
|
+
self.import_source = import_source
|
|
38
|
+
self.app_info = None
|
|
39
|
+
|
|
40
|
+
# Validation
|
|
41
|
+
if not self.app_name:
|
|
42
|
+
raise ValueError("app_name must be specified for MxxSet configuration.")
|
|
43
|
+
if not self.import_source:
|
|
44
|
+
raise ValueError("import_source must be specified for MxxSet configuration.")
|
|
45
|
+
|
|
46
|
+
@hook("action")
|
|
47
|
+
def import_configuration(self, runner):
|
|
48
|
+
"""
|
|
49
|
+
Import and merge configuration for the registered application.
|
|
50
|
+
|
|
51
|
+
Loads the app from registry, resolves import source path, and performs
|
|
52
|
+
the same smart merge as the cfg import command.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If app not found in registry
|
|
56
|
+
FileNotFoundError: If import source not found
|
|
57
|
+
"""
|
|
58
|
+
# Load app from registry
|
|
59
|
+
self.app_info = get_app_by_name(self.app_name)
|
|
60
|
+
if not self.app_info:
|
|
61
|
+
raise ValueError(f"Application '{self.app_name}' not found in MXX registry")
|
|
62
|
+
|
|
63
|
+
app_config = self.app_info["config"]
|
|
64
|
+
uid = self.app_info["uid"]
|
|
65
|
+
|
|
66
|
+
# Get target config folder path
|
|
67
|
+
target_config_folder = Path(app_config["path"]) / app_config["cfgroute"]
|
|
68
|
+
|
|
69
|
+
# Resolve import path - first try relative to app's exports directory
|
|
70
|
+
import_path = self._resolve_import_path(uid)
|
|
71
|
+
|
|
72
|
+
if not import_path.exists():
|
|
73
|
+
raise FileNotFoundError(f"Import source not found at {import_path}")
|
|
74
|
+
|
|
75
|
+
if not import_path.is_dir():
|
|
76
|
+
raise ValueError(f"Import source is not a directory: {import_path}")
|
|
77
|
+
|
|
78
|
+
# Ensure target config folder exists
|
|
79
|
+
target_config_folder.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
# Import configuration
|
|
82
|
+
self._import_config_folder(import_path, target_config_folder, app_config)
|
|
83
|
+
|
|
84
|
+
def _resolve_import_path(self, uid: str) -> Path:
|
|
85
|
+
"""
|
|
86
|
+
Resolve import source path, trying exports directory first.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
uid: App UID for exports directory resolution
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Resolved Path object
|
|
93
|
+
"""
|
|
94
|
+
import_path = Path(self.import_source)
|
|
95
|
+
|
|
96
|
+
# If not absolute, try relative to app's exports directory first
|
|
97
|
+
if not import_path.is_absolute():
|
|
98
|
+
app_exports_dir = Path.home() / ".mxx" / "exports" / uid
|
|
99
|
+
relative_import_path = app_exports_dir / self.import_source
|
|
100
|
+
|
|
101
|
+
if relative_import_path.exists() and relative_import_path.is_dir():
|
|
102
|
+
return relative_import_path
|
|
103
|
+
|
|
104
|
+
return import_path
|
|
105
|
+
|
|
106
|
+
def _import_config_folder(self, import_path: Path, target_path: Path, app_config: dict):
|
|
107
|
+
"""
|
|
108
|
+
Import configuration folder with smart merging.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
import_path: Source folder to import from
|
|
112
|
+
target_path: Target folder to import to
|
|
113
|
+
app_config: App configuration containing cfge and cfgow settings
|
|
114
|
+
"""
|
|
115
|
+
processed_files = 0
|
|
116
|
+
total_preserved = 0
|
|
117
|
+
|
|
118
|
+
# Process all JSON files in the import folder
|
|
119
|
+
for import_json_file in import_path.rglob("*.json"):
|
|
120
|
+
try:
|
|
121
|
+
# Get relative path to maintain folder structure
|
|
122
|
+
rel_path = import_json_file.relative_to(import_path)
|
|
123
|
+
target_json_file = target_path / rel_path
|
|
124
|
+
|
|
125
|
+
# Ensure target directory exists
|
|
126
|
+
target_json_file.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
|
|
128
|
+
# Load import data
|
|
129
|
+
import_config_data = load_json_config(import_json_file)
|
|
130
|
+
|
|
131
|
+
# Load existing target config or create empty
|
|
132
|
+
if target_json_file.exists():
|
|
133
|
+
target_config = load_json_config(target_json_file)
|
|
134
|
+
else:
|
|
135
|
+
target_config = {}
|
|
136
|
+
|
|
137
|
+
# Preserve excluded keys (cfge) from target
|
|
138
|
+
preserved_values = {}
|
|
139
|
+
if "cfge" in app_config:
|
|
140
|
+
for exclude_key in app_config["cfge"]:
|
|
141
|
+
value = nested_get(target_config, exclude_key)
|
|
142
|
+
if value is not None:
|
|
143
|
+
preserved_values[exclude_key] = value
|
|
144
|
+
|
|
145
|
+
# Update target config with imported data
|
|
146
|
+
target_config.update(import_config_data)
|
|
147
|
+
|
|
148
|
+
# Restore preserved excluded keys
|
|
149
|
+
for key, value in preserved_values.items():
|
|
150
|
+
nested_set(target_config, key, value)
|
|
151
|
+
|
|
152
|
+
# Apply overrides (cfgow)
|
|
153
|
+
if "cfgow" in app_config:
|
|
154
|
+
for override_key, override_value in app_config["cfgow"].items():
|
|
155
|
+
nested_set(target_config, override_key, override_value)
|
|
156
|
+
|
|
157
|
+
# Save updated config
|
|
158
|
+
save_json_config(target_json_file, target_config)
|
|
159
|
+
|
|
160
|
+
processed_files += 1
|
|
161
|
+
total_preserved += len(preserved_values)
|
|
162
|
+
|
|
163
|
+
except Exception as e:
|
|
164
|
+
print(f"Warning: Could not process {import_json_file.name}: {e}")
|
|
165
|
+
|
|
166
|
+
print(f"MxxSet imported configuration for '{self.app_name}':")
|
|
167
|
+
print(f" Source: {import_path}")
|
|
168
|
+
print(f" Target: {target_path}")
|
|
169
|
+
print(f" Processed {processed_files} JSON files")
|
|
170
|
+
print(f" Preserved {total_preserved} excluded keys")
|
|
171
|
+
print(f" Applied {len(app_config.get('cfgow', {}))} override patterns")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OS command execution plugin for running arbitrary system commands.
|
|
3
|
+
|
|
4
|
+
This plugin executes system commands at startup and optionally registers
|
|
5
|
+
processes for cleanup via the lifetime plugin.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from mxx.runner.core.plugin import MxxPlugin, hook
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OSExec(MxxPlugin):
|
|
13
|
+
"""
|
|
14
|
+
System command execution plugin.
|
|
15
|
+
|
|
16
|
+
Executes arbitrary OS commands at startup and optionally registers
|
|
17
|
+
processes for cleanup when the lifetime plugin is present.
|
|
18
|
+
|
|
19
|
+
Config Key: "os"
|
|
20
|
+
|
|
21
|
+
Features:
|
|
22
|
+
- Execute any system command via os.system()
|
|
23
|
+
- Register processes for automatic cleanup on shutdown
|
|
24
|
+
- Integrates with lifetime plugin for process management
|
|
25
|
+
|
|
26
|
+
Example Configuration:
|
|
27
|
+
```toml
|
|
28
|
+
[os]
|
|
29
|
+
cmd = "start C:/Tools/monitor.exe"
|
|
30
|
+
kill = "monitor.exe"
|
|
31
|
+
|
|
32
|
+
[lifetime]
|
|
33
|
+
lifetime = 3600
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Notes:
|
|
37
|
+
- Commands are executed synchronously
|
|
38
|
+
- If 'kill' is specified and lifetime plugin is loaded, the process
|
|
39
|
+
will be added to the lifetime plugin's kill list
|
|
40
|
+
- Supports both simple process names and commands with arguments
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
__cmdname__ = "os"
|
|
44
|
+
|
|
45
|
+
def __init__(self, cmd: str = None, kill: str = None, **kwargs):
|
|
46
|
+
super().__init__()
|
|
47
|
+
self.cmd = cmd
|
|
48
|
+
self.kill = kill
|
|
49
|
+
|
|
50
|
+
@hook("action")
|
|
51
|
+
def execute_command(self, runner):
|
|
52
|
+
"""
|
|
53
|
+
Execute the configured system command.
|
|
54
|
+
|
|
55
|
+
If a kill target is specified and the lifetime plugin is loaded,
|
|
56
|
+
registers the process for automatic termination on shutdown.
|
|
57
|
+
"""
|
|
58
|
+
if self.cmd:
|
|
59
|
+
os.system(self.cmd)
|
|
60
|
+
|
|
61
|
+
# Try to register with lifetime plugin if it exists
|
|
62
|
+
if self.kill:
|
|
63
|
+
lifetime_plugin = self._find_lifetime_plugin(runner)
|
|
64
|
+
if lifetime_plugin:
|
|
65
|
+
if " " in self.kill:
|
|
66
|
+
lifetime_plugin.killList.append(("cmd", self.kill.split(" ")[0]))
|
|
67
|
+
else:
|
|
68
|
+
lifetime_plugin.killList.append(("process", self.kill))
|
|
69
|
+
|
|
70
|
+
def _find_lifetime_plugin(self, runner):
|
|
71
|
+
"""Find the lifetime plugin in the runner's plugins."""
|
|
72
|
+
from mxx.runner.builtins.lifetime import Lifetime
|
|
73
|
+
|
|
74
|
+
if hasattr(runner, 'plugins'):
|
|
75
|
+
for plugin in runner.plugins.values():
|
|
76
|
+
if isinstance(plugin, Lifetime):
|
|
77
|
+
return plugin
|
|
78
|
+
return None
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class MxxCallstack:
|
|
8
|
+
any_cond : list[callable] = field(default_factory=list)
|
|
9
|
+
all_cond : list[callable] = field(default_factory=list)
|
|
10
|
+
action : list[callable] = field(default_factory=list)
|
|
11
|
+
pre_action : list[callable] = field(default_factory=list)
|
|
12
|
+
post_action : list[callable] = field(default_factory=list)
|
|
13
|
+
on_true : list[callable] = field(default_factory=list)
|
|
14
|
+
on_false : list[callable] = field(default_factory=list)
|
|
15
|
+
on_error : list[callable] = field(default_factory=list)
|
|
16
|
+
|
|
17
|
+
def merge(self, other : "MxxCallstack"):
|
|
18
|
+
for hook_type in self.__dataclass_fields__.keys():
|
|
19
|
+
getattr(self, hook_type).extend(getattr(other, hook_type))
|
|
20
|
+
|
|
21
|
+
class PluginCallstackMeta(type):
|
|
22
|
+
_callstackMap : dict[str, MxxCallstack] = {}
|
|
23
|
+
|
|
24
|
+
def __call__(cls, *args, **kwargs):
|
|
25
|
+
instance = super().__call__(*args, **kwargs)
|
|
26
|
+
|
|
27
|
+
if instance.__cmdname__ in cls._callstackMap:
|
|
28
|
+
raise Exception(f"Callstack for plugin '{instance.__cmdname__}' is already created")
|
|
29
|
+
|
|
30
|
+
callstack = MxxCallstack()
|
|
31
|
+
cls._callstackMap[instance.__cmdname__] = callstack
|
|
32
|
+
|
|
33
|
+
# map all hook functions to the callstack
|
|
34
|
+
for attr_name in dir(instance):
|
|
35
|
+
attr = getattr(instance, attr_name)
|
|
36
|
+
if callable(attr) and hasattr(attr, "_mxx_hook_types"):
|
|
37
|
+
hook_type = attr._mxx_hook_types
|
|
38
|
+
if hasattr(callstack, hook_type):
|
|
39
|
+
getattr(callstack, hook_type).append(attr)
|
|
40
|
+
else:
|
|
41
|
+
raise Exception(f"Invalid hook type '{hook_type}' for function '{attr_name}'")
|
|
42
|
+
|
|
43
|
+
return instance
|
|
44
|
+
|
|
45
|
+
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration file loader for MXX runner.
|
|
3
|
+
|
|
4
|
+
Supports loading configuration from YAML, TOML, and JSON files.
|
|
5
|
+
Auto-detects format based on file extension.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_config(filepath: str | Path) -> Dict[str, Any]:
|
|
14
|
+
"""
|
|
15
|
+
Load configuration from a file.
|
|
16
|
+
|
|
17
|
+
Supports:
|
|
18
|
+
- YAML (.yaml, .yml)
|
|
19
|
+
- TOML (.toml)
|
|
20
|
+
- JSON (.json)
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
filepath: Path to configuration file
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Configuration dictionary
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
FileNotFoundError: If config file doesn't exist
|
|
30
|
+
ValueError: If file format is unsupported
|
|
31
|
+
ImportError: If required library for format is not installed
|
|
32
|
+
"""
|
|
33
|
+
path = Path(filepath)
|
|
34
|
+
|
|
35
|
+
if not path.exists():
|
|
36
|
+
raise FileNotFoundError(f"Config file not found: {filepath}")
|
|
37
|
+
|
|
38
|
+
suffix = path.suffix.lower()
|
|
39
|
+
|
|
40
|
+
if suffix == '.json':
|
|
41
|
+
return _load_json(path)
|
|
42
|
+
elif suffix == '.toml':
|
|
43
|
+
return _load_toml(path)
|
|
44
|
+
elif suffix in ['.yaml', '.yml']:
|
|
45
|
+
return _load_yaml(path)
|
|
46
|
+
else:
|
|
47
|
+
raise ValueError(f"Unsupported config file format: {suffix}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load_json(path: Path) -> Dict[str, Any]:
|
|
51
|
+
"""Load JSON configuration."""
|
|
52
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
53
|
+
return json.load(f)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load_toml(path: Path) -> Dict[str, Any]:
|
|
57
|
+
"""Load TOML configuration."""
|
|
58
|
+
try:
|
|
59
|
+
import tomli
|
|
60
|
+
except ImportError:
|
|
61
|
+
try:
|
|
62
|
+
import tomllib as tomli # Python 3.11+
|
|
63
|
+
except ImportError:
|
|
64
|
+
raise ImportError(
|
|
65
|
+
"TOML support requires 'tomli' package. "
|
|
66
|
+
"Install with: pip install tomli"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
with open(path, 'rb') as f:
|
|
70
|
+
return tomli.load(f)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load_yaml(path: Path) -> Dict[str, Any]:
|
|
74
|
+
"""Load YAML configuration."""
|
|
75
|
+
try:
|
|
76
|
+
import yaml
|
|
77
|
+
except ImportError:
|
|
78
|
+
raise ImportError(
|
|
79
|
+
"YAML support requires 'pyyaml' package. "
|
|
80
|
+
"Install with: pip install pyyaml"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
84
|
+
return yaml.safe_load(f)
|
mxx/runner/core/enums.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
|
|
2
|
+
from mxx.runner.core.callstack import PluginCallstackMeta
|
|
3
|
+
from mxx.runner.core.enums import hook_types
|
|
4
|
+
|
|
5
|
+
class MxxPlugin(metaclass=PluginCallstackMeta):
|
|
6
|
+
__cmdname__ : str = None
|
|
7
|
+
|
|
8
|
+
def hook(hook_type : str):
|
|
9
|
+
def decorator(func):
|
|
10
|
+
if hook_type not in hook_types:
|
|
11
|
+
raise Exception(f"Invalid hook type: {hook_type}")
|
|
12
|
+
|
|
13
|
+
if hasattr(func, "_mxx_hook_types"):
|
|
14
|
+
raise Exception("Function is already registered as a hook")
|
|
15
|
+
|
|
16
|
+
# Mark the function with hook type
|
|
17
|
+
# Note: At class definition time, this is an unbound function
|
|
18
|
+
# The metaclass will bind it to the instance and register it
|
|
19
|
+
func._mxx_hook_types = hook_type
|
|
20
|
+
return func
|
|
21
|
+
|
|
22
|
+
return decorator
|
|
23
|
+
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import importlib.util
|
|
4
|
+
import sys
|
|
5
|
+
import logging
|
|
6
|
+
from mxx.runner.builtins.lifetime import Lifetime
|
|
7
|
+
from mxx.runner.builtins.os_exec import OSExec
|
|
8
|
+
from mxx.runner.builtins.app_launcher import AppLauncher
|
|
9
|
+
from mxx.runner.builtins.mxxrun import MxxRun
|
|
10
|
+
from mxx.runner.builtins.mxxset import MxxSet
|
|
11
|
+
from mxx.runner.core.plugin import BasePlugin
|
|
12
|
+
|
|
13
|
+
#home/.mxx/plugins
|
|
14
|
+
PLUGIN_PATH = Path.home() / ".mxx" / "plugins"
|
|
15
|
+
|
|
16
|
+
BUILTIN_MAPPINGS = {
|
|
17
|
+
"lifetime": Lifetime,
|
|
18
|
+
"os": OSExec,
|
|
19
|
+
"app": AppLauncher,
|
|
20
|
+
"mxxrun": MxxRun,
|
|
21
|
+
"mxxset": MxxSet,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Start with builtins, will be extended with custom plugins from PLUGIN_PATH
|
|
25
|
+
MAPPINGS = BUILTIN_MAPPINGS.copy()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_custom_plugins():
|
|
29
|
+
"""
|
|
30
|
+
Load custom plugins from ~/.mxx/plugins/*.py
|
|
31
|
+
|
|
32
|
+
Scans the plugins directory for Python files and loads classes
|
|
33
|
+
that inherit from BasePlugin.
|
|
34
|
+
"""
|
|
35
|
+
if not PLUGIN_PATH.exists():
|
|
36
|
+
PLUGIN_PATH.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
logging.info(f"Created plugin directory: {PLUGIN_PATH}")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
# Find all .py files in plugins directory
|
|
41
|
+
plugin_files = list(PLUGIN_PATH.glob("*.py"))
|
|
42
|
+
|
|
43
|
+
if not plugin_files:
|
|
44
|
+
logging.debug(f"No custom plugins found in {PLUGIN_PATH}")
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
loaded_count = 0
|
|
48
|
+
|
|
49
|
+
for plugin_file in plugin_files:
|
|
50
|
+
# Skip __init__.py and private files
|
|
51
|
+
if plugin_file.name.startswith("_"):
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Load the module dynamically
|
|
56
|
+
module_name = f"mxx.plugins.{plugin_file.stem}"
|
|
57
|
+
spec = importlib.util.spec_from_file_location(module_name, plugin_file)
|
|
58
|
+
|
|
59
|
+
if spec is None or spec.loader is None:
|
|
60
|
+
logging.warning(f"Could not load spec for {plugin_file.name}")
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
module = importlib.util.module_from_spec(spec)
|
|
64
|
+
sys.modules[module_name] = module
|
|
65
|
+
spec.loader.exec_module(module)
|
|
66
|
+
|
|
67
|
+
# Find all BasePlugin subclasses in the module
|
|
68
|
+
for attr_name in dir(module):
|
|
69
|
+
attr = getattr(module, attr_name)
|
|
70
|
+
|
|
71
|
+
# Check if it's a class and inherits from BasePlugin
|
|
72
|
+
if (isinstance(attr, type) and
|
|
73
|
+
issubclass(attr, BasePlugin) and
|
|
74
|
+
attr is not BasePlugin):
|
|
75
|
+
|
|
76
|
+
# Use the plugin's name attribute or class name
|
|
77
|
+
plugin_name = getattr(attr, 'name', attr_name.lower())
|
|
78
|
+
|
|
79
|
+
# Register the plugin
|
|
80
|
+
MAPPINGS[plugin_name] = attr
|
|
81
|
+
loaded_count += 1
|
|
82
|
+
logging.info(f"Loaded custom plugin '{plugin_name}' from {plugin_file.name}")
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logging.error(f"Failed to load plugin from {plugin_file.name}: {e}", exc_info=True)
|
|
86
|
+
|
|
87
|
+
if loaded_count > 0:
|
|
88
|
+
logging.info(f"Loaded {loaded_count} custom plugin(s) from {PLUGIN_PATH}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def initialize_registry():
|
|
92
|
+
"""
|
|
93
|
+
Initialize the plugin registry by loading custom plugins.
|
|
94
|
+
|
|
95
|
+
This should be called once at application startup.
|
|
96
|
+
"""
|
|
97
|
+
_load_custom_plugins()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Auto-initialize on import
|
|
101
|
+
initialize_registry()
|