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
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()
|