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.

Files changed (186) hide show
  1. mcli/app/chat_cmd.py +42 -0
  2. mcli/app/commands_cmd.py +226 -0
  3. mcli/app/completion_cmd.py +216 -0
  4. mcli/app/completion_helpers.py +288 -0
  5. mcli/app/cron_test_cmd.py +697 -0
  6. mcli/app/logs_cmd.py +419 -0
  7. mcli/app/main.py +492 -0
  8. mcli/app/model/model.py +1060 -0
  9. mcli/app/model_cmd.py +227 -0
  10. mcli/app/redis_cmd.py +269 -0
  11. mcli/app/video/video.py +1114 -0
  12. mcli/app/visual_cmd.py +303 -0
  13. mcli/chat/chat.py +2409 -0
  14. mcli/chat/command_rag.py +514 -0
  15. mcli/chat/enhanced_chat.py +652 -0
  16. mcli/chat/system_controller.py +1010 -0
  17. mcli/chat/system_integration.py +1016 -0
  18. mcli/cli.py +25 -0
  19. mcli/config.toml +20 -0
  20. mcli/lib/api/api.py +586 -0
  21. mcli/lib/api/daemon_client.py +203 -0
  22. mcli/lib/api/daemon_client_local.py +44 -0
  23. mcli/lib/api/daemon_decorator.py +217 -0
  24. mcli/lib/api/mcli_decorators.py +1032 -0
  25. mcli/lib/auth/auth.py +85 -0
  26. mcli/lib/auth/aws_manager.py +85 -0
  27. mcli/lib/auth/azure_manager.py +91 -0
  28. mcli/lib/auth/credential_manager.py +192 -0
  29. mcli/lib/auth/gcp_manager.py +93 -0
  30. mcli/lib/auth/key_manager.py +117 -0
  31. mcli/lib/auth/mcli_manager.py +93 -0
  32. mcli/lib/auth/token_manager.py +75 -0
  33. mcli/lib/auth/token_util.py +1011 -0
  34. mcli/lib/config/config.py +47 -0
  35. mcli/lib/discovery/__init__.py +1 -0
  36. mcli/lib/discovery/command_discovery.py +274 -0
  37. mcli/lib/erd/erd.py +1345 -0
  38. mcli/lib/erd/generate_graph.py +453 -0
  39. mcli/lib/files/files.py +76 -0
  40. mcli/lib/fs/fs.py +109 -0
  41. mcli/lib/lib.py +29 -0
  42. mcli/lib/logger/logger.py +611 -0
  43. mcli/lib/performance/optimizer.py +409 -0
  44. mcli/lib/performance/rust_bridge.py +502 -0
  45. mcli/lib/performance/uvloop_config.py +154 -0
  46. mcli/lib/pickles/pickles.py +50 -0
  47. mcli/lib/search/cached_vectorizer.py +479 -0
  48. mcli/lib/services/data_pipeline.py +460 -0
  49. mcli/lib/services/lsh_client.py +441 -0
  50. mcli/lib/services/redis_service.py +387 -0
  51. mcli/lib/shell/shell.py +137 -0
  52. mcli/lib/toml/toml.py +33 -0
  53. mcli/lib/ui/styling.py +47 -0
  54. mcli/lib/ui/visual_effects.py +634 -0
  55. mcli/lib/watcher/watcher.py +185 -0
  56. mcli/ml/api/app.py +215 -0
  57. mcli/ml/api/middleware.py +224 -0
  58. mcli/ml/api/routers/admin_router.py +12 -0
  59. mcli/ml/api/routers/auth_router.py +244 -0
  60. mcli/ml/api/routers/backtest_router.py +12 -0
  61. mcli/ml/api/routers/data_router.py +12 -0
  62. mcli/ml/api/routers/model_router.py +302 -0
  63. mcli/ml/api/routers/monitoring_router.py +12 -0
  64. mcli/ml/api/routers/portfolio_router.py +12 -0
  65. mcli/ml/api/routers/prediction_router.py +267 -0
  66. mcli/ml/api/routers/trade_router.py +12 -0
  67. mcli/ml/api/routers/websocket_router.py +76 -0
  68. mcli/ml/api/schemas.py +64 -0
  69. mcli/ml/auth/auth_manager.py +425 -0
  70. mcli/ml/auth/models.py +154 -0
  71. mcli/ml/auth/permissions.py +302 -0
  72. mcli/ml/backtesting/backtest_engine.py +502 -0
  73. mcli/ml/backtesting/performance_metrics.py +393 -0
  74. mcli/ml/cache.py +400 -0
  75. mcli/ml/cli/main.py +398 -0
  76. mcli/ml/config/settings.py +394 -0
  77. mcli/ml/configs/dvc_config.py +230 -0
  78. mcli/ml/configs/mlflow_config.py +131 -0
  79. mcli/ml/configs/mlops_manager.py +293 -0
  80. mcli/ml/dashboard/app.py +532 -0
  81. mcli/ml/dashboard/app_integrated.py +738 -0
  82. mcli/ml/dashboard/app_supabase.py +560 -0
  83. mcli/ml/dashboard/app_training.py +615 -0
  84. mcli/ml/dashboard/cli.py +51 -0
  85. mcli/ml/data_ingestion/api_connectors.py +501 -0
  86. mcli/ml/data_ingestion/data_pipeline.py +567 -0
  87. mcli/ml/data_ingestion/stream_processor.py +512 -0
  88. mcli/ml/database/migrations/env.py +94 -0
  89. mcli/ml/database/models.py +667 -0
  90. mcli/ml/database/session.py +200 -0
  91. mcli/ml/experimentation/ab_testing.py +845 -0
  92. mcli/ml/features/ensemble_features.py +607 -0
  93. mcli/ml/features/political_features.py +676 -0
  94. mcli/ml/features/recommendation_engine.py +809 -0
  95. mcli/ml/features/stock_features.py +573 -0
  96. mcli/ml/features/test_feature_engineering.py +346 -0
  97. mcli/ml/logging.py +85 -0
  98. mcli/ml/mlops/data_versioning.py +518 -0
  99. mcli/ml/mlops/experiment_tracker.py +377 -0
  100. mcli/ml/mlops/model_serving.py +481 -0
  101. mcli/ml/mlops/pipeline_orchestrator.py +614 -0
  102. mcli/ml/models/base_models.py +324 -0
  103. mcli/ml/models/ensemble_models.py +675 -0
  104. mcli/ml/models/recommendation_models.py +474 -0
  105. mcli/ml/models/test_models.py +487 -0
  106. mcli/ml/monitoring/drift_detection.py +676 -0
  107. mcli/ml/monitoring/metrics.py +45 -0
  108. mcli/ml/optimization/portfolio_optimizer.py +834 -0
  109. mcli/ml/preprocessing/data_cleaners.py +451 -0
  110. mcli/ml/preprocessing/feature_extractors.py +491 -0
  111. mcli/ml/preprocessing/ml_pipeline.py +382 -0
  112. mcli/ml/preprocessing/politician_trading_preprocessor.py +569 -0
  113. mcli/ml/preprocessing/test_preprocessing.py +294 -0
  114. mcli/ml/scripts/populate_sample_data.py +200 -0
  115. mcli/ml/tasks.py +400 -0
  116. mcli/ml/tests/test_integration.py +429 -0
  117. mcli/ml/tests/test_training_dashboard.py +387 -0
  118. mcli/public/oi/oi.py +15 -0
  119. mcli/public/public.py +4 -0
  120. mcli/self/self_cmd.py +1246 -0
  121. mcli/workflow/daemon/api_daemon.py +800 -0
  122. mcli/workflow/daemon/async_command_database.py +681 -0
  123. mcli/workflow/daemon/async_process_manager.py +591 -0
  124. mcli/workflow/daemon/client.py +530 -0
  125. mcli/workflow/daemon/commands.py +1196 -0
  126. mcli/workflow/daemon/daemon.py +905 -0
  127. mcli/workflow/daemon/daemon_api.py +59 -0
  128. mcli/workflow/daemon/enhanced_daemon.py +571 -0
  129. mcli/workflow/daemon/process_cli.py +244 -0
  130. mcli/workflow/daemon/process_manager.py +439 -0
  131. mcli/workflow/daemon/test_daemon.py +275 -0
  132. mcli/workflow/dashboard/dashboard_cmd.py +113 -0
  133. mcli/workflow/docker/docker.py +0 -0
  134. mcli/workflow/file/file.py +100 -0
  135. mcli/workflow/gcloud/config.toml +21 -0
  136. mcli/workflow/gcloud/gcloud.py +58 -0
  137. mcli/workflow/git_commit/ai_service.py +328 -0
  138. mcli/workflow/git_commit/commands.py +430 -0
  139. mcli/workflow/lsh_integration.py +355 -0
  140. mcli/workflow/model_service/client.py +594 -0
  141. mcli/workflow/model_service/download_and_run_efficient_models.py +288 -0
  142. mcli/workflow/model_service/lightweight_embedder.py +397 -0
  143. mcli/workflow/model_service/lightweight_model_server.py +714 -0
  144. mcli/workflow/model_service/lightweight_test.py +241 -0
  145. mcli/workflow/model_service/model_service.py +1955 -0
  146. mcli/workflow/model_service/ollama_efficient_runner.py +425 -0
  147. mcli/workflow/model_service/pdf_processor.py +386 -0
  148. mcli/workflow/model_service/test_efficient_runner.py +234 -0
  149. mcli/workflow/model_service/test_example.py +315 -0
  150. mcli/workflow/model_service/test_integration.py +131 -0
  151. mcli/workflow/model_service/test_new_features.py +149 -0
  152. mcli/workflow/openai/openai.py +99 -0
  153. mcli/workflow/politician_trading/commands.py +1790 -0
  154. mcli/workflow/politician_trading/config.py +134 -0
  155. mcli/workflow/politician_trading/connectivity.py +490 -0
  156. mcli/workflow/politician_trading/data_sources.py +395 -0
  157. mcli/workflow/politician_trading/database.py +410 -0
  158. mcli/workflow/politician_trading/demo.py +248 -0
  159. mcli/workflow/politician_trading/models.py +165 -0
  160. mcli/workflow/politician_trading/monitoring.py +413 -0
  161. mcli/workflow/politician_trading/scrapers.py +966 -0
  162. mcli/workflow/politician_trading/scrapers_california.py +412 -0
  163. mcli/workflow/politician_trading/scrapers_eu.py +377 -0
  164. mcli/workflow/politician_trading/scrapers_uk.py +350 -0
  165. mcli/workflow/politician_trading/scrapers_us_states.py +438 -0
  166. mcli/workflow/politician_trading/supabase_functions.py +354 -0
  167. mcli/workflow/politician_trading/workflow.py +852 -0
  168. mcli/workflow/registry/registry.py +180 -0
  169. mcli/workflow/repo/repo.py +223 -0
  170. mcli/workflow/scheduler/commands.py +493 -0
  171. mcli/workflow/scheduler/cron_parser.py +238 -0
  172. mcli/workflow/scheduler/job.py +182 -0
  173. mcli/workflow/scheduler/monitor.py +139 -0
  174. mcli/workflow/scheduler/persistence.py +324 -0
  175. mcli/workflow/scheduler/scheduler.py +679 -0
  176. mcli/workflow/sync/sync_cmd.py +437 -0
  177. mcli/workflow/sync/test_cmd.py +314 -0
  178. mcli/workflow/videos/videos.py +242 -0
  179. mcli/workflow/wakatime/wakatime.py +11 -0
  180. mcli/workflow/workflow.py +37 -0
  181. mcli_framework-7.0.0.dist-info/METADATA +479 -0
  182. mcli_framework-7.0.0.dist-info/RECORD +186 -0
  183. mcli_framework-7.0.0.dist-info/WHEEL +5 -0
  184. mcli_framework-7.0.0.dist-info/entry_points.txt +7 -0
  185. mcli_framework-7.0.0.dist-info/licenses/LICENSE +21 -0
  186. 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