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.
@@ -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)
@@ -0,0 +1,11 @@
1
+
2
+ hook_types = [
3
+ "any_cond",
4
+ "all_cond",
5
+ "action",
6
+ "pre_action",
7
+ "post_action",
8
+ "on_true",
9
+ "on_false",
10
+ "on_error"
11
+ ]
@@ -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()