mlx_commander 0.2.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.
@@ -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.2.0"
@@ -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,421 @@
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
+
187
+ return parser
188
+
189
+
190
+ def run_direct_conversion(args: argparse.Namespace) -> ConversionResult:
191
+ """Perform headless conversion using command-line arguments."""
192
+ dataset = load_local_dataset(args.dataset)
193
+ target_format = MLXFormat(args.format.lower())
194
+
195
+ # Build mapping
196
+ if args.mapping:
197
+ mapping = parse_mapping_arg(args.mapping)
198
+ else:
199
+ mapping = auto_detect_mapping(target_format, dataset.columns)
200
+ if args.text_col:
201
+ mapping.text_col = args.text_col
202
+ if args.text_template:
203
+ mapping.text_template = args.text_template
204
+ if args.prompt_col:
205
+ mapping.prompt_col = args.prompt_col
206
+ if args.completion_col:
207
+ mapping.completion_col = args.completion_col
208
+ if args.messages_col:
209
+ mapping.messages_col = args.messages_col
210
+ if args.user_col:
211
+ mapping.user_col = args.user_col
212
+ if args.assistant_col:
213
+ mapping.assistant_col = args.assistant_col
214
+ if args.system_col:
215
+ mapping.system_col = args.system_col
216
+ if args.chosen_col:
217
+ mapping.chosen_col = args.chosen_col
218
+ if args.rejected_col:
219
+ mapping.rejected_col = args.rejected_col
220
+
221
+ # Validate mapping
222
+ errs = validate_mapping(target_format, mapping, dataset.columns)
223
+ if errs:
224
+ raise ValueError("Mapping error: " + "; ".join(errs))
225
+
226
+ # Split config
227
+ split_config = None
228
+ if not (args.keep_splits and dataset.is_split):
229
+ train_p = args.train if args.train is not None else 80.0
230
+ valid_p = args.valid if args.valid is not None else 10.0
231
+ test_p = args.test if args.test is not None else (100.0 - train_p - valid_p)
232
+ seed = args.seed if args.seed is not None else generate_random_seed()
233
+ split_config = SplitConfig(train_pct=train_p, valid_pct=valid_p, test_pct=test_p, seed=seed)
234
+ v_errs = split_config.validate()
235
+ if v_errs:
236
+ raise ValueError("Split error: " + "; ".join(v_errs))
237
+
238
+ out_dir = args.output or str(dataset.default_output_dir)
239
+ return convert_and_save(
240
+ dataset=dataset,
241
+ format_type=target_format,
242
+ mapping=mapping,
243
+ output_dir=out_dir,
244
+ split_config=split_config,
245
+ use_existing_splits=args.keep_splits,
246
+ manifest_file=getattr(args, "manifest_file", None),
247
+ )
248
+
249
+
250
+ def is_interactive_tty() -> bool:
251
+ """Check if stdout and stdin are interactive TTYs with adequate terminfo."""
252
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
253
+ return False
254
+ term = os.environ.get("TERM", "")
255
+ if not term or term == "dumb":
256
+ return False
257
+ return True
258
+
259
+
260
+ def main(argv: Optional[List[str]] = None) -> int:
261
+ parser = build_parser()
262
+ args = parser.parse_args(argv)
263
+
264
+ if args.mcp:
265
+ from mlx_commander.mcp_server import run_mcp_server
266
+ return run_mcp_server()
267
+
268
+ # Normalize dataset path argument
269
+ dataset_input = None
270
+ if args.dataset:
271
+ if isinstance(args.dataset, list):
272
+ dataset_input = "\n".join(args.dataset) if len(args.dataset) > 1 else args.dataset[0]
273
+ else:
274
+ dataset_input = args.dataset
275
+
276
+ # Case 1: Direct Headless Run (dataset and format specified, without explicit UI flags)
277
+ if args.dataset and args.format and not args.wizard and not args.commander and not args.spawn_terminal:
278
+ try:
279
+ result = run_direct_conversion(args)
280
+ 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))
281
+ print(f"[OK] Successfully converted {src_desc} to {args.format} format in {result.output_dir}")
282
+ for s_name, path in result.output_files.items():
283
+ cnt = result.record_counts.get(s_name, 0)
284
+ print(f" • {path.name}: {cnt:,} records")
285
+ if result.manifest_path:
286
+ print(f" • Manifest: {result.manifest_path}")
287
+ print(f"\nMLX Fine-tuning command:\n{result.generate_mlx_lora_command()}\n")
288
+ return 0
289
+ except KeyboardInterrupt:
290
+ print("\nOperation cancelled.")
291
+ return 130
292
+ except MissingDependencyError as e:
293
+ print(f"\nMissing Dependency Error:\n{e.format_name} format requires package '{e.package_name}'.", file=sys.stderr)
294
+ print(f"\nTo install, run:\n {e.install_command}", file=sys.stderr)
295
+ print(f"or install optional extra:\n {e.pip_extra}\n", file=sys.stderr)
296
+ return 1
297
+ except Exception as e:
298
+ print(f"Error: {e}", file=sys.stderr)
299
+ return 1
300
+
301
+ # Case 2: Sequential wizard explicitly requested via --wizard / --no-tui / --cli
302
+ if args.wizard:
303
+ try:
304
+ run_interactive_wizard(
305
+ dataset_path=dataset_input,
306
+ format_arg=args.format,
307
+ output_dir_arg=args.output,
308
+ train_pct_arg=args.train,
309
+ valid_pct_arg=args.valid,
310
+ test_pct_arg=args.test,
311
+ seed_arg=args.seed,
312
+ )
313
+ return 0
314
+ except (KeyboardInterrupt, EOFError):
315
+ print("\nOperation cancelled.")
316
+ return 130
317
+ except MissingDependencyError as e:
318
+ print(f"\nMissing Dependency Error:\n{e.format_name} format requires package '{e.package_name}'.", file=sys.stderr)
319
+ print(f"\nTo install, run:\n {e.install_command}", file=sys.stderr)
320
+ print(f"or install optional extra:\n {e.pip_extra}\n", file=sys.stderr)
321
+ return 1
322
+ except Exception as e:
323
+ print(f"\nError: {e}", file=sys.stderr)
324
+ return 1
325
+
326
+ # Case 3: Interactive TUI Dashboard (with optional prefill and Terminal Spawner)
327
+ prefill_dict: Dict[str, Any] = {}
328
+ if args.prefill_state:
329
+ try:
330
+ prefill_dict.update(parse_prefill_state(args.prefill_state))
331
+ except Exception as e:
332
+ print(f"Error parsing --prefill-state: {e}", file=sys.stderr)
333
+ return 1
334
+
335
+ if args.format:
336
+ prefill_dict["format"] = args.format
337
+ if args.prompt_col:
338
+ prefill_dict["prompt_col"] = args.prompt_col
339
+ if args.completion_col:
340
+ prefill_dict["completion_col"] = args.completion_col
341
+ if args.text_col:
342
+ prefill_dict["text_col"] = args.text_col
343
+ if args.text_template:
344
+ prefill_dict["text_template"] = args.text_template
345
+ if args.messages_col:
346
+ prefill_dict["messages_col"] = args.messages_col
347
+ if args.user_col:
348
+ prefill_dict["user_col"] = args.user_col
349
+ if args.assistant_col:
350
+ prefill_dict["assistant_col"] = args.assistant_col
351
+ if args.system_col:
352
+ prefill_dict["system_col"] = args.system_col
353
+ if args.chosen_col:
354
+ prefill_dict["chosen_col"] = args.chosen_col
355
+ if args.rejected_col:
356
+ prefill_dict["rejected_col"] = args.rejected_col
357
+ if args.train is not None:
358
+ prefill_dict["train"] = args.train
359
+ if args.valid is not None:
360
+ prefill_dict["valid"] = args.valid
361
+ if args.test is not None:
362
+ prefill_dict["test"] = args.test
363
+ if args.seed is not None:
364
+ prefill_dict["seed"] = args.seed
365
+ if args.output:
366
+ prefill_dict["output"] = args.output
367
+ if dataset_input:
368
+ prefill_dict["dataset"] = dataset_input
369
+
370
+ # Check if external terminal window should be spawned
371
+ from mlx_commander.terminal_spawner import is_macos, spawn_terminal_tui
372
+ should_spawn = args.spawn_terminal or (not is_interactive_tty() and is_macos())
373
+
374
+ if should_spawn:
375
+ raw_args = list(sys.argv[1:] if argv is None else argv)
376
+ return spawn_terminal_tui(raw_args, manifest_path=args.manifest_file)
377
+
378
+ if not is_interactive_tty():
379
+ print(
380
+ "Error: No interactive terminal (TTY) detected.\n"
381
+ "On macOS, use --spawn-terminal to launch in Terminal.app, or specify --format for headless conversion.",
382
+ file=sys.stderr,
383
+ )
384
+ return 1
385
+
386
+ try:
387
+ result = launch_tui(default_dataset_path=dataset_input, prefill=prefill_dict)
388
+ if result is not None:
389
+ if args.manifest_file:
390
+ result.save_manifest(args.manifest_file)
391
+ print(f"\n[OK] Successfully converted dataset to {result.format_type.value} format in {result.output_dir}")
392
+ if result.manifest_path:
393
+ print(f" • Manifest: {result.manifest_path}")
394
+ return 0
395
+ else:
396
+ if args.manifest_file:
397
+ try:
398
+ Path(args.manifest_file).parent.mkdir(parents=True, exist_ok=True)
399
+ with open(args.manifest_file, "w", encoding="utf-8") as f:
400
+ json.dump({"status": "cancelled"}, f, indent=2)
401
+ except Exception:
402
+ pass
403
+ print("\nOperation cancelled.")
404
+ return 130
405
+ except (KeyboardInterrupt, EOFError):
406
+ print("\nOperation cancelled.")
407
+ return 130
408
+ except curses.error as e:
409
+ print(f"\nTerminal error: {e}", file=sys.stderr)
410
+ return 1
411
+ except Exception as e:
412
+ print(f"\nError: {e}", file=sys.stderr)
413
+ return 1
414
+
415
+
416
+ if __name__ == "__main__":
417
+ try:
418
+ sys.exit(main())
419
+ except KeyboardInterrupt:
420
+ print("\nOperation cancelled.")
421
+ sys.exit(130)
@@ -0,0 +1,177 @@
1
+ """
2
+ Conversion Engine for writing MLX JSONL datasets.
3
+ Streams converted and mapped records to train.jsonl, valid.jsonl, and test.jsonl.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any, Callable, Dict, List, Optional, Union
11
+
12
+ from mlx_commander.formats import ColumnMapping, MLXFormat, format_record, validate_mapping
13
+ from mlx_commander.loader import LoadedDataset
14
+ from mlx_commander.splitter import SplitConfig, split_records
15
+
16
+
17
+ @dataclass
18
+ class ConversionResult:
19
+ output_dir: Path
20
+ format_type: MLXFormat
21
+ output_files: Dict[str, Path]
22
+ record_counts: Dict[str, int]
23
+ file_sizes: Dict[str, int]
24
+ sample_records: Dict[str, List[Dict[str, Any]]]
25
+ seed_used: Optional[int]
26
+ manifest_path: Optional[Path] = None
27
+ source_path: Optional[str] = None
28
+
29
+ def generate_mlx_lora_command(self, model_name: str = "mlx-community/Llama-3.2-3B-Instruct-4bit") -> str:
30
+ """Generate a ready-to-use mlx_lm.lora command line string."""
31
+ mask_arg = " --mask-prompt" if self.format_type in (MLXFormat.CHAT, MLXFormat.PROMPT_COMPLETION) else ""
32
+ return (
33
+ f"mlx_lm.lora \\\n"
34
+ f" --model {model_name} \\\n"
35
+ f" --train \\\n"
36
+ f" --data {self.output_dir}{mask_arg} \\\n"
37
+ f" --iters 600 \\\n"
38
+ f" --batch-size 4"
39
+ )
40
+
41
+ def to_manifest_dict(self) -> Dict[str, Any]:
42
+ """Produce a structured, machine-readable dictionary representing conversion output."""
43
+ return {
44
+ "status": "success",
45
+ "format": self.format_type.value,
46
+ "source_path": self.source_path or "",
47
+ "output_dir": str(self.output_dir),
48
+ "files": {
49
+ split: {
50
+ "path": str(p),
51
+ "filename": p.name,
52
+ "records": self.record_counts.get(split, 0),
53
+ "size_bytes": self.file_sizes.get(split, 0),
54
+ }
55
+ for split, p in self.output_files.items()
56
+ },
57
+ "splits": self.record_counts,
58
+ "total_records": sum(self.record_counts.values()),
59
+ "seed_used": self.seed_used,
60
+ "mlx_lora_command": self.generate_mlx_lora_command(),
61
+ "manifest_path": str(self.manifest_path) if self.manifest_path else str(self.output_dir / "mlx_manifest.json"),
62
+ }
63
+
64
+ def save_manifest(self, path: Optional[Union[str, Path]] = None) -> Path:
65
+ """Write manifest JSON to disk and record manifest_path."""
66
+ target = Path(path).expanduser().resolve() if path else (self.output_dir / "mlx_manifest.json")
67
+ target.parent.mkdir(parents=True, exist_ok=True)
68
+ manifest_data = self.to_manifest_dict()
69
+ manifest_data["manifest_path"] = str(target)
70
+ with open(target, "w", encoding="utf-8") as f:
71
+ json.dump(manifest_data, f, indent=2, ensure_ascii=False)
72
+ self.manifest_path = target
73
+ return target
74
+
75
+
76
+ def convert_and_save(
77
+ dataset: LoadedDataset,
78
+ format_type: MLXFormat,
79
+ mapping: ColumnMapping,
80
+ output_dir: Optional[Union[str, Path]] = None,
81
+ split_config: Optional[SplitConfig] = None,
82
+ use_existing_splits: bool = False,
83
+ progress_callback: Optional[Callable[[str, int, int], None]] = None,
84
+ output_dir_str: Optional[Union[str, Path]] = None,
85
+ **kwargs: Any,
86
+ ) -> ConversionResult:
87
+ """
88
+ Convert a loaded dataset to MLX JSONL format and write output files.
89
+ Supports output_dir as str or Path, as well as output_dir_str for backward compatibility.
90
+ """
91
+ target = output_dir if output_dir is not None else output_dir_str
92
+ if target is None:
93
+ target = kwargs.get("output_path")
94
+ if target is None:
95
+ target = getattr(dataset, "default_output_dir", None)
96
+ if target is None:
97
+ raise ValueError("output_dir must be specified for convert_and_save")
98
+
99
+ output_dir = Path(target).expanduser().resolve()
100
+ output_dir.mkdir(parents=True, exist_ok=True)
101
+
102
+ # Validate mapping against dataset columns
103
+ errors = validate_mapping(format_type, mapping, dataset.columns)
104
+ if errors:
105
+ raise ValueError("Invalid column mapping: " + "; ".join(errors))
106
+
107
+ # Determine split dictionary
108
+ splits_data: Dict[str, List[Dict[str, Any]]] = {}
109
+
110
+ if use_existing_splits and dataset.is_split:
111
+ for s_name in dataset.split_names:
112
+ normalized_s = s_name.lower()
113
+ if normalized_s in ("validation", "val", "dev"):
114
+ key = "valid"
115
+ elif normalized_s in ("train", "training"):
116
+ key = "train"
117
+ elif normalized_s in ("test", "testing", "eval"):
118
+ key = "test"
119
+ else:
120
+ key = normalized_s
121
+ splits_data[key] = list(dataset.iter_records(split=s_name))
122
+ else:
123
+ # Re-split all records according to SplitConfig
124
+ all_records = dataset.get_all_records()
125
+ if split_config is None:
126
+ # Default 80/10/10 split
127
+ split_config = SplitConfig(train_pct=80.0, valid_pct=10.0, test_pct=10.0, seed=42)
128
+ splits_data = split_records(all_records, split_config)
129
+
130
+ output_files: Dict[str, Path] = {}
131
+ record_counts: Dict[str, int] = {}
132
+ file_sizes: Dict[str, int] = {}
133
+ samples: Dict[str, List[Dict[str, Any]]] = {}
134
+
135
+ for split_name, records in splits_data.items():
136
+ if not records:
137
+ continue
138
+
139
+ file_name = f"{split_name}.jsonl"
140
+ target_path = output_dir / file_name
141
+ output_files[split_name] = target_path
142
+
143
+ written_count = 0
144
+ split_samples: List[Dict[str, Any]] = []
145
+ total_in_split = len(records)
146
+
147
+ with open(target_path, "w", encoding="utf-8") as out_f:
148
+ for idx, raw_record in enumerate(records, start=1):
149
+ formatted = format_record(raw_record, format_type, mapping)
150
+ line = json.dumps(formatted, ensure_ascii=False)
151
+ out_f.write(line + "\n")
152
+ written_count += 1
153
+
154
+ if len(split_samples) < 3:
155
+ split_samples.append(formatted)
156
+
157
+ if progress_callback and (idx % 200 == 0 or idx == total_in_split):
158
+ progress_callback(split_name, idx, total_in_split)
159
+
160
+ record_counts[split_name] = written_count
161
+ file_sizes[split_name] = target_path.stat().st_size
162
+ samples[split_name] = split_samples
163
+
164
+ res = ConversionResult(
165
+ output_dir=output_dir,
166
+ format_type=format_type,
167
+ output_files=output_files,
168
+ record_counts=record_counts,
169
+ file_sizes=file_sizes,
170
+ sample_records=samples,
171
+ seed_used=split_config.seed if split_config else None,
172
+ source_path=getattr(dataset, "source_path", None),
173
+ )
174
+
175
+ manifest_file = kwargs.get("manifest_file")
176
+ res.save_manifest(manifest_file if manifest_file else None)
177
+ return res