mcli-framework 7.11.4__py3-none-any.whl → 7.12.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/__init__.py +160 -0
- mcli/__main__.py +14 -0
- mcli/app/__init__.py +23 -0
- mcli/app/commands_cmd.py +18 -823
- mcli/app/init_cmd.py +391 -0
- mcli/app/lock_cmd.py +288 -0
- mcli/app/main.py +37 -0
- mcli/app/model/__init__.py +0 -0
- mcli/app/store_cmd.py +448 -0
- mcli/app/video/__init__.py +5 -0
- mcli/chat/__init__.py +34 -0
- mcli/lib/__init__.py +0 -0
- mcli/lib/api/__init__.py +0 -0
- mcli/lib/auth/__init__.py +1 -0
- mcli/lib/config/__init__.py +1 -0
- mcli/lib/custom_commands.py +3 -3
- mcli/lib/erd/__init__.py +25 -0
- mcli/lib/files/__init__.py +0 -0
- mcli/lib/fs/__init__.py +1 -0
- mcli/lib/logger/__init__.py +3 -0
- mcli/lib/optional_deps.py +1 -3
- mcli/lib/performance/__init__.py +17 -0
- mcli/lib/pickles/__init__.py +1 -0
- mcli/lib/secrets/__init__.py +10 -0
- mcli/lib/shell/__init__.py +0 -0
- mcli/lib/toml/__init__.py +1 -0
- mcli/lib/watcher/__init__.py +0 -0
- mcli/ml/__init__.py +16 -0
- mcli/ml/api/__init__.py +30 -0
- mcli/ml/api/routers/__init__.py +27 -0
- mcli/ml/auth/__init__.py +41 -0
- mcli/ml/backtesting/__init__.py +33 -0
- mcli/ml/cli/__init__.py +5 -0
- mcli/ml/config/__init__.py +33 -0
- mcli/ml/configs/__init__.py +16 -0
- mcli/ml/dashboard/__init__.py +12 -0
- mcli/ml/dashboard/components/__init__.py +7 -0
- mcli/ml/dashboard/pages/__init__.py +6 -0
- mcli/ml/data_ingestion/__init__.py +29 -0
- mcli/ml/database/__init__.py +40 -0
- mcli/ml/experimentation/__init__.py +29 -0
- mcli/ml/features/__init__.py +39 -0
- mcli/ml/mlops/__init__.py +19 -0
- mcli/ml/models/__init__.py +90 -0
- mcli/ml/monitoring/__init__.py +25 -0
- mcli/ml/optimization/__init__.py +27 -0
- mcli/ml/predictions/__init__.py +5 -0
- mcli/ml/preprocessing/__init__.py +24 -0
- mcli/ml/scripts/__init__.py +1 -0
- mcli/ml/serving/__init__.py +1 -0
- mcli/ml/trading/__init__.py +63 -0
- mcli/ml/training/__init__.py +7 -0
- mcli/mygroup/__init__.py +3 -0
- mcli/public/__init__.py +1 -0
- mcli/public/commands/__init__.py +2 -0
- mcli/self/__init__.py +3 -0
- mcli/self/migrate_cmd.py +209 -76
- mcli/workflow/__init__.py +0 -0
- mcli/workflow/daemon/__init__.py +15 -0
- mcli/workflow/dashboard/__init__.py +5 -0
- mcli/workflow/docker/__init__.py +0 -0
- mcli/workflow/file/__init__.py +0 -0
- mcli/workflow/gcloud/__init__.py +1 -0
- mcli/workflow/git_commit/__init__.py +0 -0
- mcli/workflow/interview/__init__.py +0 -0
- mcli/workflow/notebook/__init__.py +16 -0
- mcli/workflow/registry/__init__.py +0 -0
- mcli/workflow/repo/__init__.py +0 -0
- mcli/workflow/scheduler/__init__.py +25 -0
- mcli/workflow/search/__init__.py +0 -0
- mcli/workflow/secrets/__init__.py +5 -0
- mcli/workflow/secrets/secrets_cmd.py +1 -2
- mcli/workflow/sync/__init__.py +5 -0
- mcli/workflow/videos/__init__.py +1 -0
- mcli/workflow/wakatime/__init__.py +80 -0
- {mcli_framework-7.11.4.dist-info → mcli_framework-7.12.0.dist-info}/METADATA +10 -10
- {mcli_framework-7.11.4.dist-info → mcli_framework-7.12.0.dist-info}/RECORD +81 -12
- {mcli_framework-7.11.4.dist-info → mcli_framework-7.12.0.dist-info}/WHEEL +0 -0
- {mcli_framework-7.11.4.dist-info → mcli_framework-7.12.0.dist-info}/entry_points.txt +0 -0
- {mcli_framework-7.11.4.dist-info → mcli_framework-7.12.0.dist-info}/licenses/LICENSE +0 -0
- {mcli_framework-7.11.4.dist-info → mcli_framework-7.12.0.dist-info}/top_level.txt +0 -0
mcli/app/init_cmd.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Top-level initialization and teardown commands for MCLI.
|
|
3
|
+
"""
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from rich.prompt import Prompt
|
|
12
|
+
|
|
13
|
+
from mcli.lib.logger.logger import get_logger
|
|
14
|
+
from mcli.lib.ui.styling import console
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.command("init")
|
|
20
|
+
@click.option(
|
|
21
|
+
"--global",
|
|
22
|
+
"-g",
|
|
23
|
+
"is_global",
|
|
24
|
+
is_flag=True,
|
|
25
|
+
help="Initialize global workflows directory instead of local",
|
|
26
|
+
)
|
|
27
|
+
@click.option("--git", is_flag=True, help="Initialize git repository in workflows directory")
|
|
28
|
+
@click.option("--force", "-f", is_flag=True, help="Force initialization even if directory exists")
|
|
29
|
+
def init(is_global, git, force):
|
|
30
|
+
"""
|
|
31
|
+
Initialize workflows directory structure.
|
|
32
|
+
|
|
33
|
+
Creates the necessary directories and configuration files for managing
|
|
34
|
+
custom workflows. By default, creates a local .mcli/workflows/ directory
|
|
35
|
+
if in a git repository, otherwise uses ~/.mcli/workflows/.
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
mcli init # Initialize local workflows (if in git repo)
|
|
39
|
+
mcli init --global # Initialize global workflows
|
|
40
|
+
mcli init --git # Also initialize git repository
|
|
41
|
+
"""
|
|
42
|
+
from mcli.lib.paths import get_git_root, get_local_mcli_dir, get_mcli_home, is_git_repository
|
|
43
|
+
|
|
44
|
+
# Determine if we're in a git repository
|
|
45
|
+
in_git_repo = is_git_repository() and not is_global
|
|
46
|
+
git_root = get_git_root() if in_git_repo else None
|
|
47
|
+
|
|
48
|
+
# Explicitly create workflows directory (not commands)
|
|
49
|
+
# This bypasses the migration logic that would check for old commands/ directory
|
|
50
|
+
if not is_global and in_git_repo:
|
|
51
|
+
local_mcli = get_local_mcli_dir()
|
|
52
|
+
workflows_dir = local_mcli / "workflows"
|
|
53
|
+
else:
|
|
54
|
+
workflows_dir = get_mcli_home() / "workflows"
|
|
55
|
+
|
|
56
|
+
lockfile_path = workflows_dir / "commands.lock.json"
|
|
57
|
+
|
|
58
|
+
# Check if already initialized
|
|
59
|
+
if workflows_dir.exists() and not force:
|
|
60
|
+
if lockfile_path.exists():
|
|
61
|
+
console.print(
|
|
62
|
+
f"[yellow]Workflows directory already initialized at:[/yellow] {workflows_dir}"
|
|
63
|
+
)
|
|
64
|
+
console.print(f"[dim]Use --force to reinitialize[/dim]")
|
|
65
|
+
|
|
66
|
+
should_continue = Prompt.ask("Continue anyway?", choices=["y", "n"], default="n")
|
|
67
|
+
if should_continue.lower() != "y":
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
# Create workflows directory
|
|
71
|
+
workflows_dir.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
console.print(f"[green]✓[/green] Created workflows directory: {workflows_dir}")
|
|
73
|
+
|
|
74
|
+
# Create README.md
|
|
75
|
+
readme_path = workflows_dir / "README.md"
|
|
76
|
+
if not readme_path.exists() or force:
|
|
77
|
+
scope = "local" if in_git_repo else "global"
|
|
78
|
+
scope_desc = f"for repository: {git_root.name}" if in_git_repo else "globally"
|
|
79
|
+
|
|
80
|
+
readme_content = f"""# MCLI Custom Workflows
|
|
81
|
+
|
|
82
|
+
This directory contains custom workflow commands {scope_desc}.
|
|
83
|
+
|
|
84
|
+
## Quick Start
|
|
85
|
+
|
|
86
|
+
### Create a New Workflow
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Python workflow
|
|
90
|
+
mcli workflow add my-workflow
|
|
91
|
+
|
|
92
|
+
# Shell workflow
|
|
93
|
+
mcli workflow add my-script --language shell
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### List Workflows
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
mcli workflow list --custom-only
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Execute a Workflow
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
mcli workflows my-workflow
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Edit a Workflow
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
mcli workflow edit my-workflow
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Export/Import Workflows
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Export all workflows
|
|
118
|
+
mcli workflow export workflows-backup.json
|
|
119
|
+
|
|
120
|
+
# Import workflows
|
|
121
|
+
mcli workflow import workflows-backup.json
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Directory Structure
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
{workflows_dir.name}/
|
|
128
|
+
├── README.md # This file
|
|
129
|
+
├── commands.lock.json # Lockfile for workflow state
|
|
130
|
+
└── *.json # Individual workflow definitions
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Workflow Format
|
|
134
|
+
|
|
135
|
+
Workflows are stored as JSON files with the following structure:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{{
|
|
139
|
+
"name": "workflow-name",
|
|
140
|
+
"description": "Workflow description",
|
|
141
|
+
"code": "Python or shell code",
|
|
142
|
+
"language": "python",
|
|
143
|
+
"group": "workflow",
|
|
144
|
+
"version": "1.0",
|
|
145
|
+
"created_at": "2025-10-30T...",
|
|
146
|
+
"updated_at": "2025-10-30T..."
|
|
147
|
+
}}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Scope
|
|
151
|
+
|
|
152
|
+
- **Scope**: {'Local (repository-specific)' if in_git_repo else 'Global (user-wide)'}
|
|
153
|
+
- **Location**: `{workflows_dir}`
|
|
154
|
+
{f"- **Git Repository**: `{git_root}`" if git_root else ""}
|
|
155
|
+
|
|
156
|
+
## Documentation
|
|
157
|
+
|
|
158
|
+
- [MCLI Documentation](https://github.com/gwicho38/mcli)
|
|
159
|
+
- [Workflow Guide](https://github.com/gwicho38/mcli/blob/main/docs/features/LOCAL_VS_GLOBAL_COMMANDS.md)
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
*Initialized: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
readme_path.write_text(readme_content)
|
|
167
|
+
console.print(f"[green]✓[/green] Created README: {readme_path.name}")
|
|
168
|
+
|
|
169
|
+
# Initialize lockfile
|
|
170
|
+
if not lockfile_path.exists() or force:
|
|
171
|
+
lockfile_data = {
|
|
172
|
+
"version": "1.0",
|
|
173
|
+
"initialized_at": datetime.now().isoformat(),
|
|
174
|
+
"scope": "local" if in_git_repo else "global",
|
|
175
|
+
"commands": {},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
with open(lockfile_path, "w") as f:
|
|
179
|
+
json.dump(lockfile_data, f, indent=2)
|
|
180
|
+
|
|
181
|
+
console.print(f"[green]✓[/green] Initialized lockfile: {lockfile_path.name}")
|
|
182
|
+
|
|
183
|
+
# Create .gitignore if in workflows directory
|
|
184
|
+
gitignore_path = workflows_dir / ".gitignore"
|
|
185
|
+
if not gitignore_path.exists() or force:
|
|
186
|
+
gitignore_content = """# Backup files
|
|
187
|
+
*.backup
|
|
188
|
+
*.bak
|
|
189
|
+
|
|
190
|
+
# Temporary files
|
|
191
|
+
*.tmp
|
|
192
|
+
*.temp
|
|
193
|
+
|
|
194
|
+
# OS files
|
|
195
|
+
.DS_Store
|
|
196
|
+
Thumbs.db
|
|
197
|
+
|
|
198
|
+
# Editor files
|
|
199
|
+
*.swp
|
|
200
|
+
*.swo
|
|
201
|
+
*~
|
|
202
|
+
.vscode/
|
|
203
|
+
.idea/
|
|
204
|
+
"""
|
|
205
|
+
gitignore_path.write_text(gitignore_content)
|
|
206
|
+
console.print(f"[green]✓[/green] Created .gitignore")
|
|
207
|
+
|
|
208
|
+
# Initialize git if requested
|
|
209
|
+
if git and not (workflows_dir / ".git").exists():
|
|
210
|
+
try:
|
|
211
|
+
subprocess.run(["git", "init"], cwd=workflows_dir, check=True, capture_output=True)
|
|
212
|
+
console.print(f"[green]✓[/green] Initialized git repository in workflows directory")
|
|
213
|
+
|
|
214
|
+
# Create initial commit
|
|
215
|
+
subprocess.run(["git", "add", "."], cwd=workflows_dir, check=True, capture_output=True)
|
|
216
|
+
subprocess.run(
|
|
217
|
+
["git", "commit", "-m", "Initial commit: Initialize workflows directory"],
|
|
218
|
+
cwd=workflows_dir,
|
|
219
|
+
check=True,
|
|
220
|
+
capture_output=True,
|
|
221
|
+
)
|
|
222
|
+
console.print(f"[green]✓[/green] Created initial commit")
|
|
223
|
+
|
|
224
|
+
except subprocess.CalledProcessError as e:
|
|
225
|
+
console.print(f"[yellow]⚠[/yellow] Git initialization failed: {e}")
|
|
226
|
+
except FileNotFoundError:
|
|
227
|
+
console.print(f"[yellow]⚠[/yellow] Git not found. Skipping git initialization.")
|
|
228
|
+
|
|
229
|
+
# Summary
|
|
230
|
+
from rich.table import Table
|
|
231
|
+
|
|
232
|
+
console.print()
|
|
233
|
+
console.print("[bold green]Workflows directory initialized successfully![/bold green]")
|
|
234
|
+
console.print()
|
|
235
|
+
|
|
236
|
+
# Display summary table
|
|
237
|
+
table = Table(title="Initialization Summary", show_header=False)
|
|
238
|
+
table.add_column("Property", style="cyan")
|
|
239
|
+
table.add_column("Value", style="green")
|
|
240
|
+
|
|
241
|
+
table.add_row("Scope", "Local (repository-specific)" if in_git_repo else "Global (user-wide)")
|
|
242
|
+
table.add_row("Location", str(workflows_dir))
|
|
243
|
+
if git_root:
|
|
244
|
+
table.add_row("Git Repository", str(git_root))
|
|
245
|
+
table.add_row("Lockfile", str(lockfile_path))
|
|
246
|
+
table.add_row("Git Initialized", "Yes" if git and (workflows_dir / ".git").exists() else "No")
|
|
247
|
+
|
|
248
|
+
console.print(table)
|
|
249
|
+
console.print()
|
|
250
|
+
|
|
251
|
+
# Next steps
|
|
252
|
+
console.print("[bold]Next Steps:[/bold]")
|
|
253
|
+
console.print(" 1. Create a workflow: [cyan]mcli workflow add my-workflow[/cyan]")
|
|
254
|
+
console.print(" 2. List workflows: [cyan]mcli workflow list --custom-only[/cyan]")
|
|
255
|
+
console.print(" 3. Execute workflow: [cyan]mcli workflows my-workflow[/cyan]")
|
|
256
|
+
console.print(" 4. View README: [cyan]cat {}/README.md[/cyan]".format(workflows_dir))
|
|
257
|
+
console.print()
|
|
258
|
+
|
|
259
|
+
if in_git_repo:
|
|
260
|
+
console.print(
|
|
261
|
+
f"[dim]Tip: Workflows are local to this repository. Use --global for user-wide workflows.[/dim]"
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
console.print(
|
|
265
|
+
f"[dim]Tip: Use workflows in any git repository, or create local ones with 'mcli init' inside repos.[/dim]"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@click.command("teardown")
|
|
272
|
+
@click.option(
|
|
273
|
+
"--global",
|
|
274
|
+
"-g",
|
|
275
|
+
"is_global",
|
|
276
|
+
is_flag=True,
|
|
277
|
+
help="Teardown global workflows directory instead of local",
|
|
278
|
+
)
|
|
279
|
+
@click.option("--force", "-f", is_flag=True, help="Skip all confirmation prompts")
|
|
280
|
+
def teardown(is_global, force):
|
|
281
|
+
"""
|
|
282
|
+
Remove all local MCLI artifacts.
|
|
283
|
+
|
|
284
|
+
This command deletes the workflows directory and all associated files.
|
|
285
|
+
By default, operates on local workflows (if in git repo), use --global for
|
|
286
|
+
global workflows.
|
|
287
|
+
|
|
288
|
+
For safety, you must type the name of the current directory to confirm.
|
|
289
|
+
|
|
290
|
+
Examples:
|
|
291
|
+
mcli teardown # Remove local workflows (requires confirmation)
|
|
292
|
+
mcli teardown --global # Remove global workflows (requires confirmation)
|
|
293
|
+
mcli teardown --force # Skip confirmations (dangerous!)
|
|
294
|
+
"""
|
|
295
|
+
from mcli.lib.paths import get_git_root, get_local_mcli_dir, get_mcli_home, is_git_repository
|
|
296
|
+
|
|
297
|
+
# Determine which directory to teardown
|
|
298
|
+
in_git_repo = is_git_repository() and not is_global
|
|
299
|
+
git_root = get_git_root() if in_git_repo else None
|
|
300
|
+
|
|
301
|
+
if not is_global and in_git_repo:
|
|
302
|
+
local_mcli = get_local_mcli_dir()
|
|
303
|
+
workflows_dir = local_mcli / "workflows"
|
|
304
|
+
scope = "local"
|
|
305
|
+
scope_display = git_root.name if git_root else "current repository"
|
|
306
|
+
else:
|
|
307
|
+
workflows_dir = get_mcli_home() / "workflows"
|
|
308
|
+
scope = "global"
|
|
309
|
+
scope_display = "global (~/.mcli)"
|
|
310
|
+
|
|
311
|
+
# Check if workflows directory exists
|
|
312
|
+
if not workflows_dir.exists():
|
|
313
|
+
console.print(f"[yellow]No workflows directory found at:[/yellow] {workflows_dir}")
|
|
314
|
+
console.print("[dim]Nothing to teardown.[/dim]")
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
# Display what will be removed
|
|
318
|
+
console.print(f"[bold red]⚠ WARNING: This will delete all {scope} MCLI artifacts[/bold red]")
|
|
319
|
+
console.print()
|
|
320
|
+
console.print(f"[bold]Scope:[/bold] {scope_display}")
|
|
321
|
+
console.print(f"[bold]Directory:[/bold] {workflows_dir}")
|
|
322
|
+
|
|
323
|
+
# Count files
|
|
324
|
+
try:
|
|
325
|
+
file_count = sum(1 for _ in workflows_dir.rglob("*") if _.is_file())
|
|
326
|
+
console.print(f"[bold]Files to delete:[/bold] {file_count}")
|
|
327
|
+
except Exception:
|
|
328
|
+
file_count = "unknown"
|
|
329
|
+
console.print(f"[bold]Files to delete:[/bold] {file_count}")
|
|
330
|
+
|
|
331
|
+
console.print()
|
|
332
|
+
|
|
333
|
+
# Safety confirmation
|
|
334
|
+
if not force:
|
|
335
|
+
console.print("[yellow]This action cannot be undone![/yellow]")
|
|
336
|
+
console.print()
|
|
337
|
+
|
|
338
|
+
if in_git_repo and git_root:
|
|
339
|
+
# For local repos, require typing the repo name
|
|
340
|
+
expected_name = git_root.name
|
|
341
|
+
console.print(
|
|
342
|
+
f"[bold]To confirm, type the repository name:[/bold] [cyan]{expected_name}[/cyan]"
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
# For global, require typing "global"
|
|
346
|
+
expected_name = "global"
|
|
347
|
+
console.print(f"[bold]To confirm, type:[/bold] [cyan]{expected_name}[/cyan]")
|
|
348
|
+
|
|
349
|
+
confirmation = Prompt.ask("Confirmation")
|
|
350
|
+
|
|
351
|
+
if confirmation != expected_name:
|
|
352
|
+
console.print("[red]Confirmation failed. Teardown cancelled.[/red]")
|
|
353
|
+
return 1
|
|
354
|
+
|
|
355
|
+
# Perform teardown
|
|
356
|
+
try:
|
|
357
|
+
console.print()
|
|
358
|
+
console.print("[yellow]Removing workflows directory...[/yellow]")
|
|
359
|
+
|
|
360
|
+
shutil.rmtree(workflows_dir)
|
|
361
|
+
|
|
362
|
+
console.print(f"[green]✓[/green] Removed: {workflows_dir}")
|
|
363
|
+
|
|
364
|
+
# Also remove parent .mcli directory if empty (local only)
|
|
365
|
+
if not is_global and in_git_repo:
|
|
366
|
+
try:
|
|
367
|
+
parent_dir = workflows_dir.parent
|
|
368
|
+
if parent_dir.exists() and not any(parent_dir.iterdir()):
|
|
369
|
+
parent_dir.rmdir()
|
|
370
|
+
console.print(f"[green]✓[/green] Removed empty directory: {parent_dir}")
|
|
371
|
+
except OSError:
|
|
372
|
+
pass # Directory not empty, that's fine
|
|
373
|
+
|
|
374
|
+
console.print()
|
|
375
|
+
console.print("[bold green]Teardown complete![/bold green]")
|
|
376
|
+
|
|
377
|
+
if in_git_repo:
|
|
378
|
+
console.print(
|
|
379
|
+
"[dim]Local workflows removed. Global workflows (if any) are still available.[/dim]"
|
|
380
|
+
)
|
|
381
|
+
else:
|
|
382
|
+
console.print(
|
|
383
|
+
"[dim]Global workflows removed. Local workflows (if any) are still available.[/dim]"
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
return 0
|
|
387
|
+
|
|
388
|
+
except Exception as e:
|
|
389
|
+
console.print(f"[red]Error during teardown: {e}[/red]")
|
|
390
|
+
logger.exception(e)
|
|
391
|
+
return 1
|
mcli/app/lock_cmd.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Top-level lock management commands for MCLI.
|
|
3
|
+
Manages workflow lockfile and verification.
|
|
4
|
+
"""
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from mcli.lib.custom_commands import get_command_manager
|
|
14
|
+
from mcli.lib.logger.logger import get_logger
|
|
15
|
+
from mcli.lib.ui.styling import console
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
# Command state lockfile configuration
|
|
20
|
+
LOCKFILE_PATH = Path.home() / ".local" / "mcli" / "command_lock.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_lockfile():
|
|
24
|
+
"""Load the command state lockfile."""
|
|
25
|
+
if LOCKFILE_PATH.exists():
|
|
26
|
+
with open(LOCKFILE_PATH, "r") as f:
|
|
27
|
+
return json.load(f)
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def save_lockfile(states):
|
|
32
|
+
"""Save states to the command state lockfile."""
|
|
33
|
+
LOCKFILE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
with open(LOCKFILE_PATH, "w") as f:
|
|
35
|
+
json.dump(states, f, indent=2, default=str)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def append_lockfile(new_state):
|
|
39
|
+
"""Append a new state to the lockfile."""
|
|
40
|
+
states = load_lockfile()
|
|
41
|
+
states.append(new_state)
|
|
42
|
+
save_lockfile(states)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def find_state_by_hash(hash_value):
|
|
46
|
+
"""Find a state by its hash value."""
|
|
47
|
+
states = load_lockfile()
|
|
48
|
+
for state in states:
|
|
49
|
+
if state["hash"] == hash_value:
|
|
50
|
+
return state
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def restore_command_state(hash_value):
|
|
55
|
+
"""Restore to a previous command state."""
|
|
56
|
+
state = find_state_by_hash(hash_value)
|
|
57
|
+
if not state:
|
|
58
|
+
return False
|
|
59
|
+
# Here you would implement logic to restore the command registry to this state
|
|
60
|
+
# For now, just print the commands
|
|
61
|
+
print(json.dumps(state["commands"], indent=2))
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_current_command_state():
|
|
66
|
+
"""Collect all command metadata (names, groups, etc.)"""
|
|
67
|
+
# Import here to avoid circular imports
|
|
68
|
+
import importlib
|
|
69
|
+
import inspect
|
|
70
|
+
import os
|
|
71
|
+
from pathlib import Path
|
|
72
|
+
|
|
73
|
+
commands = []
|
|
74
|
+
|
|
75
|
+
# Look for command modules in the mcli package
|
|
76
|
+
mcli_path = Path(__file__).parent.parent
|
|
77
|
+
|
|
78
|
+
# This finds command groups as directories under mcli
|
|
79
|
+
for item in mcli_path.iterdir():
|
|
80
|
+
if item.is_dir() and not item.name.startswith("__") and not item.name.startswith("."):
|
|
81
|
+
group_name = item.name
|
|
82
|
+
|
|
83
|
+
# Recursively find all Python files that might define commands
|
|
84
|
+
for py_file in item.glob("**/*.py"):
|
|
85
|
+
if py_file.name.startswith("__"):
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# Convert file path to module path
|
|
89
|
+
relative_path = py_file.relative_to(mcli_path.parent)
|
|
90
|
+
module_name = str(relative_path.with_suffix("")).replace(os.sep, ".")
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
# Try to import the module
|
|
94
|
+
module = importlib.import_module(module_name)
|
|
95
|
+
|
|
96
|
+
# Suppress Streamlit logging noise during command collection
|
|
97
|
+
if "streamlit" in module_name or "dashboard" in module_name:
|
|
98
|
+
# Suppress streamlit logger to avoid noise
|
|
99
|
+
import logging
|
|
100
|
+
|
|
101
|
+
streamlit_logger = logging.getLogger("streamlit")
|
|
102
|
+
original_level = streamlit_logger.level
|
|
103
|
+
streamlit_logger.setLevel(logging.CRITICAL)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
# Import and extract commands
|
|
107
|
+
pass
|
|
108
|
+
finally:
|
|
109
|
+
# Restore original logging level
|
|
110
|
+
streamlit_logger.setLevel(original_level)
|
|
111
|
+
|
|
112
|
+
# Extract command and group objects
|
|
113
|
+
for name, obj in inspect.getmembers(module):
|
|
114
|
+
# Handle Click commands and groups
|
|
115
|
+
if isinstance(obj, click.Command):
|
|
116
|
+
if isinstance(obj, click.Group):
|
|
117
|
+
# Found a Click group
|
|
118
|
+
app_info = {
|
|
119
|
+
"name": obj.name,
|
|
120
|
+
"group": group_name,
|
|
121
|
+
"path": module_name,
|
|
122
|
+
"help": obj.help,
|
|
123
|
+
}
|
|
124
|
+
commands.append(app_info)
|
|
125
|
+
|
|
126
|
+
# Add subcommands if any
|
|
127
|
+
for cmd_name, cmd in obj.commands.items():
|
|
128
|
+
commands.append(
|
|
129
|
+
{
|
|
130
|
+
"name": cmd_name,
|
|
131
|
+
"group": f"{group_name}.{app_info['name']}",
|
|
132
|
+
"path": f"{module_name}.{cmd_name}",
|
|
133
|
+
"help": cmd.help,
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
# Found a standalone Click command
|
|
138
|
+
commands.append(
|
|
139
|
+
{
|
|
140
|
+
"name": obj.name,
|
|
141
|
+
"group": group_name,
|
|
142
|
+
"path": f"{module_name}.{obj.name}",
|
|
143
|
+
"help": obj.help,
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
except (ImportError, AttributeError) as e:
|
|
147
|
+
logger.debug(f"Skipping {module_name}: {e}")
|
|
148
|
+
|
|
149
|
+
return commands
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def hash_command_state(commands):
|
|
153
|
+
"""Hash the command state for fast comparison."""
|
|
154
|
+
# Sort for deterministic hash
|
|
155
|
+
commands_sorted = sorted(commands, key=lambda c: (c.get("group") or "", c["name"]))
|
|
156
|
+
state_json = json.dumps(commands_sorted, sort_keys=True)
|
|
157
|
+
return hashlib.sha256(state_json.encode("utf-8")).hexdigest()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@click.group(name="lock")
|
|
161
|
+
def lock():
|
|
162
|
+
"""Manage workflow lockfile and verification."""
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@lock.command("list")
|
|
167
|
+
def list_states():
|
|
168
|
+
"""List all saved command states (hash, timestamp, #commands)."""
|
|
169
|
+
states = load_lockfile()
|
|
170
|
+
if not states:
|
|
171
|
+
click.echo("No command states found.")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
table = Table(title="Command States")
|
|
175
|
+
table.add_column("Hash", style="cyan")
|
|
176
|
+
table.add_column("Timestamp", style="green")
|
|
177
|
+
table.add_column("# Commands", style="yellow")
|
|
178
|
+
|
|
179
|
+
for state in states:
|
|
180
|
+
table.add_row(state["hash"][:8], state["timestamp"], str(len(state["commands"])))
|
|
181
|
+
|
|
182
|
+
console.print(table)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@lock.command("restore")
|
|
186
|
+
@click.argument("hash_value")
|
|
187
|
+
def restore_state(hash_value):
|
|
188
|
+
"""Restore to a previous command state by hash."""
|
|
189
|
+
if restore_command_state(hash_value):
|
|
190
|
+
click.echo(f"Restored to state {hash_value[:8]}")
|
|
191
|
+
else:
|
|
192
|
+
click.echo(f"State {hash_value[:8]} not found.", err=True)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@lock.command("write")
|
|
196
|
+
@click.argument("json_file", required=False, type=click.Path(exists=False))
|
|
197
|
+
def write_state(json_file):
|
|
198
|
+
"""Write a new command state to the lockfile from a JSON file or the current app state."""
|
|
199
|
+
import traceback
|
|
200
|
+
|
|
201
|
+
print("[DEBUG] write_state called")
|
|
202
|
+
print(f"[DEBUG] LOCKFILE_PATH: {LOCKFILE_PATH}")
|
|
203
|
+
try:
|
|
204
|
+
if json_file:
|
|
205
|
+
print(f"[DEBUG] Loading command state from file: {json_file}")
|
|
206
|
+
with open(json_file, "r") as f:
|
|
207
|
+
commands = json.load(f)
|
|
208
|
+
click.echo(f"Loaded command state from {json_file}.")
|
|
209
|
+
else:
|
|
210
|
+
print("[DEBUG] Snapshotting current command state.")
|
|
211
|
+
commands = get_current_command_state()
|
|
212
|
+
|
|
213
|
+
state_hash = hash_command_state(commands)
|
|
214
|
+
new_state = {
|
|
215
|
+
"hash": state_hash,
|
|
216
|
+
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
217
|
+
"commands": commands,
|
|
218
|
+
}
|
|
219
|
+
append_lockfile(new_state)
|
|
220
|
+
print(f"[DEBUG] Wrote new command state {state_hash[:8]} to lockfile at {LOCKFILE_PATH}")
|
|
221
|
+
click.echo(f"Wrote new command state {state_hash[:8]} to lockfile.")
|
|
222
|
+
except Exception as e:
|
|
223
|
+
print(f"[ERROR] Exception in write_state: {e}")
|
|
224
|
+
print(traceback.format_exc())
|
|
225
|
+
click.echo(f"[ERROR] Failed to write command state: {e}", err=True)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@lock.command("verify")
|
|
229
|
+
@click.option(
|
|
230
|
+
"--global", "-g", "is_global", is_flag=True, help="Verify global commands instead of local"
|
|
231
|
+
)
|
|
232
|
+
def verify_commands(is_global):
|
|
233
|
+
"""
|
|
234
|
+
Verify that custom commands match the lockfile.
|
|
235
|
+
|
|
236
|
+
By default verifies local commands (if in git repo), use --global/-g for global commands.
|
|
237
|
+
"""
|
|
238
|
+
manager = get_command_manager(global_mode=is_global)
|
|
239
|
+
|
|
240
|
+
# First, ensure lockfile is up to date
|
|
241
|
+
manager.update_lockfile()
|
|
242
|
+
|
|
243
|
+
verification = manager.verify_lockfile()
|
|
244
|
+
|
|
245
|
+
if verification["valid"]:
|
|
246
|
+
console.print("[green]All custom commands are in sync with the lockfile.[/green]")
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
console.print("[yellow]Commands are out of sync with the lockfile:[/yellow]\n")
|
|
250
|
+
|
|
251
|
+
if verification["missing"]:
|
|
252
|
+
console.print("Missing commands (in lockfile but not found):")
|
|
253
|
+
for name in verification["missing"]:
|
|
254
|
+
console.print(f" - {name}")
|
|
255
|
+
|
|
256
|
+
if verification["extra"]:
|
|
257
|
+
console.print("\nExtra commands (not in lockfile):")
|
|
258
|
+
for name in verification["extra"]:
|
|
259
|
+
console.print(f" - {name}")
|
|
260
|
+
|
|
261
|
+
if verification["modified"]:
|
|
262
|
+
console.print("\nModified commands:")
|
|
263
|
+
for name in verification["modified"]:
|
|
264
|
+
console.print(f" - {name}")
|
|
265
|
+
|
|
266
|
+
console.print("\n[dim]Run 'mcli lock update' to sync the lockfile[/dim]")
|
|
267
|
+
|
|
268
|
+
return 1
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@lock.command("update")
|
|
272
|
+
@click.option(
|
|
273
|
+
"--global", "-g", "is_global", is_flag=True, help="Update global lockfile instead of local"
|
|
274
|
+
)
|
|
275
|
+
def update_lockfile(is_global):
|
|
276
|
+
"""
|
|
277
|
+
Update the commands lockfile with current state.
|
|
278
|
+
|
|
279
|
+
By default updates local lockfile (if in git repo), use --global/-g for global lockfile.
|
|
280
|
+
"""
|
|
281
|
+
manager = get_command_manager(global_mode=is_global)
|
|
282
|
+
|
|
283
|
+
if manager.update_lockfile():
|
|
284
|
+
console.print(f"[green]Updated lockfile: {manager.lockfile_path}[/green]")
|
|
285
|
+
return 0
|
|
286
|
+
else:
|
|
287
|
+
console.print("[red]Failed to update lockfile.[/red]")
|
|
288
|
+
return 1
|