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.
- mlx_commander/__init__.py +12 -0
- mlx_commander/__main__.py +10 -0
- mlx_commander/bin/mac_picker.m +69 -0
- mlx_commander/cli.py +421 -0
- mlx_commander/converter.py +177 -0
- mlx_commander/exceptions.py +39 -0
- mlx_commander/formats.py +379 -0
- mlx_commander/gui_picker.py +206 -0
- mlx_commander/loader.py +1192 -0
- mlx_commander/mcp_server.py +308 -0
- mlx_commander/splitter.py +110 -0
- mlx_commander/terminal_spawner.py +150 -0
- mlx_commander/tui/__init__.py +7 -0
- mlx_commander/tui/app.py +965 -0
- mlx_commander/tui/state.py +400 -0
- mlx_commander/tui/widgets.py +1439 -0
- mlx_commander/tui/wizard_fallback.py +631 -0
- mlx_commander-0.2.0.dist-info/METADATA +370 -0
- mlx_commander-0.2.0.dist-info/RECORD +22 -0
- mlx_commander-0.2.0.dist-info/WHEEL +4 -0
- mlx_commander-0.2.0.dist-info/entry_points.txt +2 -0
- mlx_commander-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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
|