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/client/client.py ADDED
@@ -0,0 +1,316 @@
1
+ """
2
+ MXX Scheduler Client
3
+
4
+ Command-line interface for interacting with the MXX Scheduler Server.
5
+
6
+ Usage:
7
+ mxx-client [OPTIONS] COMMAND [ARGS]...
8
+
9
+ Commands:
10
+ list List all jobs
11
+ status Get status of a specific job
12
+ trigger Trigger an on-demand job
13
+ cancel Cancel a scheduled job
14
+ remove Remove a completed/failed job
15
+ register Register a new job
16
+ unregister Unregister a job
17
+ registry List registered jobs
18
+ health Check server health
19
+ """
20
+
21
+ import click
22
+ import requests
23
+ import json
24
+ import os
25
+ from pathlib import Path
26
+ from typing import Optional
27
+ import sys
28
+
29
+
30
+ def get_server_url() -> str:
31
+ """Get server URL from environment or default"""
32
+ host = os.environ.get('MXX_SERVER_HOST', '127.0.0.1')
33
+ port = os.environ.get('MXX_SERVER_PORT', '5000')
34
+ return f"http://{host}:{port}"
35
+
36
+
37
+ def handle_response(response: requests.Response, success_message: str = None):
38
+ """
39
+ Handle HTTP response and print formatted output.
40
+
41
+ Args:
42
+ response: Response object
43
+ success_message: Optional message to show on success
44
+ """
45
+ try:
46
+ data = response.json()
47
+ except Exception:
48
+ data = {"text": response.text}
49
+
50
+ if response.status_code >= 200 and response.status_code < 300:
51
+ if success_message:
52
+ click.echo(click.style(success_message, fg='green'))
53
+
54
+ # Pretty print JSON data
55
+ if data:
56
+ click.echo(json.dumps(data, indent=2))
57
+ else:
58
+ click.echo(click.style(f"Error: {response.status_code}", fg='red'), err=True)
59
+ if 'error' in data:
60
+ click.echo(click.style(data['error'], fg='red'), err=True)
61
+ if 'hint' in data:
62
+ click.echo(click.style(f"Hint: {data['hint']}", fg='yellow'), err=True)
63
+ else:
64
+ click.echo(json.dumps(data, indent=2), err=True)
65
+ sys.exit(1)
66
+
67
+
68
+ @click.group()
69
+ @click.option('--server', default=None, help='Server URL (default: http://127.0.0.1:5000)')
70
+ @click.pass_context
71
+ def cli(ctx, server: Optional[str]):
72
+ """MXX Scheduler Client - Manage scheduled jobs"""
73
+ ctx.ensure_object(dict)
74
+ ctx.obj['SERVER_URL'] = server or get_server_url()
75
+
76
+
77
+ @cli.command()
78
+ @click.pass_context
79
+ def health(ctx):
80
+ """Check server health"""
81
+ server_url = ctx.obj['SERVER_URL']
82
+
83
+ try:
84
+ response = requests.get(f"{server_url}/api/scheduler/health", timeout=5)
85
+ handle_response(response)
86
+ except requests.exceptions.ConnectionError:
87
+ click.echo(click.style(f"Error: Cannot connect to server at {server_url}", fg='red'), err=True)
88
+ sys.exit(1)
89
+ except Exception as e:
90
+ click.echo(click.style(f"Error: {e}", fg='red'), err=True)
91
+ sys.exit(1)
92
+
93
+
94
+ @cli.command()
95
+ @click.option('--type', 'filter_type', type=click.Choice(['all', 'active', 'scheduled']),
96
+ default='all', help='Filter jobs by type')
97
+ @click.pass_context
98
+ def list(ctx, filter_type: str):
99
+ """List all jobs"""
100
+ server_url = ctx.obj['SERVER_URL']
101
+
102
+ if filter_type == 'active':
103
+ endpoint = f"{server_url}/api/scheduler/jobs/active"
104
+ else:
105
+ endpoint = f"{server_url}/api/scheduler/jobs"
106
+
107
+ try:
108
+ response = requests.get(endpoint, timeout=5)
109
+ handle_response(response)
110
+ except requests.exceptions.ConnectionError:
111
+ click.echo(click.style(f"Error: Cannot connect to server at {server_url}", fg='red'), err=True)
112
+ sys.exit(1)
113
+ except Exception as e:
114
+ click.echo(click.style(f"Error: {e}", fg='red'), err=True)
115
+ sys.exit(1)
116
+
117
+
118
+ @cli.command()
119
+ @click.argument('job_id')
120
+ @click.pass_context
121
+ def status(ctx, job_id: str):
122
+ """Get status of a specific job"""
123
+ server_url = ctx.obj['SERVER_URL']
124
+
125
+ try:
126
+ response = requests.get(f"{server_url}/api/scheduler/jobs/{job_id}", timeout=5)
127
+ handle_response(response)
128
+ except requests.exceptions.ConnectionError:
129
+ click.echo(click.style(f"Error: Cannot connect to server at {server_url}", fg='red'), err=True)
130
+ sys.exit(1)
131
+ except Exception as e:
132
+ click.echo(click.style(f"Error: {e}", fg='red'), err=True)
133
+ sys.exit(1)
134
+
135
+
136
+ @cli.command()
137
+ @click.argument('job_id')
138
+ @click.pass_context
139
+ def trigger(ctx, job_id: str):
140
+ """Trigger an on-demand job to run immediately"""
141
+ server_url = ctx.obj['SERVER_URL']
142
+
143
+ try:
144
+ response = requests.post(f"{server_url}/api/scheduler/jobs/{job_id}/trigger", timeout=5)
145
+ handle_response(response, f"Job '{job_id}' triggered successfully")
146
+ except requests.exceptions.ConnectionError:
147
+ click.echo(click.style(f"Error: Cannot connect to server at {server_url}", fg='red'), err=True)
148
+ sys.exit(1)
149
+ except Exception as e:
150
+ click.echo(click.style(f"Error: {e}", fg='red'), err=True)
151
+ sys.exit(1)
152
+
153
+
154
+ @cli.command()
155
+ @click.argument('job_id')
156
+ @click.pass_context
157
+ def cancel(ctx, job_id: str):
158
+ """Cancel a scheduled job"""
159
+ server_url = ctx.obj['SERVER_URL']
160
+
161
+ try:
162
+ response = requests.delete(f"{server_url}/api/scheduler/jobs/{job_id}", timeout=5)
163
+ handle_response(response, f"Job '{job_id}' cancelled successfully")
164
+ except requests.exceptions.ConnectionError:
165
+ click.echo(click.style(f"Error: Cannot connect to server at {server_url}", fg='red'), err=True)
166
+ sys.exit(1)
167
+ except Exception as e:
168
+ click.echo(click.style(f"Error: {e}", fg='red'), err=True)
169
+ sys.exit(1)
170
+
171
+
172
+ @cli.command()
173
+ @click.argument('job_id')
174
+ @click.pass_context
175
+ def remove(ctx, job_id: str):
176
+ """Remove a completed/failed job from tracking"""
177
+ server_url = ctx.obj['SERVER_URL']
178
+
179
+ try:
180
+ response = requests.post(f"{server_url}/api/scheduler/jobs/{job_id}/remove", timeout=5)
181
+ handle_response(response, f"Job '{job_id}' removed successfully")
182
+ except requests.exceptions.ConnectionError:
183
+ click.echo(click.style(f"Error: Cannot connect to server at {server_url}", fg='red'), err=True)
184
+ sys.exit(1)
185
+ except Exception as e:
186
+ click.echo(click.style(f"Error: {e}", fg='red'), err=True)
187
+ sys.exit(1)
188
+
189
+
190
+ @cli.command()
191
+ @click.argument('config_file', type=click.Path(exists=True))
192
+ @click.option('--job-id', required=True, help='Unique identifier for the job')
193
+ @click.option('--replace', is_flag=True, help='Replace existing job if it exists')
194
+ @click.pass_context
195
+ def register(ctx, config_file: str, job_id: str, replace: bool):
196
+ """
197
+ Register a new job from a configuration file.
198
+
199
+ The config file should be in JSON, TOML, or YAML format.
200
+ It may include an optional [schedule] section.
201
+
202
+ Example TOML:
203
+ \b
204
+ [lifetime]
205
+ lifetime = 3600
206
+
207
+ [os]
208
+ cmd = "echo hello"
209
+
210
+ [schedule]
211
+ trigger = "cron"
212
+ hour = 10
213
+ minute = 30
214
+ """
215
+ server_url = ctx.obj['SERVER_URL']
216
+
217
+ # Load config file
218
+ config_path = Path(config_file)
219
+
220
+ try:
221
+ from mxx.runner.core.config_loader import load_config
222
+ config_data = load_config(config_path)
223
+
224
+ # Extract schedule if present
225
+ schedule_data = config_data.pop('schedule', None)
226
+
227
+ # Prepare request payload
228
+ payload = {
229
+ 'job_id': job_id,
230
+ 'config': config_data,
231
+ 'replace_existing': replace
232
+ }
233
+
234
+ if schedule_data:
235
+ payload['schedule'] = schedule_data
236
+
237
+ # Send request
238
+ response = requests.post(
239
+ f"{server_url}/api/scheduler/jobs",
240
+ json=payload,
241
+ timeout=10
242
+ )
243
+
244
+ handle_response(response, f"Job '{job_id}' registered successfully")
245
+
246
+ except ImportError:
247
+ click.echo(click.style("Error: mxx package not properly installed", fg='red'), err=True)
248
+ sys.exit(1)
249
+ except Exception as e:
250
+ click.echo(click.style(f"Error loading config: {e}", fg='red'), err=True)
251
+ sys.exit(1)
252
+
253
+
254
+ @cli.command()
255
+ @click.argument('job_id')
256
+ @click.pass_context
257
+ def unregister(ctx, job_id: str):
258
+ """Unregister a job from the registry"""
259
+ server_url = ctx.obj['SERVER_URL']
260
+
261
+ try:
262
+ response = requests.delete(f"{server_url}/api/scheduler/registry/{job_id}", timeout=5)
263
+ handle_response(response, f"Job '{job_id}' unregistered successfully")
264
+ except requests.exceptions.ConnectionError:
265
+ click.echo(click.style(f"Error: Cannot connect to server at {server_url}", fg='red'), err=True)
266
+ sys.exit(1)
267
+ except Exception as e:
268
+ click.echo(click.style(f"Error: {e}", fg='red'), err=True)
269
+ sys.exit(1)
270
+
271
+
272
+ @cli.command()
273
+ @click.option('--type', 'filter_type',
274
+ type=click.Choice(['all', 'scheduled', 'on_demand']),
275
+ default='all',
276
+ help='Filter by job type')
277
+ @click.pass_context
278
+ def registry(ctx, filter_type: str):
279
+ """List all registered jobs"""
280
+ server_url = ctx.obj['SERVER_URL']
281
+
282
+ try:
283
+ response = requests.get(
284
+ f"{server_url}/api/scheduler/registry",
285
+ params={'type': filter_type},
286
+ timeout=5
287
+ )
288
+ handle_response(response)
289
+ except requests.exceptions.ConnectionError:
290
+ click.echo(click.style(f"Error: Cannot connect to server at {server_url}", fg='red'), err=True)
291
+ sys.exit(1)
292
+ except Exception as e:
293
+ click.echo(click.style(f"Error: {e}", fg='red'), err=True)
294
+ sys.exit(1)
295
+
296
+
297
+ @cli.command()
298
+ @click.argument('job_id')
299
+ @click.pass_context
300
+ def info(ctx, job_id: str):
301
+ """Get detailed information about a registered job"""
302
+ server_url = ctx.obj['SERVER_URL']
303
+
304
+ try:
305
+ response = requests.get(f"{server_url}/api/scheduler/registry/{job_id}", timeout=5)
306
+ handle_response(response)
307
+ except requests.exceptions.ConnectionError:
308
+ click.echo(click.style(f"Error: Cannot connect to server at {server_url}", fg='red'), err=True)
309
+ sys.exit(1)
310
+ except Exception as e:
311
+ click.echo(click.style(f"Error: {e}", fg='red'), err=True)
312
+ sys.exit(1)
313
+
314
+
315
+ if __name__ == '__main__':
316
+ cli()
@@ -0,0 +1,18 @@
1
+ """
2
+ Built-in plugins for the Mxx runner system.
3
+
4
+ This module provides a collection of ready-to-use plugins for common tasks:
5
+ - lifetime: Time-based execution control and process cleanup
6
+ - os_exec: Execute arbitrary system commands
7
+ - app_launcher: Launch external applications with Scoop integration
8
+ """
9
+
10
+ from .lifetime import Lifetime
11
+ from .os_exec import OSExec
12
+ from .app_launcher import AppLauncher
13
+
14
+ __all__ = [
15
+ "Lifetime",
16
+ "OSExec",
17
+ "AppLauncher",
18
+ ]
@@ -0,0 +1,121 @@
1
+ """
2
+ Application launcher plugin for launching external executables.
3
+
4
+ This plugin manages external executable applications, supporting both
5
+ Scoop-managed installations and custom paths.
6
+ """
7
+
8
+ import os
9
+ import subprocess
10
+ from mxx.runner.core.plugin import MxxPlugin, hook
11
+
12
+
13
+ class AppLauncher(MxxPlugin):
14
+ """
15
+ External executable launcher plugin.
16
+
17
+ Launches and manages external executables with support for:
18
+ - Scoop package manager integration (automatic path resolution)
19
+ - Custom executable paths
20
+ - Automatic termination on shutdown
21
+
22
+ Config Key: "app"
23
+
24
+ The plugin resolves the executable path based on configuration,
25
+ verifies the file exists, launches it in detached mode, and
26
+ terminates it on shutdown.
27
+
28
+ Example Configurations:
29
+ Scoop-managed:
30
+ ```toml
31
+ [app]
32
+ scoop = true
33
+ pkg = "my-app"
34
+ targetExe = "app.exe"
35
+ ```
36
+
37
+ Custom path:
38
+ ```toml
39
+ [app]
40
+ scoop = false
41
+ path = "C:/MyApps/Tool"
42
+ targetExe = "tool.exe"
43
+ delay = 5
44
+ ```
45
+ """
46
+
47
+ __cmdname__ = "app"
48
+
49
+ def __init__(self, scoop: bool = True, pkg: str = None, path: str = None,
50
+ targetExe: str = None, delay: int = 10, **kwargs):
51
+ super().__init__()
52
+ self.scoop = scoop
53
+ self.pkg = pkg
54
+ self.path = path
55
+ self.targetExe = targetExe
56
+ self.delay = delay
57
+ self.executable_path = None
58
+
59
+ # Validation
60
+ if not self.targetExe:
61
+ raise ValueError("targetExe must be specified for App configuration.")
62
+ if self.scoop and not self.pkg:
63
+ raise ValueError("pkg must be specified when scoop is True for App configuration.")
64
+ if self.scoop and self.path:
65
+ raise ValueError("path should not be specified when scoop is True for App configuration.")
66
+
67
+ @hook("action")
68
+ def launch_application(self, runner):
69
+ """
70
+ Resolve executable path and launch the application.
71
+
72
+ For Scoop installations, resolves path using SCOOP environment variable
73
+ or default location. For custom paths, uses the provided directory.
74
+ Verifies the executable exists before launching.
75
+
76
+ Raises:
77
+ FileNotFoundError: If the executable file does not exist
78
+ """
79
+ if self.scoop:
80
+ scoop_path = os.environ.get("SCOOP", os.path.expanduser("~\\scoop"))
81
+ app_path = os.path.join(scoop_path, "apps", self.pkg, "current", self.targetExe)
82
+
83
+ if not os.path.isfile(app_path):
84
+ raise FileNotFoundError(f"App executable not found at {app_path}")
85
+
86
+ self.executable_path = app_path
87
+ else:
88
+ app_path = os.path.join(self.path, self.targetExe)
89
+ if not os.path.isfile(app_path):
90
+ raise FileNotFoundError(f"App executable not found at {app_path}")
91
+
92
+ self.executable_path = app_path
93
+
94
+ # Launch in detached mode
95
+ self._open_detached([self.executable_path])
96
+
97
+ @hook("post_action")
98
+ def shutdown_application(self, runner):
99
+ """
100
+ Forcefully terminate the launched executable.
101
+
102
+ Uses Windows taskkill command to terminate the process by executable name.
103
+ """
104
+ os.system(f"taskkill /IM {self.targetExe} /F")
105
+
106
+ @staticmethod
107
+ def _open_detached(args):
108
+ """
109
+ Open a process in detached mode (doesn't block).
110
+
111
+ Args:
112
+ args: List of command arguments, first element is the executable path
113
+ """
114
+ DETACHED_PROCESS = 0x00000008
115
+ subprocess.Popen(
116
+ args,
117
+ creationflags=DETACHED_PROCESS,
118
+ stdout=subprocess.DEVNULL,
119
+ stderr=subprocess.DEVNULL,
120
+ stdin=subprocess.DEVNULL
121
+ )
@@ -0,0 +1,114 @@
1
+ """
2
+ Lifetime control plugin for time-based execution limits.
3
+
4
+ This plugin manages the duration of runner execution and provides cleanup
5
+ capabilities for processes, commands, and tasks at shutdown.
6
+ """
7
+
8
+ import datetime
9
+ import logging
10
+ import os
11
+ from mxx.runner.core.plugin import MxxPlugin, hook
12
+
13
+
14
+ class Lifetime(MxxPlugin):
15
+ """
16
+ Time-based execution control plugin.
17
+
18
+ Controls how long the runner executes and provides process cleanup at shutdown.
19
+ Other plugins can register items to kill on shutdown by adding to the killList.
20
+
21
+ Config Key: "lifetime"
22
+
23
+ Features:
24
+ - Sets execution duration in seconds
25
+ - Stops runner when time expires
26
+ - Kills registered processes/commands on shutdown
27
+ - Integrates with other plugins for cleanup coordination
28
+
29
+ Example Configuration:
30
+ ```toml
31
+ [lifetime]
32
+ lifetime = 3600 # Run for 1 hour
33
+ ```
34
+
35
+ Kill List Format:
36
+ Plugins can add items to self.killList as tuples:
37
+ - ("process", "process_name.exe") - Terminate by process name using psutil
38
+ - ("cmd", "command") - Terminate using taskkill /IM
39
+ - ("taskkill", "task_name") - Terminate using taskkill /IM
40
+ """
41
+
42
+ __cmdname__ = "lifetime"
43
+
44
+ def __init__(self, lifetime: int = None, **kwargs):
45
+ super().__init__()
46
+ self.lifetime = lifetime
47
+ self.killList = []
48
+ self.targetStopTime = None
49
+
50
+ @hook("all_cond")
51
+ def can_run(self, runner):
52
+ """Check if lifetime is valid (greater than 0)."""
53
+ if self.lifetime is None:
54
+ return True
55
+ return self.lifetime > 0
56
+
57
+ @hook("action")
58
+ def calculate_stop_time(self, runner):
59
+ """Calculate and store the target stop time."""
60
+ if self.lifetime:
61
+ self.targetStopTime = datetime.datetime.now() + datetime.timedelta(seconds=self.lifetime)
62
+
63
+ @hook("on_true")
64
+ def should_continue(self, runner):
65
+ """Return True to continue, False to stop."""
66
+ if self.targetStopTime:
67
+ # Continue while time hasn't expired
68
+ return datetime.datetime.now() < self.targetStopTime
69
+ # No lifetime set, continue indefinitely
70
+ return True
71
+
72
+ @hook("post_action")
73
+ def cleanup(self, runner):
74
+ """
75
+ Terminate all registered processes, commands, and tasks.
76
+
77
+ Iterates through killList and terminates items based on their type:
78
+ - process: Uses psutil to find and terminate by name
79
+ - cmd/taskkill: Uses Windows taskkill command
80
+ """
81
+ for killItem in self.killList:
82
+ match killItem:
83
+ case ("process", procName):
84
+ logging.info(f"Terminating process: {procName}")
85
+ try:
86
+ import psutil
87
+ killed = False
88
+ for proc in psutil.process_iter(['name']):
89
+ try:
90
+ if proc.info['name'] == procName:
91
+ proc.kill()
92
+ proc.wait(timeout=5)
93
+ killed = True
94
+ logging.info(f"Successfully killed {procName}")
95
+ except (psutil.NoSuchProcess, psutil.AccessDenied) as e:
96
+ logging.warning(f"Could not kill process {procName}: {e}")
97
+
98
+ if not killed:
99
+ logging.warning(f"Process {procName} not found, trying taskkill")
100
+ os.system(f"taskkill /IM {procName} /F")
101
+ except Exception as e:
102
+ logging.error(f"Failed to terminate process {procName}: {e}")
103
+ # Fallback to taskkill
104
+ os.system(f"taskkill /IM {procName} /F")
105
+ case ("cmd", cmdName):
106
+ logging.info(f"Terminating command: {cmdName}")
107
+ os.system(f"taskkill /IM {cmdName} /F")
108
+ case ("taskkill", taskName):
109
+ logging.info(f"Terminating task: {taskName}")
110
+ os.system(f"taskkill /IM {taskName} /F")
111
+ case _:
112
+ pass
113
+
114
+ self.killList.clear()