mcli-framework 7.0.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.
Potentially problematic release.
This version of mcli-framework might be problematic. Click here for more details.
- mcli/app/chat_cmd.py +42 -0
- mcli/app/commands_cmd.py +226 -0
- mcli/app/completion_cmd.py +216 -0
- mcli/app/completion_helpers.py +288 -0
- mcli/app/cron_test_cmd.py +697 -0
- mcli/app/logs_cmd.py +419 -0
- mcli/app/main.py +492 -0
- mcli/app/model/model.py +1060 -0
- mcli/app/model_cmd.py +227 -0
- mcli/app/redis_cmd.py +269 -0
- mcli/app/video/video.py +1114 -0
- mcli/app/visual_cmd.py +303 -0
- mcli/chat/chat.py +2409 -0
- mcli/chat/command_rag.py +514 -0
- mcli/chat/enhanced_chat.py +652 -0
- mcli/chat/system_controller.py +1010 -0
- mcli/chat/system_integration.py +1016 -0
- mcli/cli.py +25 -0
- mcli/config.toml +20 -0
- mcli/lib/api/api.py +586 -0
- mcli/lib/api/daemon_client.py +203 -0
- mcli/lib/api/daemon_client_local.py +44 -0
- mcli/lib/api/daemon_decorator.py +217 -0
- mcli/lib/api/mcli_decorators.py +1032 -0
- mcli/lib/auth/auth.py +85 -0
- mcli/lib/auth/aws_manager.py +85 -0
- mcli/lib/auth/azure_manager.py +91 -0
- mcli/lib/auth/credential_manager.py +192 -0
- mcli/lib/auth/gcp_manager.py +93 -0
- mcli/lib/auth/key_manager.py +117 -0
- mcli/lib/auth/mcli_manager.py +93 -0
- mcli/lib/auth/token_manager.py +75 -0
- mcli/lib/auth/token_util.py +1011 -0
- mcli/lib/config/config.py +47 -0
- mcli/lib/discovery/__init__.py +1 -0
- mcli/lib/discovery/command_discovery.py +274 -0
- mcli/lib/erd/erd.py +1345 -0
- mcli/lib/erd/generate_graph.py +453 -0
- mcli/lib/files/files.py +76 -0
- mcli/lib/fs/fs.py +109 -0
- mcli/lib/lib.py +29 -0
- mcli/lib/logger/logger.py +611 -0
- mcli/lib/performance/optimizer.py +409 -0
- mcli/lib/performance/rust_bridge.py +502 -0
- mcli/lib/performance/uvloop_config.py +154 -0
- mcli/lib/pickles/pickles.py +50 -0
- mcli/lib/search/cached_vectorizer.py +479 -0
- mcli/lib/services/data_pipeline.py +460 -0
- mcli/lib/services/lsh_client.py +441 -0
- mcli/lib/services/redis_service.py +387 -0
- mcli/lib/shell/shell.py +137 -0
- mcli/lib/toml/toml.py +33 -0
- mcli/lib/ui/styling.py +47 -0
- mcli/lib/ui/visual_effects.py +634 -0
- mcli/lib/watcher/watcher.py +185 -0
- mcli/ml/api/app.py +215 -0
- mcli/ml/api/middleware.py +224 -0
- mcli/ml/api/routers/admin_router.py +12 -0
- mcli/ml/api/routers/auth_router.py +244 -0
- mcli/ml/api/routers/backtest_router.py +12 -0
- mcli/ml/api/routers/data_router.py +12 -0
- mcli/ml/api/routers/model_router.py +302 -0
- mcli/ml/api/routers/monitoring_router.py +12 -0
- mcli/ml/api/routers/portfolio_router.py +12 -0
- mcli/ml/api/routers/prediction_router.py +267 -0
- mcli/ml/api/routers/trade_router.py +12 -0
- mcli/ml/api/routers/websocket_router.py +76 -0
- mcli/ml/api/schemas.py +64 -0
- mcli/ml/auth/auth_manager.py +425 -0
- mcli/ml/auth/models.py +154 -0
- mcli/ml/auth/permissions.py +302 -0
- mcli/ml/backtesting/backtest_engine.py +502 -0
- mcli/ml/backtesting/performance_metrics.py +393 -0
- mcli/ml/cache.py +400 -0
- mcli/ml/cli/main.py +398 -0
- mcli/ml/config/settings.py +394 -0
- mcli/ml/configs/dvc_config.py +230 -0
- mcli/ml/configs/mlflow_config.py +131 -0
- mcli/ml/configs/mlops_manager.py +293 -0
- mcli/ml/dashboard/app.py +532 -0
- mcli/ml/dashboard/app_integrated.py +738 -0
- mcli/ml/dashboard/app_supabase.py +560 -0
- mcli/ml/dashboard/app_training.py +615 -0
- mcli/ml/dashboard/cli.py +51 -0
- mcli/ml/data_ingestion/api_connectors.py +501 -0
- mcli/ml/data_ingestion/data_pipeline.py +567 -0
- mcli/ml/data_ingestion/stream_processor.py +512 -0
- mcli/ml/database/migrations/env.py +94 -0
- mcli/ml/database/models.py +667 -0
- mcli/ml/database/session.py +200 -0
- mcli/ml/experimentation/ab_testing.py +845 -0
- mcli/ml/features/ensemble_features.py +607 -0
- mcli/ml/features/political_features.py +676 -0
- mcli/ml/features/recommendation_engine.py +809 -0
- mcli/ml/features/stock_features.py +573 -0
- mcli/ml/features/test_feature_engineering.py +346 -0
- mcli/ml/logging.py +85 -0
- mcli/ml/mlops/data_versioning.py +518 -0
- mcli/ml/mlops/experiment_tracker.py +377 -0
- mcli/ml/mlops/model_serving.py +481 -0
- mcli/ml/mlops/pipeline_orchestrator.py +614 -0
- mcli/ml/models/base_models.py +324 -0
- mcli/ml/models/ensemble_models.py +675 -0
- mcli/ml/models/recommendation_models.py +474 -0
- mcli/ml/models/test_models.py +487 -0
- mcli/ml/monitoring/drift_detection.py +676 -0
- mcli/ml/monitoring/metrics.py +45 -0
- mcli/ml/optimization/portfolio_optimizer.py +834 -0
- mcli/ml/preprocessing/data_cleaners.py +451 -0
- mcli/ml/preprocessing/feature_extractors.py +491 -0
- mcli/ml/preprocessing/ml_pipeline.py +382 -0
- mcli/ml/preprocessing/politician_trading_preprocessor.py +569 -0
- mcli/ml/preprocessing/test_preprocessing.py +294 -0
- mcli/ml/scripts/populate_sample_data.py +200 -0
- mcli/ml/tasks.py +400 -0
- mcli/ml/tests/test_integration.py +429 -0
- mcli/ml/tests/test_training_dashboard.py +387 -0
- mcli/public/oi/oi.py +15 -0
- mcli/public/public.py +4 -0
- mcli/self/self_cmd.py +1246 -0
- mcli/workflow/daemon/api_daemon.py +800 -0
- mcli/workflow/daemon/async_command_database.py +681 -0
- mcli/workflow/daemon/async_process_manager.py +591 -0
- mcli/workflow/daemon/client.py +530 -0
- mcli/workflow/daemon/commands.py +1196 -0
- mcli/workflow/daemon/daemon.py +905 -0
- mcli/workflow/daemon/daemon_api.py +59 -0
- mcli/workflow/daemon/enhanced_daemon.py +571 -0
- mcli/workflow/daemon/process_cli.py +244 -0
- mcli/workflow/daemon/process_manager.py +439 -0
- mcli/workflow/daemon/test_daemon.py +275 -0
- mcli/workflow/dashboard/dashboard_cmd.py +113 -0
- mcli/workflow/docker/docker.py +0 -0
- mcli/workflow/file/file.py +100 -0
- mcli/workflow/gcloud/config.toml +21 -0
- mcli/workflow/gcloud/gcloud.py +58 -0
- mcli/workflow/git_commit/ai_service.py +328 -0
- mcli/workflow/git_commit/commands.py +430 -0
- mcli/workflow/lsh_integration.py +355 -0
- mcli/workflow/model_service/client.py +594 -0
- mcli/workflow/model_service/download_and_run_efficient_models.py +288 -0
- mcli/workflow/model_service/lightweight_embedder.py +397 -0
- mcli/workflow/model_service/lightweight_model_server.py +714 -0
- mcli/workflow/model_service/lightweight_test.py +241 -0
- mcli/workflow/model_service/model_service.py +1955 -0
- mcli/workflow/model_service/ollama_efficient_runner.py +425 -0
- mcli/workflow/model_service/pdf_processor.py +386 -0
- mcli/workflow/model_service/test_efficient_runner.py +234 -0
- mcli/workflow/model_service/test_example.py +315 -0
- mcli/workflow/model_service/test_integration.py +131 -0
- mcli/workflow/model_service/test_new_features.py +149 -0
- mcli/workflow/openai/openai.py +99 -0
- mcli/workflow/politician_trading/commands.py +1790 -0
- mcli/workflow/politician_trading/config.py +134 -0
- mcli/workflow/politician_trading/connectivity.py +490 -0
- mcli/workflow/politician_trading/data_sources.py +395 -0
- mcli/workflow/politician_trading/database.py +410 -0
- mcli/workflow/politician_trading/demo.py +248 -0
- mcli/workflow/politician_trading/models.py +165 -0
- mcli/workflow/politician_trading/monitoring.py +413 -0
- mcli/workflow/politician_trading/scrapers.py +966 -0
- mcli/workflow/politician_trading/scrapers_california.py +412 -0
- mcli/workflow/politician_trading/scrapers_eu.py +377 -0
- mcli/workflow/politician_trading/scrapers_uk.py +350 -0
- mcli/workflow/politician_trading/scrapers_us_states.py +438 -0
- mcli/workflow/politician_trading/supabase_functions.py +354 -0
- mcli/workflow/politician_trading/workflow.py +852 -0
- mcli/workflow/registry/registry.py +180 -0
- mcli/workflow/repo/repo.py +223 -0
- mcli/workflow/scheduler/commands.py +493 -0
- mcli/workflow/scheduler/cron_parser.py +238 -0
- mcli/workflow/scheduler/job.py +182 -0
- mcli/workflow/scheduler/monitor.py +139 -0
- mcli/workflow/scheduler/persistence.py +324 -0
- mcli/workflow/scheduler/scheduler.py +679 -0
- mcli/workflow/sync/sync_cmd.py +437 -0
- mcli/workflow/sync/test_cmd.py +314 -0
- mcli/workflow/videos/videos.py +242 -0
- mcli/workflow/wakatime/wakatime.py +11 -0
- mcli/workflow/workflow.py +37 -0
- mcli_framework-7.0.0.dist-info/METADATA +479 -0
- mcli_framework-7.0.0.dist-info/RECORD +186 -0
- mcli_framework-7.0.0.dist-info/WHEEL +5 -0
- mcli_framework-7.0.0.dist-info/entry_points.txt +7 -0
- mcli_framework-7.0.0.dist-info/licenses/LICENSE +21 -0
- mcli_framework-7.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cron expression parser for the MCLI scheduler
|
|
3
|
+
|
|
4
|
+
Supports standard cron expressions with some extensions:
|
|
5
|
+
- Standard: minute hour day month weekday
|
|
6
|
+
- Extensions: @yearly, @monthly, @weekly, @daily, @hourly
|
|
7
|
+
- Special: @reboot (run at scheduler start)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
from typing import List, Optional, Set
|
|
13
|
+
|
|
14
|
+
from mcli.lib.logger.logger import get_logger
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CronParseError(Exception):
|
|
20
|
+
"""Exception raised when cron expression cannot be parsed"""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CronExpression:
|
|
26
|
+
"""Parser and calculator for cron expressions"""
|
|
27
|
+
|
|
28
|
+
# Predefined cron shortcuts
|
|
29
|
+
SHORTCUTS = {
|
|
30
|
+
"@yearly": "0 0 1 1 *",
|
|
31
|
+
"@annually": "0 0 1 1 *",
|
|
32
|
+
"@monthly": "0 0 1 * *",
|
|
33
|
+
"@weekly": "0 0 * * 0",
|
|
34
|
+
"@daily": "0 0 * * *",
|
|
35
|
+
"@midnight": "0 0 * * *",
|
|
36
|
+
"@hourly": "0 * * * *",
|
|
37
|
+
"@reboot": "@reboot", # Special case
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def __init__(self, expression: str):
|
|
41
|
+
self.original_expression = expression.strip()
|
|
42
|
+
self.expression = self._normalize_expression(expression)
|
|
43
|
+
self.is_reboot = self.expression == "@reboot"
|
|
44
|
+
|
|
45
|
+
if not self.is_reboot:
|
|
46
|
+
self.fields = self._parse_expression()
|
|
47
|
+
self._validate_fields()
|
|
48
|
+
|
|
49
|
+
def _normalize_expression(self, expression: str) -> str:
|
|
50
|
+
"""Convert shortcuts to standard cron format"""
|
|
51
|
+
expression = expression.strip().lower()
|
|
52
|
+
|
|
53
|
+
if expression in self.SHORTCUTS:
|
|
54
|
+
return self.SHORTCUTS[expression]
|
|
55
|
+
|
|
56
|
+
return expression
|
|
57
|
+
|
|
58
|
+
def _parse_expression(self) -> List[Set[int]]:
|
|
59
|
+
"""Parse cron expression into field sets"""
|
|
60
|
+
if self.is_reboot:
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
parts = self.expression.split()
|
|
64
|
+
if len(parts) != 5:
|
|
65
|
+
raise CronParseError(f"Invalid cron expression: expected 5 fields, got {len(parts)}")
|
|
66
|
+
|
|
67
|
+
fields = []
|
|
68
|
+
ranges = [
|
|
69
|
+
(0, 59), # minute
|
|
70
|
+
(0, 23), # hour
|
|
71
|
+
(1, 31), # day
|
|
72
|
+
(1, 12), # month
|
|
73
|
+
(0, 6), # weekday (0=Sunday)
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
for i, (part, (min_val, max_val)) in enumerate(zip(parts, ranges)):
|
|
77
|
+
field_values = self._parse_field(part, min_val, max_val)
|
|
78
|
+
fields.append(field_values)
|
|
79
|
+
|
|
80
|
+
return fields
|
|
81
|
+
|
|
82
|
+
def _parse_field(self, field: str, min_val: int, max_val: int) -> Set[int]:
|
|
83
|
+
"""Parse a single cron field"""
|
|
84
|
+
if field == "*":
|
|
85
|
+
return set(range(min_val, max_val + 1))
|
|
86
|
+
|
|
87
|
+
values = set()
|
|
88
|
+
|
|
89
|
+
# Handle comma-separated values
|
|
90
|
+
for part in field.split(","):
|
|
91
|
+
part = part.strip()
|
|
92
|
+
|
|
93
|
+
if "/" in part:
|
|
94
|
+
# Handle step values (e.g., */5, 10-20/2)
|
|
95
|
+
range_part, step_part = part.split("/", 1)
|
|
96
|
+
step = int(step_part)
|
|
97
|
+
|
|
98
|
+
if range_part == "*":
|
|
99
|
+
start, end = min_val, max_val
|
|
100
|
+
elif "-" in range_part:
|
|
101
|
+
start, end = map(int, range_part.split("-", 1))
|
|
102
|
+
else:
|
|
103
|
+
start = end = int(range_part)
|
|
104
|
+
|
|
105
|
+
values.update(range(start, end + 1, step))
|
|
106
|
+
|
|
107
|
+
elif "-" in part:
|
|
108
|
+
# Handle ranges (e.g., 1-5)
|
|
109
|
+
start, end = map(int, part.split("-", 1))
|
|
110
|
+
values.update(range(start, end + 1))
|
|
111
|
+
|
|
112
|
+
else:
|
|
113
|
+
# Handle single values
|
|
114
|
+
values.add(int(part))
|
|
115
|
+
|
|
116
|
+
# Validate values are within range
|
|
117
|
+
for value in values:
|
|
118
|
+
if not (min_val <= value <= max_val):
|
|
119
|
+
raise CronParseError(f"Value {value} out of range [{min_val}, {max_val}]")
|
|
120
|
+
|
|
121
|
+
return values
|
|
122
|
+
|
|
123
|
+
def _validate_fields(self):
|
|
124
|
+
"""Validate parsed cron fields"""
|
|
125
|
+
if len(self.fields) != 5:
|
|
126
|
+
raise CronParseError("Invalid number of parsed fields")
|
|
127
|
+
|
|
128
|
+
def get_next_run_time(self, from_time: Optional[datetime] = None) -> Optional[datetime]:
|
|
129
|
+
"""Calculate the next time this cron expression should run"""
|
|
130
|
+
if self.is_reboot:
|
|
131
|
+
return None # @reboot jobs run only at scheduler start
|
|
132
|
+
|
|
133
|
+
if from_time is None:
|
|
134
|
+
from_time = datetime.now()
|
|
135
|
+
|
|
136
|
+
# Start from the next minute to avoid immediate execution
|
|
137
|
+
next_time = from_time.replace(second=0, microsecond=0) + timedelta(minutes=1)
|
|
138
|
+
|
|
139
|
+
# Search for the next valid time (with safety limit)
|
|
140
|
+
max_iterations = 366 * 24 * 60 # One year worth of minutes
|
|
141
|
+
iterations = 0
|
|
142
|
+
|
|
143
|
+
while iterations < max_iterations:
|
|
144
|
+
if self._matches_time(next_time):
|
|
145
|
+
return next_time
|
|
146
|
+
|
|
147
|
+
next_time += timedelta(minutes=1)
|
|
148
|
+
iterations += 1
|
|
149
|
+
|
|
150
|
+
logger.warning(f"Could not find next run time for cron expression: {self.expression}")
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
def _matches_time(self, dt: datetime) -> bool:
|
|
154
|
+
"""Check if datetime matches this cron expression"""
|
|
155
|
+
if self.is_reboot:
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
minute, hour, day, month, weekday = self.fields
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
dt.minute in minute
|
|
162
|
+
and dt.hour in hour
|
|
163
|
+
and dt.day in day
|
|
164
|
+
and dt.month in month
|
|
165
|
+
and dt.weekday() + 1 % 7 in weekday # Convert Python weekday to cron weekday
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def matches_now(self) -> bool:
|
|
169
|
+
"""Check if cron expression matches current time"""
|
|
170
|
+
return self._matches_time(datetime.now())
|
|
171
|
+
|
|
172
|
+
def get_description(self) -> str:
|
|
173
|
+
"""Get human-readable description of cron expression"""
|
|
174
|
+
if self.is_reboot:
|
|
175
|
+
return "Run at scheduler startup"
|
|
176
|
+
|
|
177
|
+
# Basic descriptions for common patterns
|
|
178
|
+
if self.expression == "0 0 * * *":
|
|
179
|
+
return "Run daily at midnight"
|
|
180
|
+
elif self.expression == "0 * * * *":
|
|
181
|
+
return "Run every hour"
|
|
182
|
+
elif self.expression == "*/5 * * * *":
|
|
183
|
+
return "Run every 5 minutes"
|
|
184
|
+
elif self.expression == "0 0 * * 0":
|
|
185
|
+
return "Run weekly on Sunday at midnight"
|
|
186
|
+
elif self.expression == "0 0 1 * *":
|
|
187
|
+
return "Run monthly on the 1st at midnight"
|
|
188
|
+
elif self.expression == "0 0 1 1 *":
|
|
189
|
+
return "Run yearly on January 1st at midnight"
|
|
190
|
+
else:
|
|
191
|
+
return f"Custom schedule: {self.original_expression}"
|
|
192
|
+
|
|
193
|
+
def is_valid(self) -> bool:
|
|
194
|
+
"""Check if cron expression is valid"""
|
|
195
|
+
try:
|
|
196
|
+
if self.is_reboot:
|
|
197
|
+
return True
|
|
198
|
+
return len(self.fields) == 5
|
|
199
|
+
except:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
def __str__(self) -> str:
|
|
203
|
+
return self.original_expression
|
|
204
|
+
|
|
205
|
+
def __repr__(self) -> str:
|
|
206
|
+
return f"CronExpression('{self.original_expression}')"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def validate_cron_expression(expression: str) -> bool:
|
|
210
|
+
"""Validate a cron expression without creating a full object"""
|
|
211
|
+
try:
|
|
212
|
+
cron = CronExpression(expression)
|
|
213
|
+
return cron.is_valid()
|
|
214
|
+
except:
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def get_next_run_times(expression: str, count: int = 5) -> List[datetime]:
|
|
219
|
+
"""Get the next N run times for a cron expression"""
|
|
220
|
+
try:
|
|
221
|
+
cron = CronExpression(expression)
|
|
222
|
+
if cron.is_reboot:
|
|
223
|
+
return []
|
|
224
|
+
|
|
225
|
+
times = []
|
|
226
|
+
current_time = datetime.now()
|
|
227
|
+
|
|
228
|
+
for _ in range(count):
|
|
229
|
+
next_time = cron.get_next_run_time(current_time)
|
|
230
|
+
if next_time is None:
|
|
231
|
+
break
|
|
232
|
+
times.append(next_time)
|
|
233
|
+
current_time = next_time
|
|
234
|
+
|
|
235
|
+
return times
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.error(f"Error getting next run times: {e}")
|
|
238
|
+
return []
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Job definitions and status tracking for the MCLI scheduler
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from mcli.lib.logger.logger import get_logger
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JobStatus(Enum):
|
|
17
|
+
"""Job execution status"""
|
|
18
|
+
|
|
19
|
+
PENDING = "pending"
|
|
20
|
+
RUNNING = "running"
|
|
21
|
+
COMPLETED = "completed"
|
|
22
|
+
FAILED = "failed"
|
|
23
|
+
CANCELLED = "cancelled"
|
|
24
|
+
SKIPPED = "skipped"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class JobType(Enum):
|
|
28
|
+
"""Types of jobs that can be scheduled"""
|
|
29
|
+
|
|
30
|
+
COMMAND = "command" # Execute shell commands
|
|
31
|
+
PYTHON = "python" # Execute Python code
|
|
32
|
+
CLEANUP = "cleanup" # File system cleanup tasks
|
|
33
|
+
SYSTEM = "system" # System maintenance tasks
|
|
34
|
+
API_CALL = "api_call" # HTTP API calls
|
|
35
|
+
CUSTOM = "custom" # Custom user-defined jobs
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ScheduledJob:
|
|
39
|
+
"""Represents a scheduled job with all its metadata"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
name: str,
|
|
44
|
+
cron_expression: str,
|
|
45
|
+
job_type: JobType,
|
|
46
|
+
command: str,
|
|
47
|
+
description: str = "",
|
|
48
|
+
enabled: bool = True,
|
|
49
|
+
max_runtime: int = 3600, # 1 hour default
|
|
50
|
+
retry_count: int = 0,
|
|
51
|
+
retry_delay: int = 60, # 1 minute default
|
|
52
|
+
environment: Optional[Dict[str, str]] = None,
|
|
53
|
+
working_directory: Optional[str] = None,
|
|
54
|
+
output_format: str = "json",
|
|
55
|
+
notifications: Optional[Dict[str, Any]] = None,
|
|
56
|
+
job_id: Optional[str] = None,
|
|
57
|
+
):
|
|
58
|
+
self.id = job_id or str(uuid.uuid4())
|
|
59
|
+
self.name = name
|
|
60
|
+
self.cron_expression = cron_expression
|
|
61
|
+
self.job_type = job_type
|
|
62
|
+
self.command = command
|
|
63
|
+
self.description = description
|
|
64
|
+
self.enabled = enabled
|
|
65
|
+
self.max_runtime = max_runtime
|
|
66
|
+
self.retry_count = retry_count
|
|
67
|
+
self.retry_delay = retry_delay
|
|
68
|
+
self.environment = environment or {}
|
|
69
|
+
self.working_directory = working_directory
|
|
70
|
+
self.output_format = output_format
|
|
71
|
+
self.notifications = notifications or {}
|
|
72
|
+
|
|
73
|
+
# Runtime tracking
|
|
74
|
+
self.status = JobStatus.PENDING
|
|
75
|
+
self.created_at = datetime.now()
|
|
76
|
+
self.last_run = None
|
|
77
|
+
self.next_run = None
|
|
78
|
+
self.run_count = 0
|
|
79
|
+
self.success_count = 0
|
|
80
|
+
self.failure_count = 0
|
|
81
|
+
self.last_output = ""
|
|
82
|
+
self.last_error = ""
|
|
83
|
+
self.runtime_seconds = 0
|
|
84
|
+
self.current_retry = 0
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
87
|
+
"""Convert job to dictionary for serialization"""
|
|
88
|
+
return {
|
|
89
|
+
"id": self.id,
|
|
90
|
+
"name": self.name,
|
|
91
|
+
"cron_expression": self.cron_expression,
|
|
92
|
+
"job_type": self.job_type.value,
|
|
93
|
+
"command": self.command,
|
|
94
|
+
"description": self.description,
|
|
95
|
+
"enabled": self.enabled,
|
|
96
|
+
"max_runtime": self.max_runtime,
|
|
97
|
+
"retry_count": self.retry_count,
|
|
98
|
+
"retry_delay": self.retry_delay,
|
|
99
|
+
"environment": self.environment,
|
|
100
|
+
"working_directory": self.working_directory,
|
|
101
|
+
"output_format": self.output_format,
|
|
102
|
+
"notifications": self.notifications,
|
|
103
|
+
"status": self.status.value,
|
|
104
|
+
"created_at": self.created_at.isoformat(),
|
|
105
|
+
"last_run": self.last_run.isoformat() if self.last_run else None,
|
|
106
|
+
"next_run": self.next_run.isoformat() if self.next_run else None,
|
|
107
|
+
"run_count": self.run_count,
|
|
108
|
+
"success_count": self.success_count,
|
|
109
|
+
"failure_count": self.failure_count,
|
|
110
|
+
"last_output": self.last_output,
|
|
111
|
+
"last_error": self.last_error,
|
|
112
|
+
"runtime_seconds": self.runtime_seconds,
|
|
113
|
+
"current_retry": self.current_retry,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ScheduledJob":
|
|
118
|
+
"""Create job from dictionary"""
|
|
119
|
+
job = cls(
|
|
120
|
+
name=data["name"],
|
|
121
|
+
cron_expression=data["cron_expression"],
|
|
122
|
+
job_type=JobType(data["job_type"]),
|
|
123
|
+
command=data["command"],
|
|
124
|
+
description=data.get("description", ""),
|
|
125
|
+
enabled=data.get("enabled", True),
|
|
126
|
+
max_runtime=data.get("max_runtime", 3600),
|
|
127
|
+
retry_count=data.get("retry_count", 0),
|
|
128
|
+
retry_delay=data.get("retry_delay", 60),
|
|
129
|
+
environment=data.get("environment", {}),
|
|
130
|
+
working_directory=data.get("working_directory"),
|
|
131
|
+
output_format=data.get("output_format", "json"),
|
|
132
|
+
notifications=data.get("notifications", {}),
|
|
133
|
+
job_id=data.get("id"),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Restore runtime state
|
|
137
|
+
job.status = JobStatus(data.get("status", "pending"))
|
|
138
|
+
job.created_at = datetime.fromisoformat(data["created_at"])
|
|
139
|
+
job.last_run = datetime.fromisoformat(data["last_run"]) if data.get("last_run") else None
|
|
140
|
+
job.next_run = datetime.fromisoformat(data["next_run"]) if data.get("next_run") else None
|
|
141
|
+
job.run_count = data.get("run_count", 0)
|
|
142
|
+
job.success_count = data.get("success_count", 0)
|
|
143
|
+
job.failure_count = data.get("failure_count", 0)
|
|
144
|
+
job.last_output = data.get("last_output", "")
|
|
145
|
+
job.last_error = data.get("last_error", "")
|
|
146
|
+
job.runtime_seconds = data.get("runtime_seconds", 0)
|
|
147
|
+
job.current_retry = data.get("current_retry", 0)
|
|
148
|
+
|
|
149
|
+
return job
|
|
150
|
+
|
|
151
|
+
def update_status(self, status: JobStatus, output: str = "", error: str = ""):
|
|
152
|
+
"""Update job status and related metadata"""
|
|
153
|
+
self.status = status
|
|
154
|
+
self.last_output = output
|
|
155
|
+
self.last_error = error
|
|
156
|
+
|
|
157
|
+
if status == JobStatus.RUNNING:
|
|
158
|
+
self.last_run = datetime.now()
|
|
159
|
+
self.run_count += 1
|
|
160
|
+
elif status == JobStatus.COMPLETED:
|
|
161
|
+
self.success_count += 1
|
|
162
|
+
self.current_retry = 0
|
|
163
|
+
elif status == JobStatus.FAILED:
|
|
164
|
+
self.failure_count += 1
|
|
165
|
+
|
|
166
|
+
def should_retry(self) -> bool:
|
|
167
|
+
"""Check if job should be retried after failure"""
|
|
168
|
+
return self.status == JobStatus.FAILED and self.current_retry < self.retry_count
|
|
169
|
+
|
|
170
|
+
def get_next_retry_time(self) -> datetime:
|
|
171
|
+
"""Calculate next retry time"""
|
|
172
|
+
return datetime.now() + timedelta(seconds=self.retry_delay)
|
|
173
|
+
|
|
174
|
+
def to_json(self) -> str:
|
|
175
|
+
"""Convert job to JSON string"""
|
|
176
|
+
return json.dumps(self.to_dict(), indent=2)
|
|
177
|
+
|
|
178
|
+
def __str__(self) -> str:
|
|
179
|
+
return f"Job(id={self.id[:8]}, name={self.name}, status={self.status.value})"
|
|
180
|
+
|
|
181
|
+
def __repr__(self) -> str:
|
|
182
|
+
return f"ScheduledJob(id='{self.id}', name='{self.name}', cron='{self.cron_expression}')"
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Job monitoring and execution tracking for the MCLI scheduler
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from typing import Callable, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from mcli.lib.logger.logger import get_logger
|
|
11
|
+
|
|
12
|
+
from .job import JobStatus, ScheduledJob
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JobMonitor:
|
|
18
|
+
"""Monitors running jobs and handles timeouts, retries, and status updates"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, status_callback: Optional[Callable] = None):
|
|
21
|
+
self.running_jobs: Dict[str, threading.Thread] = {}
|
|
22
|
+
self.job_start_times: Dict[str, datetime] = {}
|
|
23
|
+
self.status_callback = status_callback
|
|
24
|
+
self.monitor_thread: Optional[threading.Thread] = None
|
|
25
|
+
self.monitoring = False
|
|
26
|
+
self.lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
def start_monitoring(self):
|
|
29
|
+
"""Start the monitoring thread"""
|
|
30
|
+
if self.monitoring:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
self.monitoring = True
|
|
34
|
+
self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
|
35
|
+
self.monitor_thread.start()
|
|
36
|
+
logger.info("Job monitor started")
|
|
37
|
+
|
|
38
|
+
def stop_monitoring(self):
|
|
39
|
+
"""Stop the monitoring thread"""
|
|
40
|
+
self.monitoring = False
|
|
41
|
+
if self.monitor_thread:
|
|
42
|
+
self.monitor_thread.join(timeout=5)
|
|
43
|
+
logger.info("Job monitor stopped")
|
|
44
|
+
|
|
45
|
+
def _monitor_loop(self):
|
|
46
|
+
"""Main monitoring loop"""
|
|
47
|
+
while self.monitoring:
|
|
48
|
+
try:
|
|
49
|
+
self._check_running_jobs()
|
|
50
|
+
time.sleep(10) # Check every 10 seconds
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.error(f"Error in monitor loop: {e}")
|
|
53
|
+
|
|
54
|
+
def _check_running_jobs(self):
|
|
55
|
+
"""Check status of running jobs"""
|
|
56
|
+
with self.lock:
|
|
57
|
+
current_time = datetime.now()
|
|
58
|
+
jobs_to_remove = []
|
|
59
|
+
|
|
60
|
+
for job_id, thread in self.running_jobs.items():
|
|
61
|
+
start_time = self.job_start_times.get(job_id)
|
|
62
|
+
|
|
63
|
+
if start_time:
|
|
64
|
+
runtime = (current_time - start_time).total_seconds()
|
|
65
|
+
|
|
66
|
+
# Check if thread is still alive
|
|
67
|
+
if not thread.is_alive():
|
|
68
|
+
jobs_to_remove.append(job_id)
|
|
69
|
+
logger.debug(f"Job {job_id} completed, removing from monitor")
|
|
70
|
+
|
|
71
|
+
# Note: Timeout handling would need job reference for max_runtime
|
|
72
|
+
# This is a simplified implementation
|
|
73
|
+
|
|
74
|
+
# Clean up completed jobs
|
|
75
|
+
for job_id in jobs_to_remove:
|
|
76
|
+
self._remove_job(job_id)
|
|
77
|
+
|
|
78
|
+
def add_job(self, job: ScheduledJob, thread: threading.Thread):
|
|
79
|
+
"""Add a job to monitoring"""
|
|
80
|
+
with self.lock:
|
|
81
|
+
self.running_jobs[job.id] = thread
|
|
82
|
+
self.job_start_times[job.id] = datetime.now()
|
|
83
|
+
logger.debug(f"Added job {job.id} to monitor")
|
|
84
|
+
|
|
85
|
+
def _remove_job(self, job_id: str):
|
|
86
|
+
"""Remove a job from monitoring"""
|
|
87
|
+
self.running_jobs.pop(job_id, None)
|
|
88
|
+
self.job_start_times.pop(job_id, None)
|
|
89
|
+
|
|
90
|
+
def get_running_jobs(self) -> List[str]:
|
|
91
|
+
"""Get list of currently running job IDs"""
|
|
92
|
+
with self.lock:
|
|
93
|
+
return list(self.running_jobs.keys())
|
|
94
|
+
|
|
95
|
+
def is_job_running(self, job_id: str) -> bool:
|
|
96
|
+
"""Check if a specific job is currently running"""
|
|
97
|
+
with self.lock:
|
|
98
|
+
return job_id in self.running_jobs
|
|
99
|
+
|
|
100
|
+
def get_job_runtime(self, job_id: str) -> Optional[int]:
|
|
101
|
+
"""Get runtime in seconds for a running job"""
|
|
102
|
+
with self.lock:
|
|
103
|
+
start_time = self.job_start_times.get(job_id)
|
|
104
|
+
if start_time:
|
|
105
|
+
return int((datetime.now() - start_time).total_seconds())
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def kill_job(self, job_id: str) -> bool:
|
|
109
|
+
"""Attempt to kill a running job"""
|
|
110
|
+
with self.lock:
|
|
111
|
+
thread = self.running_jobs.get(job_id)
|
|
112
|
+
if thread and thread.is_alive():
|
|
113
|
+
# Note: Python threads cannot be forcibly killed
|
|
114
|
+
# This would need process-based execution for true killing
|
|
115
|
+
logger.warning(f"Cannot forcibly kill job {job_id} - Python thread limitation")
|
|
116
|
+
return False
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
def get_monitor_stats(self) -> dict:
|
|
120
|
+
"""Get monitoring statistics"""
|
|
121
|
+
with self.lock:
|
|
122
|
+
stats = {
|
|
123
|
+
"monitoring": self.monitoring,
|
|
124
|
+
"running_jobs_count": len(self.running_jobs),
|
|
125
|
+
"running_job_ids": list(self.running_jobs.keys()),
|
|
126
|
+
"monitor_thread_alive": (
|
|
127
|
+
self.monitor_thread.is_alive() if self.monitor_thread else False
|
|
128
|
+
),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Add runtime info for each job
|
|
132
|
+
current_time = datetime.now()
|
|
133
|
+
job_runtimes = {}
|
|
134
|
+
for job_id, start_time in self.job_start_times.items():
|
|
135
|
+
runtime = (current_time - start_time).total_seconds()
|
|
136
|
+
job_runtimes[job_id] = int(runtime)
|
|
137
|
+
|
|
138
|
+
stats["job_runtimes"] = job_runtimes
|
|
139
|
+
return stats
|