Skip to content

Commit 44c4e51

Browse files
authored
feat: enhance modelfile handling for paths with spaces and improve co… (#211)
feat: enhance modelfile handling for paths with spaces and improve command parsing - Added tests for modelfile creation and parsing with file paths containing spaces. - Implemented quoting for values in modelfiles that contain spaces or special characters. - Enhanced the `splitCommand` and `parseArgs` functions to correctly handle quoted arguments and spaces. This improves the robustness of modelfile handling in various scenarios. Signed-off-by: Zhao Chen <[email protected]>
1 parent 4c8706d commit 44c4e51

File tree

4 files changed

+407
-4
lines changed

4 files changed

+407
-4
lines changed

pkg/modelfile/modelfile.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,8 +570,21 @@ func (mf *modelfile) writeMultiField(comment, cmd string, values []string, patte
570570

571571
sort.Strings(values)
572572
for _, value := range values {
573-
content += fmt.Sprintf("%s %s\n", cmd, value)
573+
// Quote the value if it contains spaces or special characters
574+
quotedValue := mf.quoteIfNeeded(value)
575+
content += fmt.Sprintf("%s %s\n", cmd, quotedValue)
574576
}
575577

576578
return content
577579
}
580+
581+
// quoteIfNeeded adds quotes around a value if it contains spaces or special characters
582+
func (mf *modelfile) quoteIfNeeded(value string) string {
583+
// Check if the value contains spaces or other characters that need quoting
584+
if strings.ContainsAny(value, " \t\n\r") {
585+
// Escape any existing quotes in the value
586+
escaped := strings.ReplaceAll(value, `"`, `\"`)
587+
return fmt.Sprintf(`"%s"`, escaped)
588+
}
589+
return value
590+
}

pkg/modelfile/modelfile_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,6 +1194,176 @@ func createHashSet(items []string) *hashset.Set {
11941194
return set
11951195
}
11961196

1197+
// TestModelfileWithSpacesInPaths tests handling of file paths containing spaces
1198+
func TestModelfileWithSpacesInPaths(t *testing.T) {
1199+
// Test case 1: Create a modelfile with paths containing spaces
1200+
tempDir, err := os.MkdirTemp("", "spaces-test-*")
1201+
require.NoError(t, err)
1202+
defer os.RemoveAll(tempDir)
1203+
1204+
// Create files with spaces in their names
1205+
filesWithSpaces := map[string]string{
1206+
"config file.json": `{"model_type": "test"}`,
1207+
"model weights.bin": "model content",
1208+
"inference script.py": "print('hello')",
1209+
"README file.md": "# Documentation",
1210+
"nested dir/config.json": `{"test": true}`,
1211+
"path with/spaces here/model.safetensors": "model data",
1212+
}
1213+
1214+
// Create directories
1215+
err = os.MkdirAll(filepath.Join(tempDir, "nested dir"), 0755)
1216+
require.NoError(t, err)
1217+
err = os.MkdirAll(filepath.Join(tempDir, "path with", "spaces here"), 0755)
1218+
require.NoError(t, err)
1219+
1220+
// Create files
1221+
for filename, content := range filesWithSpaces {
1222+
fullPath := filepath.Join(tempDir, filename)
1223+
err = os.WriteFile(fullPath, []byte(content), 0644)
1224+
require.NoError(t, err)
1225+
}
1226+
1227+
// Generate modelfile using workspace
1228+
config := &configmodelfile.GenerateConfig{
1229+
Name: "spaces-test",
1230+
}
1231+
1232+
mf, err := NewModelfileByWorkspace(tempDir, config)
1233+
require.NoError(t, err)
1234+
1235+
// Get the generated modelfile content
1236+
content := mf.Content()
1237+
contentStr := string(content)
1238+
1239+
// Print the content for debugging
1240+
t.Logf("Generated Modelfile content:\n%s", contentStr)
1241+
1242+
// Test case 2: Try to parse the generated modelfile
1243+
modelfileContent := contentStr
1244+
1245+
// Write the content to a temporary file
1246+
tempModelfile, err := os.CreateTemp("", "Modelfile-*")
1247+
require.NoError(t, err)
1248+
defer os.Remove(tempModelfile.Name())
1249+
1250+
_, err = tempModelfile.WriteString(modelfileContent)
1251+
require.NoError(t, err)
1252+
err = tempModelfile.Close()
1253+
require.NoError(t, err)
1254+
1255+
// Try to parse the modelfile back
1256+
parsedMf, err := NewModelfile(tempModelfile.Name())
1257+
if err != nil {
1258+
t.Logf("Error parsing modelfile with spaces: %v", err)
1259+
t.Logf("Modelfile content that failed to parse:\n%s", modelfileContent)
1260+
}
1261+
require.NoError(t, err, "Should be able to parse modelfile with spaces in paths")
1262+
1263+
// Verify that the parsed modelfile contains the expected files
1264+
configs := parsedMf.GetConfigs()
1265+
models := parsedMf.GetModels()
1266+
codes := parsedMf.GetCodes()
1267+
docs := parsedMf.GetDocs()
1268+
1269+
// Check that files with spaces are properly handled
1270+
allFiles := append(append(append(configs, models...), codes...), docs...)
1271+
1272+
expectedFiles := []string{
1273+
"config file.json",
1274+
"model weights.bin",
1275+
"inference script.py",
1276+
"README file.md",
1277+
"nested dir/config.json",
1278+
"path with/spaces here/model.safetensors",
1279+
}
1280+
1281+
for _, expectedFile := range expectedFiles {
1282+
found := false
1283+
for _, file := range allFiles {
1284+
if file == expectedFile {
1285+
found = true
1286+
break
1287+
}
1288+
}
1289+
assert.True(t, found, "Expected file '%s' should be found in parsed modelfile", expectedFile)
1290+
}
1291+
}
1292+
1293+
// TestModelfileParsingWithQuotedPaths tests parsing of modelfile with quoted paths
1294+
func TestModelfileParsingWithQuotedPaths(t *testing.T) {
1295+
testCases := []struct {
1296+
name string
1297+
content string
1298+
expectError bool
1299+
description string
1300+
}{
1301+
{
1302+
name: "unquoted_paths_with_spaces",
1303+
content: `# Test modelfile
1304+
NAME test-model
1305+
CONFIG config file.json
1306+
MODEL model weights.bin
1307+
CODE inference script.py
1308+
DOC README file.md
1309+
`,
1310+
expectError: true,
1311+
description: "Unquoted paths with spaces should cause parsing errors",
1312+
},
1313+
{
1314+
name: "quoted_paths_with_spaces",
1315+
content: `# Test modelfile
1316+
NAME test-model
1317+
CONFIG "config file.json"
1318+
MODEL "model weights.bin"
1319+
CODE "inference script.py"
1320+
DOC "README file.md"
1321+
`,
1322+
expectError: false,
1323+
description: "Quoted paths with spaces should parse correctly",
1324+
},
1325+
{
1326+
name: "mixed_quoted_unquoted",
1327+
content: `# Test modelfile
1328+
NAME test-model
1329+
CONFIG config.json
1330+
MODEL "model weights.bin"
1331+
CODE script.py
1332+
DOC "README file.md"
1333+
`,
1334+
expectError: false,
1335+
description: "Mix of quoted and unquoted paths should work",
1336+
},
1337+
}
1338+
1339+
for _, tc := range testCases {
1340+
t.Run(tc.name, func(t *testing.T) {
1341+
// Create temporary modelfile
1342+
tempFile, err := os.CreateTemp("", "modelfile-test-*")
1343+
require.NoError(t, err)
1344+
defer os.Remove(tempFile.Name())
1345+
1346+
_, err = tempFile.WriteString(tc.content)
1347+
require.NoError(t, err)
1348+
err = tempFile.Close()
1349+
require.NoError(t, err)
1350+
1351+
// Try to parse
1352+
mf, err := NewModelfile(tempFile.Name())
1353+
1354+
if tc.expectError {
1355+
assert.Error(t, err, tc.description)
1356+
} else {
1357+
assert.NoError(t, err, tc.description)
1358+
if err == nil {
1359+
// Verify content is parsed correctly
1360+
assert.Equal(t, "test-model", mf.GetName())
1361+
}
1362+
}
1363+
})
1364+
}
1365+
}
1366+
11971367
// TestGenerateByModelConfig tests the generateByModelConfig method
11981368
func TestGenerateByModelConfig(t *testing.T) {
11991369
testcases := []struct {

pkg/modelfile/parser/parser.go

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,77 @@ func parseCommandLine(line string, start, end int) (Node, error) {
119119
// splitCommand splits the command line into the command and the args. Returns the
120120
// command and the args, and an error if the command line is invalid.
121121
// Example: "MODEL foo" returns "MODEL", ["foo"] and nil.
122+
// Example: "MODEL \"foo bar\"" returns "MODEL", ["foo bar"] and nil.
122123
func splitCommand(line string) (string, []string, error) {
123-
parts := strings.Fields(line)
124-
if len(parts) < 2 {
124+
// First, split to get the command
125+
firstSpace := strings.Index(line, " ")
126+
if firstSpace == -1 {
125127
return "", nil, fmt.Errorf("invalid command line: %s", line)
126128
}
127129

128-
return strings.ToUpper(parts[0]), parts[1:], nil
130+
command := strings.ToUpper(strings.TrimSpace(line[:firstSpace]))
131+
argsStr := strings.TrimSpace(line[firstSpace+1:])
132+
133+
if argsStr == "" {
134+
return "", nil, fmt.Errorf("invalid command line: %s", line)
135+
}
136+
137+
// Parse the arguments, handling quoted strings
138+
args, err := parseArgs(argsStr)
139+
if err != nil {
140+
return "", nil, fmt.Errorf("invalid args in command line: %s", line)
141+
}
142+
143+
return command, args, nil
144+
}
145+
146+
// parseArgs parses argument string, handling quoted strings with spaces
147+
func parseArgs(argsStr string) ([]string, error) {
148+
var args []string
149+
var current strings.Builder
150+
var inQuotes bool
151+
var escape bool
152+
var hadQuotes bool // Track if we've seen quotes for the current argument
153+
154+
for _, r := range argsStr {
155+
switch {
156+
case escape:
157+
// Previous character was escape, add this character literally
158+
current.WriteRune(r)
159+
escape = false
160+
case r == '\\':
161+
// Escape next character
162+
escape = true
163+
case r == '"':
164+
// Toggle quote mode
165+
inQuotes = !inQuotes
166+
hadQuotes = true
167+
case r == ' ' || r == '\t':
168+
if inQuotes {
169+
// Inside quotes, preserve the space
170+
current.WriteRune(r)
171+
} else {
172+
// Outside quotes, this ends the current argument
173+
if current.Len() > 0 || hadQuotes {
174+
args = append(args, current.String())
175+
current.Reset()
176+
hadQuotes = false
177+
}
178+
}
179+
default:
180+
current.WriteRune(r)
181+
}
182+
}
183+
184+
// Check for unclosed quotes
185+
if inQuotes {
186+
return nil, fmt.Errorf("unclosed quotes in arguments")
187+
}
188+
189+
// Add the final argument if any
190+
if current.Len() > 0 || hadQuotes {
191+
args = append(args, current.String())
192+
}
193+
194+
return args, nil
129195
}

0 commit comments

Comments
 (0)