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/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}")
|