mlx-commander 0.3.19__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.
@@ -0,0 +1,12 @@
1
+ """
2
+ mlx_commander - Hugging Face to MLX Dataset Converter
3
+ Convert Hugging Face datasets into Apple MLX fine-tuning formats with interactive TUI and CLI.
4
+ """
5
+
6
+ import os
7
+
8
+ # Set ncurses escape delay to 25ms (default is 1000ms) to ensure instantaneous
9
+ # exit/cancellation on ESC across all curses pickers and dialogs.
10
+ os.environ.setdefault("ESCDELAY", "25")
11
+
12
+ __version__ = "0.3.19"
@@ -0,0 +1,10 @@
1
+ """Top-level package execution entry point.
2
+
3
+ Allows running MLX Commander via:
4
+ python3 -m mlx_commander
5
+ """
6
+ import sys
7
+ from mlx_commander.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,69 @@
1
+ #import <Cocoa/Cocoa.h>
2
+
3
+ int main(int argc, const char * argv[]) {
4
+ @autoreleasepool {
5
+ [NSApplication sharedApplication];
6
+ [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
7
+ [NSApp activateIgnoringOtherApps:YES];
8
+
9
+ NSOpenPanel *panel = [NSOpenPanel openPanel];
10
+ panel.canChooseFiles = YES;
11
+ panel.canChooseDirectories = YES;
12
+ panel.allowsMultipleSelection = YES;
13
+ panel.canCreateDirectories = YES;
14
+ panel.title = @"Select Dataset (File or Folder)";
15
+ panel.prompt = @"Select";
16
+
17
+ // Argument 1: mode ("both", "folder", "file")
18
+ if (argc > 1) {
19
+ NSString *mode = [NSString stringWithUTF8String:argv[1]];
20
+ if ([mode isEqualToString:@"folder"]) {
21
+ panel.canChooseFiles = NO;
22
+ panel.canChooseDirectories = YES;
23
+ panel.allowsMultipleSelection = NO;
24
+ panel.title = @"Select Destination Folder";
25
+ } else if ([mode isEqualToString:@"file"]) {
26
+ panel.canChooseFiles = YES;
27
+ panel.canChooseDirectories = NO;
28
+ panel.allowsMultipleSelection = YES;
29
+ panel.title = @"Select Dataset File(s)";
30
+ } else {
31
+ panel.canChooseFiles = YES;
32
+ panel.canChooseDirectories = YES;
33
+ panel.allowsMultipleSelection = YES;
34
+ panel.title = @"Select Dataset (File, Files, or Folder)";
35
+ }
36
+ }
37
+
38
+ // Argument 2: initial directory
39
+ if (argc > 2) {
40
+ NSString *dirPath = [NSString stringWithUTF8String:argv[2]];
41
+ BOOL isDir = NO;
42
+ if ([[NSFileManager defaultManager] fileExistsAtPath:dirPath isDirectory:&isDir]) {
43
+ if (!isDir) {
44
+ dirPath = [dirPath stringByDeletingLastPathComponent];
45
+ }
46
+ panel.directoryURL = [NSURL fileURLWithPath:dirPath];
47
+ }
48
+ }
49
+
50
+ // Argument 3: custom title
51
+ if (argc > 3) {
52
+ panel.title = [NSString stringWithUTF8String:argv[3]];
53
+ }
54
+
55
+ [panel makeKeyAndOrderFront:nil];
56
+ [panel setLevel:NSFloatingWindowLevel];
57
+
58
+ NSModalResponse response = [panel runModal];
59
+ if (response == NSModalResponseOK) {
60
+ for (NSURL *selectedURL in [panel URLs]) {
61
+ if (selectedURL) {
62
+ printf("%s\n", [[selectedURL path] UTF8String]);
63
+ }
64
+ }
65
+ fflush(stdout);
66
+ }
67
+ }
68
+ return 0;
69
+ }
mlx_commander/cli.py ADDED
@@ -0,0 +1,463 @@
1
+ """
2
+ Command Line Interface (CLI) for mlx_commander converter.
3
+ Dispatches between full-screen Curses TUI, interactive terminal wizard,
4
+ and automated headless conversion.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import sys
11
+
12
+ # Ensure ncurses escape delay is 25ms to make ESC instantaneous in all TUI pickers
13
+ os.environ.setdefault("ESCDELAY", "25")
14
+
15
+ import curses
16
+ from pathlib import Path
17
+ from typing import List, Optional
18
+
19
+ from mlx_commander import __version__
20
+ from mlx_commander.converter import ConversionResult, convert_and_save
21
+ from mlx_commander.formats import (
22
+ ColumnMapping,
23
+ MLXFormat,
24
+ auto_detect_mapping,
25
+ validate_mapping,
26
+ )
27
+ from mlx_commander.exceptions import MissingDependencyError
28
+ from mlx_commander.loader import load_local_dataset
29
+ from mlx_commander.splitter import SplitConfig, generate_random_seed
30
+ from mlx_commander.tui.app import launch_tui
31
+ from mlx_commander.tui.wizard_fallback import run_interactive_wizard
32
+
33
+
34
+ def parse_mapping_arg(mapping_str: str) -> ColumnMapping:
35
+ """Parse JSON or key=val,key=val string into ColumnMapping."""
36
+ clean = mapping_str.strip()
37
+ if clean.startswith("{"):
38
+ d = json.loads(clean)
39
+ return ColumnMapping(**d)
40
+
41
+ mapping = ColumnMapping()
42
+ for pair in clean.split(","):
43
+ if "=" in pair:
44
+ k, v = pair.split("=", 1)
45
+ k, v = k.strip(), v.strip()
46
+ if hasattr(mapping, k):
47
+ setattr(mapping, k, v)
48
+ elif k == "prompt":
49
+ mapping.prompt_col = v
50
+ elif k == "completion":
51
+ mapping.completion_col = v
52
+ elif k == "text":
53
+ mapping.text_col = v
54
+ elif k == "messages":
55
+ mapping.messages_col = v
56
+ elif k == "user":
57
+ mapping.user_col = v
58
+ elif k == "assistant":
59
+ mapping.assistant_col = v
60
+ elif k == "system":
61
+ mapping.system_col = v
62
+ elif k == "chosen":
63
+ mapping.chosen_col = v
64
+ elif k == "rejected":
65
+ mapping.rejected_col = v
66
+ return mapping
67
+
68
+
69
+ def parse_prefill_state(val: str) -> dict:
70
+ """Parse prefill state from a JSON string or file path."""
71
+ clean = val.strip()
72
+ if clean.startswith("{"):
73
+ return json.loads(clean)
74
+ p = Path(clean).expanduser()
75
+ if p.exists() and p.is_file():
76
+ with open(p, "r", encoding="utf-8") as f:
77
+ return json.load(f)
78
+ try:
79
+ return json.loads(clean)
80
+ except Exception as e:
81
+ raise ValueError(f"Could not parse --prefill-state as JSON or file path: {e}")
82
+
83
+
84
+ def build_parser() -> argparse.ArgumentParser:
85
+ parser = argparse.ArgumentParser(
86
+ prog="mlx_commander",
87
+ description="Convert Hugging Face datasets into Apple MLX (mlx-lm) format with TUI or CLI.",
88
+ formatter_class=argparse.RawDescriptionHelpFormatter,
89
+ epilog="""
90
+ Examples:
91
+ # Launch interactive TUI wizard:
92
+ mlx_commander
93
+
94
+ # Launch line-by-line CLI wizard:
95
+ mlx_commander --no-tui
96
+
97
+ # Direct conversion from CLI:
98
+ mlx_commander -d ./my_hf_dataset -f prompt_completion -o ./mlx_out \
99
+ --prompt-col question --completion-col answer \
100
+ --train 80 --valid 10 --test 10 --seed 42
101
+
102
+ # Combine multiple dataset files with schema verification & re-splitting:
103
+ mlx_commander -d train.jsonl test.jsonl -f prompt_completion -o ./mlx_out \
104
+ --prompt-col question --completion-col answer
105
+ """,
106
+ )
107
+
108
+ parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
109
+
110
+ # Core conversion flags
111
+ parser.add_argument(
112
+ "-d", "--dataset",
113
+ nargs="+",
114
+ type=str,
115
+ help="Path(s) to local Hugging Face dataset folder or data file(s) (.parquet, .arrow, .jsonl, .csv, .tsv, .sqlite, .tar). Multiple files will be verified for schema consistency and merged.",
116
+ )
117
+ parser.add_argument(
118
+ "-f", "--format",
119
+ type=str,
120
+ choices=["text", "chat", "prompt_completion", "dpo"],
121
+ help="Target MLX format: 'text', 'chat', 'prompt_completion', or 'dpo'.",
122
+ )
123
+ parser.add_argument(
124
+ "-o", "--output",
125
+ type=str,
126
+ help="Destination directory where train.jsonl, valid.jsonl, test.jsonl will be saved (default: 'mlx_dataset' subfolder in same folder as source dataset).",
127
+ )
128
+
129
+ # Split parameters
130
+ parser.add_argument("--train", type=float, default=None, help="Train split percentage (e.g. 80.0).")
131
+ parser.add_argument("--valid", type=float, default=None, help="Validation split percentage (e.g. 10.0).")
132
+ parser.add_argument("--test", type=float, default=None, help="Test split percentage (e.g. 10.0, or 0 to omit).")
133
+ parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducible shuffling.")
134
+ parser.add_argument("--keep-splits", action="store_true", help="Preserve existing splits without re-splitting.")
135
+
136
+ # Column mapping flags
137
+ parser.add_argument("--mapping", type=str, help="Column mapping as JSON string or key=val,key=val.")
138
+ parser.add_argument("--text-col", type=str, help="Source column for 'text' format.")
139
+ parser.add_argument("--text-template", type=str, help="Template string for 'text' format (e.g. '{instruction}\\n{output}').")
140
+ parser.add_argument("--prompt-col", type=str, help="Source column for prompt / question.")
141
+ parser.add_argument("--completion-col", type=str, help="Source column for completion / answer.")
142
+ parser.add_argument("--messages-col", type=str, help="Source column containing chat messages list.")
143
+ parser.add_argument("--user-col", type=str, help="Source column for user turn in chat format.")
144
+ parser.add_argument("--assistant-col", type=str, help="Source column for assistant turn in chat format.")
145
+ parser.add_argument("--system-col", type=str, help="Source column for system prompt in chat format.")
146
+ parser.add_argument("--chosen-col", type=str, help="Source column for chosen response in DPO format.")
147
+ parser.add_argument("--rejected-col", type=str, help="Source column for rejected response in DPO format.")
148
+
149
+ # Agent & Hand-off flags
150
+ parser.add_argument(
151
+ "--manifest-file",
152
+ type=str,
153
+ default=None,
154
+ help="Custom file path where machine-readable mlx_manifest.json will be saved.",
155
+ )
156
+ parser.add_argument(
157
+ "--prefill-state",
158
+ type=str,
159
+ default=None,
160
+ help="Pre-populate TUI state from a JSON string or path to JSON file.",
161
+ )
162
+ parser.add_argument(
163
+ "--spawn-terminal",
164
+ action="store_true",
165
+ help="Spawn interactive TUI in an external macOS Terminal window (ideal for AI agents & subshells).",
166
+ )
167
+ parser.add_argument(
168
+ "--mcp",
169
+ action="store_true",
170
+ help="Launch Model Context Protocol (MCP) server over stdio for Claude Desktop, Cursor, etc.",
171
+ )
172
+
173
+ # UI mode flags
174
+ parser.add_argument(
175
+ "--commander", "--tui",
176
+ action="store_true",
177
+ dest="commander",
178
+ help="Launch full-screen persistent MLX Commander TUI dashboard (default in interactive terminal).",
179
+ )
180
+ parser.add_argument(
181
+ "--wizard", "--no-tui", "--cli",
182
+ action="store_true",
183
+ dest="wizard",
184
+ help="Run sequential step-by-step terminal wizard instead of persistent MLX Commander dashboard.",
185
+ )
186
+ parser.add_argument(
187
+ "--lora",
188
+ action="store_true",
189
+ help="Launch TUI directly in LoRA Fine-Tuning mode (Mode 2).",
190
+ )
191
+ parser.add_argument(
192
+ "--run-queue",
193
+ nargs="?",
194
+ const="mlx_runs",
195
+ type=str,
196
+ default=None,
197
+ help="Execute LoRA fine-tuning runs sequentially from queue directory (default: 'mlx_runs').",
198
+ )
199
+ parser.add_argument(
200
+ "--wandb-project",
201
+ type=str,
202
+ default="mlx-commander",
203
+ help="Weights & Biases project name for experiment tracking (default: 'mlx-commander').",
204
+ )
205
+ parser.add_argument(
206
+ "--no-wandb",
207
+ action="store_true",
208
+ help="Disable Weights & Biases experiment logging even if wandb is installed and logged in.",
209
+ )
210
+
211
+ return parser
212
+
213
+
214
+ def run_direct_conversion(args: argparse.Namespace) -> ConversionResult:
215
+ """Perform headless conversion using command-line arguments."""
216
+ dataset = load_local_dataset(args.dataset)
217
+ target_format = MLXFormat(args.format.lower())
218
+
219
+ # Build mapping
220
+ if args.mapping:
221
+ mapping = parse_mapping_arg(args.mapping)
222
+ else:
223
+ mapping = auto_detect_mapping(target_format, dataset.columns)
224
+ if args.text_col:
225
+ mapping.text_col = args.text_col
226
+ if args.text_template:
227
+ mapping.text_template = args.text_template
228
+ if args.prompt_col:
229
+ mapping.prompt_col = args.prompt_col
230
+ if args.completion_col:
231
+ mapping.completion_col = args.completion_col
232
+ if args.messages_col:
233
+ mapping.messages_col = args.messages_col
234
+ if args.user_col:
235
+ mapping.user_col = args.user_col
236
+ if args.assistant_col:
237
+ mapping.assistant_col = args.assistant_col
238
+ if args.system_col:
239
+ mapping.system_col = args.system_col
240
+ if args.chosen_col:
241
+ mapping.chosen_col = args.chosen_col
242
+ if args.rejected_col:
243
+ mapping.rejected_col = args.rejected_col
244
+
245
+ # Validate mapping
246
+ errs = validate_mapping(target_format, mapping, dataset.columns)
247
+ if errs:
248
+ raise ValueError("Mapping error: " + "; ".join(errs))
249
+
250
+ # Split config
251
+ split_config = None
252
+ if not (args.keep_splits and dataset.is_split):
253
+ train_p = args.train if args.train is not None else 80.0
254
+ valid_p = args.valid if args.valid is not None else 10.0
255
+ test_p = args.test if args.test is not None else (100.0 - train_p - valid_p)
256
+ seed = args.seed if args.seed is not None else generate_random_seed()
257
+ split_config = SplitConfig(train_pct=train_p, valid_pct=valid_p, test_pct=test_p, seed=seed)
258
+ v_errs = split_config.validate()
259
+ if v_errs:
260
+ raise ValueError("Split error: " + "; ".join(v_errs))
261
+
262
+ out_dir = args.output or str(dataset.default_output_dir)
263
+ return convert_and_save(
264
+ dataset=dataset,
265
+ format_type=target_format,
266
+ mapping=mapping,
267
+ output_dir=out_dir,
268
+ split_config=split_config,
269
+ use_existing_splits=args.keep_splits,
270
+ manifest_file=getattr(args, "manifest_file", None),
271
+ )
272
+
273
+
274
+ def is_interactive_tty() -> bool:
275
+ """Check if stdout and stdin are interactive TTYs with adequate terminfo."""
276
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
277
+ return False
278
+ term = os.environ.get("TERM", "")
279
+ if not term or term == "dumb":
280
+ return False
281
+ return True
282
+
283
+
284
+ def main(argv: Optional[List[str]] = None) -> int:
285
+ parser = build_parser()
286
+ args = parser.parse_args(argv)
287
+
288
+ if args.mcp:
289
+ from mlx_commander.mcp_server import run_mcp_server
290
+ return run_mcp_server()
291
+
292
+ # Case 0: Sequential LoRA queue execution
293
+ if args.run_queue is not None:
294
+ from mlx_commander.lora import run_lora_queue
295
+ q_dir = Path(args.run_queue)
296
+ ok = run_lora_queue(
297
+ q_dir,
298
+ wandb_project=args.wandb_project,
299
+ enable_wandb=not args.no_wandb,
300
+ )
301
+ return 0 if ok else 1
302
+
303
+ # Normalize dataset path argument
304
+ dataset_input = None
305
+ if args.dataset:
306
+ if isinstance(args.dataset, list):
307
+ dataset_input = "\n".join(args.dataset) if len(args.dataset) > 1 else args.dataset[0]
308
+ else:
309
+ dataset_input = args.dataset
310
+
311
+ # Case 1: Direct Headless Run (dataset and format specified, without explicit UI flags)
312
+ if args.dataset and args.format and not args.wizard and not args.commander and not args.spawn_terminal:
313
+ try:
314
+ result = run_direct_conversion(args)
315
+ src_desc = f"{len(args.dataset)} files (merged)" if isinstance(args.dataset, list) and len(args.dataset) > 1 else (args.dataset[0] if isinstance(args.dataset, list) else str(args.dataset))
316
+ print(f"[OK] Successfully converted {src_desc} to {args.format} format in {result.output_dir}")
317
+ for s_name, path in result.output_files.items():
318
+ cnt = result.record_counts.get(s_name, 0)
319
+ print(f" • {path.name}: {cnt:,} records")
320
+ if result.manifest_path:
321
+ print(f" • Manifest: {result.manifest_path}")
322
+ print(f"\nMLX Fine-tuning command:\n{result.generate_mlx_lora_command()}\n")
323
+ return 0
324
+ except KeyboardInterrupt:
325
+ print("\nOperation cancelled.")
326
+ return 130
327
+ except MissingDependencyError as e:
328
+ print(f"\nMissing Dependency Error:\n{e.format_name} format requires package '{e.package_name}'.", file=sys.stderr)
329
+ print(f"\nTo install, run:\n {e.install_command}", file=sys.stderr)
330
+ print(f"or install optional extra:\n {e.pip_extra}\n", file=sys.stderr)
331
+ return 1
332
+ except Exception as e:
333
+ print(f"Error: {e}", file=sys.stderr)
334
+ return 1
335
+
336
+ # Case 2: Sequential wizard explicitly requested via --wizard / --no-tui / --cli
337
+ if args.wizard:
338
+ try:
339
+ run_interactive_wizard(
340
+ dataset_path=dataset_input,
341
+ format_arg=args.format,
342
+ output_dir_arg=args.output,
343
+ train_pct_arg=args.train,
344
+ valid_pct_arg=args.valid,
345
+ test_pct_arg=args.test,
346
+ seed_arg=args.seed,
347
+ )
348
+ return 0
349
+ except (KeyboardInterrupt, EOFError):
350
+ print("\nOperation cancelled.")
351
+ return 130
352
+ except MissingDependencyError as e:
353
+ print(f"\nMissing Dependency Error:\n{e.format_name} format requires package '{e.package_name}'.", file=sys.stderr)
354
+ print(f"\nTo install, run:\n {e.install_command}", file=sys.stderr)
355
+ print(f"or install optional extra:\n {e.pip_extra}\n", file=sys.stderr)
356
+ return 1
357
+ except Exception as e:
358
+ print(f"\nError: {e}", file=sys.stderr)
359
+ return 1
360
+
361
+ # Case 3: Interactive TUI Dashboard (with optional prefill and Terminal Spawner)
362
+ prefill_dict: Dict[str, Any] = {}
363
+ if args.prefill_state:
364
+ try:
365
+ prefill_dict.update(parse_prefill_state(args.prefill_state))
366
+ except Exception as e:
367
+ print(f"Error parsing --prefill-state: {e}", file=sys.stderr)
368
+ return 1
369
+
370
+ if args.format:
371
+ prefill_dict["format"] = args.format
372
+ if args.prompt_col:
373
+ prefill_dict["prompt_col"] = args.prompt_col
374
+ if args.completion_col:
375
+ prefill_dict["completion_col"] = args.completion_col
376
+ if args.text_col:
377
+ prefill_dict["text_col"] = args.text_col
378
+ if args.text_template:
379
+ prefill_dict["text_template"] = args.text_template
380
+ if args.messages_col:
381
+ prefill_dict["messages_col"] = args.messages_col
382
+ if args.user_col:
383
+ prefill_dict["user_col"] = args.user_col
384
+ if args.assistant_col:
385
+ prefill_dict["assistant_col"] = args.assistant_col
386
+ if args.system_col:
387
+ prefill_dict["system_col"] = args.system_col
388
+ if args.chosen_col:
389
+ prefill_dict["chosen_col"] = args.chosen_col
390
+ if args.rejected_col:
391
+ prefill_dict["rejected_col"] = args.rejected_col
392
+ if args.train is not None:
393
+ prefill_dict["train"] = args.train
394
+ if args.valid is not None:
395
+ prefill_dict["valid"] = args.valid
396
+ if args.test is not None:
397
+ prefill_dict["test"] = args.test
398
+ if args.seed is not None:
399
+ prefill_dict["seed"] = args.seed
400
+ if args.output:
401
+ prefill_dict["output"] = args.output
402
+ if dataset_input:
403
+ prefill_dict["dataset"] = dataset_input
404
+ if args.lora:
405
+ prefill_dict["tab"] = 1
406
+ prefill_dict["active_tab"] = 1
407
+ if args.wandb_project:
408
+ prefill_dict["wandb_project"] = args.wandb_project
409
+ if args.no_wandb:
410
+ prefill_dict["wandb_enabled"] = False
411
+
412
+ # Check if external terminal window should be spawned
413
+ from mlx_commander.terminal_spawner import is_macos, spawn_terminal_tui
414
+ should_spawn = args.spawn_terminal or (not is_interactive_tty() and is_macos())
415
+
416
+ if should_spawn:
417
+ raw_args = list(sys.argv[1:] if argv is None else argv)
418
+ return spawn_terminal_tui(raw_args, manifest_path=args.manifest_file)
419
+
420
+ if not is_interactive_tty():
421
+ print(
422
+ "Error: No interactive terminal (TTY) detected.\n"
423
+ "On macOS, use --spawn-terminal to launch in Terminal.app, or specify --format for headless conversion.",
424
+ file=sys.stderr,
425
+ )
426
+ return 1
427
+
428
+ try:
429
+ result = launch_tui(default_dataset_path=dataset_input, prefill=prefill_dict)
430
+ if result is not None:
431
+ if args.manifest_file:
432
+ result.save_manifest(args.manifest_file)
433
+ print(f"\n[OK] Successfully converted dataset to {result.format_type.value} format in {result.output_dir}")
434
+ if result.manifest_path:
435
+ print(f" • Manifest: {result.manifest_path}")
436
+ return 0
437
+ else:
438
+ if args.manifest_file:
439
+ try:
440
+ Path(args.manifest_file).parent.mkdir(parents=True, exist_ok=True)
441
+ with open(args.manifest_file, "w", encoding="utf-8") as f:
442
+ json.dump({"status": "cancelled"}, f, indent=2)
443
+ except Exception:
444
+ pass
445
+ print("\nOperation cancelled.")
446
+ return 130
447
+ except (KeyboardInterrupt, EOFError):
448
+ print("\nOperation cancelled.")
449
+ return 130
450
+ except curses.error as e:
451
+ print(f"\nTerminal error: {e}", file=sys.stderr)
452
+ return 1
453
+ except Exception as e:
454
+ print(f"\nError: {e}", file=sys.stderr)
455
+ return 1
456
+
457
+
458
+ if __name__ == "__main__":
459
+ try:
460
+ sys.exit(main())
461
+ except KeyboardInterrupt:
462
+ print("\nOperation cancelled.")
463
+ sys.exit(130)