rhylthyme 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.
rhylthyme/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """
2
+ Rhylthyme - A tool for working with real-time program schedules
3
+
4
+ This package provides tools for defining, validating, and executing
5
+ real-time program schedules with tracks, steps, and resource constraints.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ from .validate_program import validate_program, perform_additional_validations, validate_program_file, load_program_file
11
+ from .program_runner import run_program
12
+ from .marimo_runner import create_marimo_runner, MarimoRunner
13
+
14
+ __all__ = [
15
+ 'validate_program',
16
+ 'perform_additional_validations',
17
+ 'validate_program_file',
18
+ 'load_program_file',
19
+ 'run_program',
20
+ 'create_marimo_runner',
21
+ 'MarimoRunner'
22
+ ]
@@ -0,0 +1,618 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Auto-planner for Rhylthyme programs.
4
+
5
+ This module implements automatic timeline planning with various strategies:
6
+ - Synchronized finish: All tracks finish around the same time
7
+ - Cover: Minimize dead time/pauses between activities
8
+ - Minimize overall length: Shortest total duration
9
+
10
+ Features:
11
+ - Respects track priorities
12
+ - Avoids overutilization of resources
13
+ - Handles flexible (flex) steps that can expand to fill gaps
14
+ - Considers step min/max duration limits
15
+ """
16
+
17
+ import copy
18
+ import json
19
+ from typing import Dict, List, Any, Tuple, Optional, Set
20
+ from dataclasses import dataclass
21
+ from enum import Enum
22
+
23
+ class PlanningStrategy(Enum):
24
+ SYNCHRONIZED_FINISH = "synchronized_finish"
25
+ COVER = "cover"
26
+ MINIMIZE_LENGTH = "minimize_length"
27
+
28
+ @dataclass
29
+ class ResourceConstraint:
30
+ name: str
31
+ capacity: float = 1.0
32
+
33
+ @dataclass
34
+ class ResourceUsage:
35
+ resource: str
36
+ start_time: float
37
+ end_time: float
38
+ fraction: float = 1.0
39
+ step_id: str = ""
40
+ track_id: str = ""
41
+
42
+ @dataclass
43
+ class StepTiming:
44
+ step_id: str
45
+ track_id: str
46
+ start_time: float
47
+ duration: float
48
+ end_time: float
49
+ is_flex: bool = False
50
+ min_duration: float = 0
51
+ max_duration: float = float('inf')
52
+ priority: int = 100
53
+ resources: List[str] = None
54
+
55
+ def __post_init__(self):
56
+ if self.resources is None:
57
+ self.resources = []
58
+ self.end_time = self.start_time + self.duration
59
+
60
+ @dataclass
61
+ class TrackInfo:
62
+ track_id: str
63
+ name: str
64
+ priority: int = 100
65
+ steps: List[Dict] = None
66
+
67
+ def __post_init__(self):
68
+ if self.steps is None:
69
+ self.steps = []
70
+
71
+ class AutoPlanner:
72
+ """
73
+ Auto-planner that implements various planning strategies for Rhylthyme programs.
74
+ """
75
+
76
+ def __init__(self, program: Dict[str, Any], resource_constraints: List[Dict[str, Any]] = None):
77
+ self.program = program
78
+ self.resource_constraints = self._parse_resource_constraints(resource_constraints or [])
79
+ self.tracks = self._parse_tracks()
80
+ self.steps = self._parse_steps()
81
+
82
+ def _parse_resource_constraints(self, constraints: List[Dict[str, Any]]) -> Dict[str, ResourceConstraint]:
83
+ """Parse resource constraints from the program."""
84
+ result = {}
85
+ for constraint in constraints:
86
+ name = constraint.get('name', '')
87
+ capacity = constraint.get('capacity', 1.0)
88
+ result[name] = ResourceConstraint(name=name, capacity=capacity)
89
+ return result
90
+
91
+ def _parse_tracks(self) -> Dict[str, TrackInfo]:
92
+ """Parse track information including priorities."""
93
+ tracks = {}
94
+ for track_data in self.program.get('tracks', []):
95
+ track_id = track_data.get('trackId', '')
96
+ name = track_data.get('name', '')
97
+ priority = track_data.get('priority', 100)
98
+ steps = track_data.get('steps', [])
99
+
100
+ tracks[track_id] = TrackInfo(
101
+ track_id=track_id,
102
+ name=name,
103
+ priority=priority,
104
+ steps=steps
105
+ )
106
+ return tracks
107
+
108
+ def _parse_steps(self) -> Dict[str, Dict]:
109
+ """Parse all steps from all tracks."""
110
+ steps = {}
111
+ for track in self.program.get('tracks', []):
112
+ track_id = track.get('trackId', '')
113
+ for step in track.get('steps', []):
114
+ step_id = step.get('stepId', '')
115
+ step_copy = copy.deepcopy(step)
116
+ step_copy['trackId'] = track_id
117
+ steps[step_id] = step_copy
118
+ return steps
119
+
120
+ def _parse_duration(self, duration_spec: Any) -> Tuple[float, float, float]:
121
+ """
122
+ Parse duration specification into (min, optimal, max) seconds.
123
+
124
+ Args:
125
+ duration_spec: Duration specification from step
126
+
127
+ Returns:
128
+ Tuple of (min_duration, optimal_duration, max_duration) in seconds
129
+ """
130
+ if isinstance(duration_spec, (int, float)):
131
+ return (duration_spec, duration_spec, duration_spec)
132
+
133
+ if isinstance(duration_spec, dict):
134
+ duration_type = duration_spec.get('type', 'fixed')
135
+
136
+ if duration_type == 'fixed':
137
+ seconds = self._parse_time_string(duration_spec.get('seconds', 0))
138
+ return (seconds, seconds, seconds)
139
+
140
+ elif duration_type == 'variable':
141
+ min_sec = self._parse_time_string(duration_spec.get('minSeconds', 0))
142
+ max_sec = self._parse_time_string(duration_spec.get('maxSeconds', min_sec))
143
+ default_sec = self._parse_time_string(duration_spec.get('defaultSeconds', (min_sec + max_sec) / 2))
144
+ optimal_sec = self._parse_time_string(duration_spec.get('optimalSeconds', default_sec))
145
+ return (min_sec, optimal_sec, max_sec)
146
+
147
+ elif duration_type == 'indefinite':
148
+ return (0, 3600, float('inf')) # Default 1 hour optimal for indefinite
149
+
150
+ return (0, 0, 0)
151
+
152
+ def _parse_time_string(self, time_str: Any) -> float:
153
+ """Parse time string like '30s', '2m', '1h' into seconds."""
154
+ if isinstance(time_str, (int, float)):
155
+ return float(time_str)
156
+
157
+ if not isinstance(time_str, str):
158
+ return 0.0
159
+
160
+ time_str = str(time_str).strip().lower()
161
+
162
+ if time_str.endswith('s'):
163
+ return float(time_str[:-1])
164
+ elif time_str.endswith('m'):
165
+ return float(time_str[:-1]) * 60
166
+ elif time_str.endswith('h'):
167
+ return float(time_str[:-1]) * 3600
168
+ else:
169
+ try:
170
+ return float(time_str)
171
+ except ValueError:
172
+ return 0.0
173
+
174
+ def _get_step_resources(self, step: Dict[str, Any]) -> List[str]:
175
+ """Extract resource requirements from a step."""
176
+ resources = []
177
+
178
+ # Single task
179
+ if 'task' in step:
180
+ resources.append(step['task'])
181
+
182
+ # Multiple tasks
183
+ if 'tasks' in step:
184
+ resources.extend(step['tasks'])
185
+
186
+ # Task resources with fractions
187
+ if 'taskResources' in step:
188
+ for res in step['taskResources']:
189
+ resources.append(res.get('name', ''))
190
+
191
+ return [r for r in resources if r] # Remove empty strings
192
+
193
+ def _build_dependency_graph(self) -> Dict[str, List[str]]:
194
+ """Build dependency graph: step_id -> [prerequisite_step_ids]."""
195
+ dependencies = {}
196
+
197
+ for step_id, step in self.steps.items():
198
+ dependencies[step_id] = []
199
+
200
+ start_trigger = step.get('trigger', {})
201
+ deps = self._extract_dependencies_from_trigger(start_trigger)
202
+ dependencies[step_id].extend(deps)
203
+
204
+ return dependencies
205
+
206
+ def _extract_dependencies_from_trigger(self, trigger: Dict[str, Any]) -> List[str]:
207
+ """Extract step dependencies from a start trigger."""
208
+ deps = []
209
+
210
+ # If no type is specified but stepId or on is present, default to afterStep
211
+ trigger_type = trigger.get('type')
212
+ if not trigger_type and (trigger.get('stepId') or trigger.get('on')):
213
+ trigger_type = 'afterStep'
214
+
215
+ if trigger_type in ['afterStep', 'afterStepWithBuffer']:
216
+ step_id = trigger.get('stepId') or trigger.get('on')
217
+ if step_id:
218
+ deps.append(step_id)
219
+
220
+ elif trigger.get('logic'): # Multiple triggers
221
+ for sub_trigger in trigger.get('triggers', []):
222
+ deps.extend(self._extract_dependencies_from_trigger(sub_trigger))
223
+
224
+ return deps
225
+
226
+ def _calculate_earliest_start_times(self, dependencies: Dict[str, List[str]]) -> Dict[str, float]:
227
+ """Calculate earliest possible start time for each step."""
228
+ earliest_times = {}
229
+
230
+ def calculate_earliest(step_id: str, visited: Set[str] = None) -> float:
231
+ if visited is None:
232
+ visited = set()
233
+
234
+ if step_id in visited:
235
+ return 0 # Cycle detected
236
+
237
+ if step_id in earliest_times:
238
+ return earliest_times[step_id]
239
+
240
+ visited.add(step_id)
241
+
242
+ max_predecessor_end = 0.0
243
+
244
+ for dep_step_id in dependencies.get(step_id, []):
245
+ if dep_step_id in self.steps:
246
+ dep_start = calculate_earliest(dep_step_id, visited.copy())
247
+ _, dep_duration, _ = self._parse_duration(self.steps[dep_step_id].get('duration', 0))
248
+ dep_end = dep_start + dep_duration
249
+ max_predecessor_end = max(max_predecessor_end, dep_end)
250
+
251
+ earliest_times[step_id] = max_predecessor_end
252
+ return max_predecessor_end
253
+
254
+ for step_id in self.steps:
255
+ calculate_earliest(step_id)
256
+
257
+ return earliest_times
258
+
259
+ def _check_resource_overutilization(self, step_timings: List[StepTiming]) -> List[Tuple[str, float, float]]:
260
+ """
261
+ Check for resource overutilization in the current plan.
262
+
263
+ Returns:
264
+ List of (resource_name, start_time, end_time) tuples for overutilized periods
265
+ """
266
+ overutilizations = []
267
+
268
+ # Group resource usage by resource
269
+ resource_usage = {}
270
+ for timing in step_timings:
271
+ for resource in timing.resources:
272
+ if resource not in resource_usage:
273
+ resource_usage[resource] = []
274
+
275
+ resource_usage[resource].append(ResourceUsage(
276
+ resource=resource,
277
+ start_time=timing.start_time,
278
+ end_time=timing.end_time,
279
+ step_id=timing.step_id,
280
+ track_id=timing.track_id
281
+ ))
282
+
283
+ # Check each resource for overutilization
284
+ for resource_name, usages in resource_usage.items():
285
+ constraint = self.resource_constraints.get(resource_name)
286
+ if not constraint:
287
+ continue # No constraint defined
288
+
289
+ # Sort usages by start time
290
+ usages.sort(key=lambda x: x.start_time)
291
+
292
+ # Find overlapping periods
293
+ for i, usage1 in enumerate(usages):
294
+ concurrent_count = 1
295
+ for j in range(i + 1, len(usages)):
296
+ usage2 = usages[j]
297
+
298
+ # Check if they overlap
299
+ if usage2.start_time < usage1.end_time:
300
+ concurrent_count += 1
301
+ else:
302
+ break
303
+
304
+ if concurrent_count > constraint.capacity:
305
+ overutilizations.append((resource_name, usage1.start_time, usage1.end_time))
306
+
307
+ return overutilizations
308
+
309
+ def _apply_flex_adjustments(self, step_timings: List[StepTiming]) -> List[StepTiming]:
310
+ """Apply flex adjustments to fill gaps between steps."""
311
+ # Group timings by track
312
+ track_timings = {}
313
+ for timing in step_timings:
314
+ if timing.track_id not in track_timings:
315
+ track_timings[timing.track_id] = []
316
+ track_timings[timing.track_id].append(timing)
317
+
318
+ # Process each track
319
+ adjusted_timings = []
320
+ for track_id, timings in track_timings.items():
321
+ # Sort by start time
322
+ timings.sort(key=lambda x: x.start_time)
323
+
324
+ for i, timing in enumerate(timings):
325
+ step = self.steps.get(timing.step_id, {})
326
+ flex_config = step.get('flex', {})
327
+
328
+ if not flex_config.get('enabled', False):
329
+ adjusted_timings.append(timing)
330
+ continue
331
+
332
+ # Find next step in the same track
333
+ next_step_start = None
334
+ if i + 1 < len(timings):
335
+ next_step_start = timings[i + 1].start_time
336
+
337
+ # Calculate flex limits
338
+ min_dur = timing.min_duration
339
+ max_dur = timing.max_duration
340
+
341
+ if flex_config.get('minDuration'):
342
+ min_dur = max(min_dur, self._parse_time_string(flex_config['minDuration']))
343
+
344
+ if flex_config.get('maxDuration'):
345
+ max_dur = min(max_dur, self._parse_time_string(flex_config['maxDuration']))
346
+
347
+ # Apply flex based on mode
348
+ flex_mode = flex_config.get('mode', 'fill')
349
+ new_duration = timing.duration
350
+
351
+ if flex_mode == 'fill' and next_step_start is not None:
352
+ # Fill gap to next step
353
+ available_time = next_step_start - timing.start_time
354
+ new_duration = min(max_dur, max(min_dur, available_time))
355
+
356
+ elif flex_mode == 'max':
357
+ # Extend to maximum duration
358
+ new_duration = max_dur
359
+
360
+ # Update timing
361
+ adjusted_timing = copy.deepcopy(timing)
362
+ adjusted_timing.duration = new_duration
363
+ adjusted_timing.end_time = adjusted_timing.start_time + new_duration
364
+ adjusted_timings.append(adjusted_timing)
365
+
366
+ return adjusted_timings
367
+
368
+ def plan_synchronized_finish(self) -> List[StepTiming]:
369
+ """
370
+ Plan timeline with synchronized finish strategy.
371
+ All tracks should finish around the same time.
372
+ """
373
+ dependencies = self._build_dependency_graph()
374
+ earliest_times = self._calculate_earliest_start_times(dependencies)
375
+
376
+ # Calculate track durations at earliest start times
377
+ track_durations = {}
378
+ step_timings = []
379
+
380
+ for track_id, track_info in self.tracks.items():
381
+ track_duration = 0
382
+ track_steps = []
383
+
384
+ for step in track_info.steps:
385
+ step_id = step.get('stepId', '')
386
+ start_time = earliest_times.get(step_id, 0)
387
+ min_dur, opt_dur, max_dur = self._parse_duration(step.get('duration', 0))
388
+
389
+ step_timing = StepTiming(
390
+ step_id=step_id,
391
+ track_id=track_id,
392
+ start_time=start_time,
393
+ duration=opt_dur,
394
+ end_time=start_time + opt_dur,
395
+ min_duration=min_dur,
396
+ max_duration=max_dur,
397
+ priority=step.get('priority', 100),
398
+ resources=self._get_step_resources(step)
399
+ )
400
+
401
+ track_steps.append(step_timing)
402
+ track_duration = max(track_duration, step_timing.end_time)
403
+
404
+ track_durations[track_id] = track_duration
405
+ step_timings.extend(track_steps)
406
+
407
+ # Find the longest track
408
+ max_duration = max(track_durations.values()) if track_durations else 0
409
+
410
+ # Delay shorter tracks to finish at the same time
411
+ for track_id, duration in track_durations.items():
412
+ if duration < max_duration:
413
+ delay = max_duration - duration
414
+
415
+ # Apply delay to all steps in this track
416
+ for timing in step_timings:
417
+ if timing.track_id == track_id:
418
+ timing.start_time += delay
419
+ timing.end_time += delay
420
+
421
+ # Apply flex adjustments
422
+ step_timings = self._apply_flex_adjustments(step_timings)
423
+
424
+ return step_timings
425
+
426
+ def plan_cover(self) -> List[StepTiming]:
427
+ """
428
+ Plan timeline with cover strategy.
429
+ Minimize dead time/pauses between activities.
430
+ """
431
+ dependencies = self._build_dependency_graph()
432
+ earliest_times = self._calculate_earliest_start_times(dependencies)
433
+
434
+ step_timings = []
435
+
436
+ # Sort tracks by priority (higher priority = lower number)
437
+ sorted_tracks = sorted(self.tracks.items(), key=lambda x: x[1].priority)
438
+
439
+ for track_id, track_info in sorted_tracks:
440
+ for step in track_info.steps:
441
+ step_id = step.get('stepId', '')
442
+ start_time = earliest_times.get(step_id, 0)
443
+ min_dur, opt_dur, max_dur = self._parse_duration(step.get('duration', 0))
444
+
445
+ step_timing = StepTiming(
446
+ step_id=step_id,
447
+ track_id=track_id,
448
+ start_time=start_time,
449
+ duration=opt_dur,
450
+ end_time=start_time + opt_dur,
451
+ min_duration=min_dur,
452
+ max_duration=max_dur,
453
+ priority=step.get('priority', 100),
454
+ resources=self._get_step_resources(step)
455
+ )
456
+
457
+ step_timings.append(step_timing)
458
+
459
+ # Apply flex adjustments to minimize gaps
460
+ step_timings = self._apply_flex_adjustments(step_timings)
461
+
462
+ return step_timings
463
+
464
+ def plan_minimize_length(self) -> List[StepTiming]:
465
+ """
466
+ Plan timeline with minimize overall length strategy.
467
+ Shortest total duration while respecting dependencies.
468
+ """
469
+ dependencies = self._build_dependency_graph()
470
+ earliest_times = self._calculate_earliest_start_times(dependencies)
471
+
472
+ step_timings = []
473
+
474
+ for step_id, step in self.steps.items():
475
+ start_time = earliest_times.get(step_id, 0)
476
+ min_dur, opt_dur, max_dur = self._parse_duration(step.get('duration', 0))
477
+
478
+ # Use minimum duration for shortest timeline
479
+ duration = min_dur if min_dur > 0 else opt_dur
480
+
481
+ step_timing = StepTiming(
482
+ step_id=step_id,
483
+ track_id=step.get('trackId', ''),
484
+ start_time=start_time,
485
+ duration=duration,
486
+ end_time=start_time + duration,
487
+ min_duration=min_dur,
488
+ max_duration=max_dur,
489
+ priority=step.get('priority', 100),
490
+ resources=self._get_step_resources(step)
491
+ )
492
+
493
+ step_timings.append(step_timing)
494
+
495
+ return step_timings
496
+
497
+ def auto_plan(self, strategy: PlanningStrategy) -> Dict[str, Any]:
498
+ """
499
+ Generate an auto-planned timeline using the specified strategy.
500
+
501
+ Args:
502
+ strategy: The planning strategy to use
503
+
504
+ Returns:
505
+ Dictionary with the planned timeline and metadata
506
+ """
507
+ if strategy == PlanningStrategy.SYNCHRONIZED_FINISH:
508
+ step_timings = self.plan_synchronized_finish()
509
+ elif strategy == PlanningStrategy.COVER:
510
+ step_timings = self.plan_cover()
511
+ elif strategy == PlanningStrategy.MINIMIZE_LENGTH:
512
+ step_timings = self.plan_minimize_length()
513
+ else:
514
+ raise ValueError(f"Unknown planning strategy: {strategy}")
515
+
516
+ # Check for resource overutilization
517
+ overutilizations = self._check_resource_overutilization(step_timings)
518
+
519
+ # Calculate total duration
520
+ total_duration = max(timing.end_time for timing in step_timings) if step_timings else 0
521
+
522
+ # Format results
523
+ result = {
524
+ 'strategy': strategy.value,
525
+ 'totalDuration': total_duration,
526
+ 'overutilizations': [
527
+ {
528
+ 'resource': res_name,
529
+ 'startTime': start_time,
530
+ 'endTime': end_time
531
+ }
532
+ for res_name, start_time, end_time in overutilizations
533
+ ],
534
+ 'stepTimings': [
535
+ {
536
+ 'stepId': timing.step_id,
537
+ 'trackId': timing.track_id,
538
+ 'startTime': timing.start_time,
539
+ 'duration': timing.duration,
540
+ 'endTime': timing.end_time,
541
+ 'isFlex': timing.is_flex,
542
+ 'minDuration': timing.min_duration,
543
+ 'maxDuration': timing.max_duration,
544
+ 'priority': timing.priority,
545
+ 'resources': timing.resources
546
+ }
547
+ for timing in step_timings
548
+ ]
549
+ }
550
+
551
+ return result
552
+
553
+
554
+ def auto_plan_program(program_file: str, strategy: str = "cover",
555
+ resource_constraints_file: str = None) -> Dict[str, Any]:
556
+ """
557
+ Auto-plan a program with the specified strategy.
558
+
559
+ Args:
560
+ program_file: Path to the program JSON file
561
+ strategy: Planning strategy ("synchronized_finish", "cover", "minimize_length")
562
+ resource_constraints_file: Optional path to resource constraints JSON file
563
+
564
+ Returns:
565
+ Dictionary with the planned timeline
566
+ """
567
+ # Load program
568
+ with open(program_file, 'r') as f:
569
+ program = json.load(f)
570
+
571
+ # Load resource constraints if provided
572
+ resource_constraints = []
573
+ if resource_constraints_file:
574
+ with open(resource_constraints_file, 'r') as f:
575
+ constraints_data = json.load(f)
576
+ resource_constraints = constraints_data.get('resources', [])
577
+
578
+ # Map strategy string to enum
579
+ strategy_map = {
580
+ 'synchronized_finish': PlanningStrategy.SYNCHRONIZED_FINISH,
581
+ 'cover': PlanningStrategy.COVER,
582
+ 'minimize_length': PlanningStrategy.MINIMIZE_LENGTH
583
+ }
584
+
585
+ strategy_enum = strategy_map.get(strategy)
586
+ if not strategy_enum:
587
+ raise ValueError(f"Unknown strategy: {strategy}")
588
+
589
+ # Create planner and generate plan
590
+ planner = AutoPlanner(program, resource_constraints)
591
+ return planner.auto_plan(strategy_enum)
592
+
593
+
594
+ if __name__ == "__main__":
595
+ import argparse
596
+
597
+ parser = argparse.ArgumentParser(description="Auto-plan a Rhylthyme program")
598
+ parser.add_argument("program_file", help="Path to program JSON file")
599
+ parser.add_argument("--strategy", choices=["synchronized_finish", "cover", "minimize_length"],
600
+ default="cover", help="Planning strategy")
601
+ parser.add_argument("--resources", help="Path to resource constraints JSON file")
602
+ parser.add_argument("--output", help="Output file for the plan")
603
+
604
+ args = parser.parse_args()
605
+
606
+ try:
607
+ result = auto_plan_program(args.program_file, args.strategy, args.resources)
608
+
609
+ if args.output:
610
+ with open(args.output, 'w') as f:
611
+ json.dump(result, f, indent=2)
612
+ print(f"Plan saved to {args.output}")
613
+ else:
614
+ print(json.dumps(result, indent=2))
615
+
616
+ except Exception as e:
617
+ print(f"Error: {e}")
618
+ exit(1)
rhylthyme/cli/cli.py ADDED
@@ -0,0 +1,52 @@
1
+ import click
2
+ import sys
3
+ import os
4
+ import json
5
+
6
+ @click.group()
7
+ def cli():
8
+ """Rhylthyme CLI Runner"""
9
+ pass
10
+
11
+ @cli.command()
12
+ @click.argument('program_file', type=click.Path(exists=True))
13
+ @click.option('--env', '-e', type=click.Path(exists=True), help='Environment file (optional)')
14
+ @click.option('--debug', is_flag=True, help='Enable debug output')
15
+ def run(program_file, env, debug):
16
+ """Run a Rhylthyme program."""
17
+ click.echo(f"Running program: {program_file}")
18
+ if env:
19
+ click.echo(f"Using environment: {env}")
20
+ if debug:
21
+ click.echo("Debug mode enabled.")
22
+ # Placeholder: Add actual program running logic here
23
+ click.echo("[run] Not yet implemented.")
24
+
25
+ @cli.command()
26
+ @click.argument('input_file', type=click.Path(exists=True))
27
+ @click.option('--type', '-t', type=click.Choice(['program', 'environment']), default='program', help='Type of file to validate')
28
+ def validate(input_file, type):
29
+ """Validate a Rhylthyme program or environment file against the spec."""
30
+ click.echo(f"Validating {type}: {input_file}")
31
+ import jsonschema
32
+ import importlib.resources
33
+ if type == 'program':
34
+ with open(input_file) as f:
35
+ data = json.load(f)
36
+ # Load schema from rhylthyme-spec package
37
+ import pkg_resources
38
+ schema_path = pkg_resources.resource_filename('rhylthyme_spec', 'schemas/program_schema.json')
39
+ with open(schema_path) as sf:
40
+ schema = json.load(sf)
41
+ try:
42
+ jsonschema.validate(instance=data, schema=schema)
43
+ click.echo("Validation successful.")
44
+ except jsonschema.ValidationError as e:
45
+ click.echo(f"Validation failed: {e.message}")
46
+ sys.exit(1)
47
+ else:
48
+ click.echo("Environment validation not yet implemented.")
49
+ sys.exit(1)
50
+
51
+ if __name__ == "__main__":
52
+ cli()