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/server/routes.py ADDED
@@ -0,0 +1,370 @@
1
+ """
2
+ Flask routes for MXX scheduler service.
3
+
4
+ Provides HTTP API for:
5
+ - Scheduling jobs
6
+ - Listing jobs and their status
7
+ - Checking active jobs
8
+ - Canceling jobs
9
+ - Removing completed jobs
10
+ - Triggering on-demand jobs
11
+ """
12
+
13
+ from flask import Blueprint, request, jsonify, current_app
14
+ from mxx.server.schedule import ScheduleConfig
15
+ import logging
16
+
17
+ # Create blueprint for scheduler routes
18
+ scheduler_bp = Blueprint('scheduler', __name__, url_prefix='/api/scheduler')
19
+
20
+
21
+ def get_scheduler_service():
22
+ """Get scheduler service from Flask app config"""
23
+ return current_app.config.get('SCHEDULER_SERVICE')
24
+
25
+
26
+ @scheduler_bp.route('/jobs', methods=['POST'])
27
+ def schedule_job():
28
+ """
29
+ Schedule a new job.
30
+
31
+ Request body:
32
+ {
33
+ "job_id": "my_job_1",
34
+ "config": {
35
+ "lifetime": {"lifetime": 3600},
36
+ "os": {"cmd": "echo test"}
37
+ },
38
+ "schedule": { // Optional - if omitted, registered as on-demand
39
+ "trigger": "cron",
40
+ "hour": 10,
41
+ "minute": 30
42
+ },
43
+ "replace_existing": false // Optional - default false
44
+ }
45
+
46
+ Returns:
47
+ 200: Job scheduled successfully
48
+ 400: Invalid request or schedule overlap
49
+ 500: Server error
50
+ """
51
+ try:
52
+ data = request.get_json()
53
+
54
+ if not data:
55
+ return jsonify({"error": "Request body required"}), 400
56
+
57
+ job_id = data.get('job_id')
58
+ config = data.get('config')
59
+ schedule_data = data.get('schedule')
60
+ replace_existing = data.get('replace_existing', False)
61
+
62
+ if not job_id:
63
+ return jsonify({"error": "job_id is required"}), 400
64
+
65
+ if not config:
66
+ return jsonify({"error": "config is required"}), 400
67
+
68
+ # Parse schedule if provided
69
+ schedule_config = None
70
+ if schedule_data:
71
+ try:
72
+ schedule_config = ScheduleConfig(**schedule_data)
73
+ except Exception as e:
74
+ return jsonify({"error": f"Invalid schedule configuration: {str(e)}"}), 400
75
+
76
+ # Schedule the job
77
+ scheduler = get_scheduler_service()
78
+ result = scheduler.schedule_job(
79
+ job_id=job_id,
80
+ config=config,
81
+ schedule_config=schedule_config,
82
+ replace_existing=replace_existing
83
+ )
84
+
85
+ return jsonify(result), 200
86
+
87
+ except ValueError as e:
88
+ return jsonify({"error": str(e)}), 400
89
+ except Exception as e:
90
+ logging.error(f"Error scheduling job: {e}", exc_info=True)
91
+ return jsonify({"error": "Internal server error"}), 500
92
+
93
+
94
+ @scheduler_bp.route('/jobs', methods=['GET'])
95
+ def list_jobs():
96
+ """
97
+ List all jobs with their status.
98
+
99
+ Returns:
100
+ 200: List of all jobs
101
+ """
102
+ try:
103
+ scheduler = get_scheduler_service()
104
+ jobs = scheduler.list_jobs()
105
+ return jsonify({"jobs": jobs, "count": len(jobs)}), 200
106
+ except Exception as e:
107
+ logging.error(f"Error listing jobs: {e}", exc_info=True)
108
+ return jsonify({"error": "Internal server error"}), 500
109
+
110
+
111
+ @scheduler_bp.route('/jobs/active', methods=['GET'])
112
+ def list_active_jobs():
113
+ """
114
+ List currently running jobs.
115
+
116
+ Returns:
117
+ 200: List of active jobs
118
+ """
119
+ try:
120
+ scheduler = get_scheduler_service()
121
+ active_jobs = scheduler.list_active_jobs()
122
+ return jsonify({"jobs": active_jobs, "count": len(active_jobs)}), 200
123
+ except Exception as e:
124
+ logging.error(f"Error listing active jobs: {e}", exc_info=True)
125
+ return jsonify({"error": "Internal server error"}), 500
126
+
127
+
128
+ @scheduler_bp.route('/jobs/<job_id>', methods=['GET'])
129
+ def get_job_status(job_id: str):
130
+ """
131
+ Get status of a specific job.
132
+
133
+ Args:
134
+ job_id: Job identifier
135
+
136
+ Returns:
137
+ 200: Job status
138
+ 404: Job not found
139
+ """
140
+ try:
141
+ scheduler = get_scheduler_service()
142
+ status = scheduler.get_job_status(job_id)
143
+
144
+ if not status:
145
+ return jsonify({"error": f"Job '{job_id}' not found"}), 404
146
+
147
+ return jsonify(status), 200
148
+ except Exception as e:
149
+ logging.error(f"Error getting job status: {e}", exc_info=True)
150
+ return jsonify({"error": "Internal server error"}), 500
151
+
152
+
153
+ @scheduler_bp.route('/jobs/<job_id>', methods=['DELETE'])
154
+ def cancel_job(job_id: str):
155
+ """
156
+ Cancel a scheduled job (cannot stop running jobs).
157
+
158
+ Args:
159
+ job_id: Job identifier
160
+
161
+ Returns:
162
+ 200: Job cancelled successfully
163
+ 400: Cannot cancel running job
164
+ 404: Job not found
165
+ """
166
+ try:
167
+ scheduler = get_scheduler_service()
168
+
169
+ # Check if job exists
170
+ status = scheduler.get_job_status(job_id)
171
+ if not status:
172
+ return jsonify({"error": f"Job '{job_id}' not found"}), 404
173
+
174
+ # Check if job is running
175
+ if status['status'] == 'running':
176
+ return jsonify({
177
+ "error": "Cannot cancel running job",
178
+ "hint": "Running jobs cannot be stopped mid-execution"
179
+ }), 400
180
+
181
+ # Cancel the job
182
+ success = scheduler.cancel_job(job_id)
183
+
184
+ if success:
185
+ return jsonify({"message": f"Job '{job_id}' cancelled successfully"}), 200
186
+ else:
187
+ return jsonify({"error": "Failed to cancel job"}), 500
188
+
189
+ except Exception as e:
190
+ logging.error(f"Error cancelling job: {e}", exc_info=True)
191
+ return jsonify({"error": "Internal server error"}), 500
192
+
193
+
194
+ @scheduler_bp.route('/jobs/<job_id>/remove', methods=['POST'])
195
+ def remove_job(job_id: str):
196
+ """
197
+ Remove a completed/failed job from tracking.
198
+
199
+ Args:
200
+ job_id: Job identifier
201
+
202
+ Returns:
203
+ 200: Job removed successfully
204
+ 400: Cannot remove active job
205
+ 404: Job not found
206
+ """
207
+ try:
208
+ scheduler = get_scheduler_service()
209
+
210
+ # Check if job exists
211
+ status = scheduler.get_job_status(job_id)
212
+ if not status:
213
+ return jsonify({"error": f"Job '{job_id}' not found"}), 404
214
+
215
+ # Check if job can be removed
216
+ if status['status'] in ['pending', 'running']:
217
+ return jsonify({
218
+ "error": "Cannot remove active job",
219
+ "hint": "Cancel the job first before removing it"
220
+ }), 400
221
+
222
+ # Remove the job
223
+ success = scheduler.remove_job(job_id)
224
+
225
+ if success:
226
+ return jsonify({"message": f"Job '{job_id}' removed successfully"}), 200
227
+ else:
228
+ return jsonify({"error": "Failed to remove job"}), 500
229
+
230
+ except Exception as e:
231
+ logging.error(f"Error removing job: {e}", exc_info=True)
232
+ return jsonify({"error": "Internal server error"}), 500
233
+
234
+
235
+ @scheduler_bp.route('/jobs/<job_id>/trigger', methods=['POST'])
236
+ def trigger_job(job_id: str):
237
+ """
238
+ Trigger an on-demand job to run immediately.
239
+
240
+ This creates a one-time execution of a registered job that
241
+ doesn't have a schedule.
242
+
243
+ Args:
244
+ job_id: Job identifier from registry
245
+
246
+ Returns:
247
+ 200: Job triggered successfully
248
+ 404: Job not found
249
+ 400: Job cannot be triggered (validation failed)
250
+ """
251
+ try:
252
+ scheduler = get_scheduler_service()
253
+ result = scheduler.trigger_job(job_id)
254
+ return jsonify(result), 200
255
+
256
+ except ValueError as e:
257
+ return jsonify({"error": str(e)}), 404 if "not found" in str(e).lower() else 400
258
+ except Exception as e:
259
+ logging.error(f"Error triggering job: {e}", exc_info=True)
260
+ return jsonify({"error": "Internal server error"}), 500
261
+
262
+
263
+ @scheduler_bp.route('/registry', methods=['GET'])
264
+ def list_registry():
265
+ """
266
+ List all registered jobs.
267
+
268
+ Query parameters:
269
+ type: Filter by type ("scheduled", "on_demand", "all" - default: "all")
270
+
271
+ Returns:
272
+ 200: List of registered jobs
273
+ """
274
+ try:
275
+ scheduler = get_scheduler_service()
276
+ registry = scheduler.registry
277
+
278
+ filter_type = request.args.get('type', 'all')
279
+
280
+ if filter_type == 'scheduled':
281
+ entries = registry.list_scheduled()
282
+ elif filter_type == 'on_demand':
283
+ entries = registry.list_on_demand()
284
+ else:
285
+ entries = registry.list_all()
286
+
287
+ jobs = [entry.to_dict() for entry in entries]
288
+
289
+ return jsonify({
290
+ "jobs": jobs,
291
+ "count": len(jobs),
292
+ "filter": filter_type
293
+ }), 200
294
+
295
+ except Exception as e:
296
+ logging.error(f"Error listing registry: {e}", exc_info=True)
297
+ return jsonify({"error": "Internal server error"}), 500
298
+
299
+
300
+ @scheduler_bp.route('/registry/<job_id>', methods=['GET'])
301
+ def get_registry_entry(job_id: str):
302
+ """
303
+ Get a specific job from registry.
304
+
305
+ Args:
306
+ job_id: Job identifier
307
+
308
+ Returns:
309
+ 200: Job details
310
+ 404: Job not found
311
+ """
312
+ try:
313
+ scheduler = get_scheduler_service()
314
+ entry = scheduler.registry.get(job_id)
315
+
316
+ if not entry:
317
+ return jsonify({"error": f"Job '{job_id}' not found in registry"}), 404
318
+
319
+ return jsonify(entry.to_dict()), 200
320
+
321
+ except Exception as e:
322
+ logging.error(f"Error getting registry entry: {e}", exc_info=True)
323
+ return jsonify({"error": "Internal server error"}), 500
324
+
325
+
326
+ @scheduler_bp.route('/registry/<job_id>', methods=['DELETE'])
327
+ def unregister_job(job_id: str):
328
+ """
329
+ Remove a job from registry.
330
+
331
+ This also cancels any scheduled execution and removes from tracking.
332
+
333
+ Args:
334
+ job_id: Job identifier
335
+
336
+ Returns:
337
+ 200: Job unregistered successfully
338
+ 404: Job not found
339
+ """
340
+ try:
341
+ scheduler = get_scheduler_service()
342
+
343
+ # Try to cancel scheduled execution
344
+ scheduler.cancel_job(job_id)
345
+
346
+ # Remove from registry
347
+ success = scheduler.registry.unregister(job_id)
348
+
349
+ if success:
350
+ return jsonify({"message": f"Job '{job_id}' unregistered successfully"}), 200
351
+ else:
352
+ return jsonify({"error": f"Job '{job_id}' not found in registry"}), 404
353
+
354
+ except Exception as e:
355
+ logging.error(f"Error unregistering job: {e}", exc_info=True)
356
+ return jsonify({"error": "Internal server error"}), 500
357
+
358
+
359
+ @scheduler_bp.route('/health', methods=['GET'])
360
+ def health_check():
361
+ """
362
+ Health check endpoint.
363
+
364
+ Returns:
365
+ 200: Service is healthy
366
+ """
367
+ return jsonify({
368
+ "status": "healthy",
369
+ "service": "mxx-scheduler"
370
+ }), 200
mxx/server/schedule.py ADDED
@@ -0,0 +1,107 @@
1
+ """
2
+ Schedule configuration for MXX jobs.
3
+
4
+ Supports cron-style and interval-based scheduling compatible with APScheduler.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Optional, Dict, Any
9
+
10
+
11
+ @dataclass
12
+ class ScheduleConfig:
13
+ """
14
+ Configuration for job scheduling.
15
+
16
+ Supports two trigger types:
17
+ 1. cron: Time-based scheduling (daily, weekly, specific times)
18
+ 2. interval: Interval-based scheduling (every N seconds)
19
+
20
+ Examples:
21
+ # Daily at 10:30 AM
22
+ ScheduleConfig(trigger="cron", hour=10, minute=30)
23
+
24
+ # Every hour
25
+ ScheduleConfig(trigger="cron", minute=0)
26
+
27
+ # Every 5 minutes
28
+ ScheduleConfig(trigger="interval", interval_seconds=300)
29
+ """
30
+
31
+ trigger: str # "cron" or "interval"
32
+
33
+ # Cron parameters
34
+ hour: Optional[int] = None
35
+ minute: Optional[int] = None
36
+ second: Optional[int] = None
37
+ day_of_week: Optional[str] = None # "mon", "tue", etc. or "*"
38
+ day: Optional[int] = None # Day of month
39
+
40
+ # Interval parameters
41
+ interval_seconds: Optional[int] = None
42
+
43
+ def __post_init__(self):
44
+ """Validate schedule configuration"""
45
+ if self.trigger not in ["cron", "interval"]:
46
+ raise ValueError(f"Invalid trigger type: {self.trigger}. Must be 'cron' or 'interval'")
47
+
48
+ if self.trigger == "interval" and not self.interval_seconds:
49
+ raise ValueError("interval_seconds required for interval trigger")
50
+
51
+ if self.trigger == "cron":
52
+ # At least one time component should be specified
53
+ if all(v is None for v in [self.hour, self.minute, self.second, self.day_of_week, self.day]):
54
+ raise ValueError("At least one time parameter required for cron trigger")
55
+
56
+ def to_apscheduler_config(self) -> Dict[str, Any]:
57
+ """
58
+ Convert to APScheduler configuration dict.
59
+
60
+ Returns:
61
+ Dict suitable for APScheduler's add_job(**config)
62
+ """
63
+ if self.trigger == "interval":
64
+ return {
65
+ "trigger": "interval",
66
+ "seconds": self.interval_seconds
67
+ }
68
+ else: # cron
69
+ config = {"trigger": "cron"}
70
+
71
+ if self.hour is not None:
72
+ config["hour"] = self.hour
73
+ if self.minute is not None:
74
+ config["minute"] = self.minute
75
+ if self.second is not None:
76
+ config["second"] = self.second
77
+ if self.day_of_week is not None:
78
+ config["day_of_week"] = self.day_of_week
79
+ if self.day is not None:
80
+ config["day"] = self.day
81
+
82
+ return config
83
+
84
+ @staticmethod
85
+ def from_dict(data: dict) -> 'ScheduleConfig':
86
+ """Create ScheduleConfig from dictionary"""
87
+ return ScheduleConfig(**data)
88
+
89
+
90
+ def extract_schedule(config: dict) -> Optional[ScheduleConfig]:
91
+ """
92
+ Extract schedule configuration from a job config dict.
93
+
94
+ Args:
95
+ config: Job configuration dict that may contain 'schedule' key
96
+
97
+ Returns:
98
+ ScheduleConfig if schedule section exists, None otherwise
99
+ """
100
+ schedule_data = config.get('schedule')
101
+ if not schedule_data:
102
+ return None
103
+
104
+ try:
105
+ return ScheduleConfig.from_dict(schedule_data)
106
+ except Exception as e:
107
+ raise ValueError(f"Invalid schedule configuration: {e}")