main
py 766 lines 29.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import ast
5 import json
6
7 import pytest
8
9 from vllm.tool_parsers.utils import (
10 UnexpectedAstError,
11 coerce_to_schema_type,
12 contains_broken_string_literal,
13 escape_ctrl_chars_in_strings,
14 escape_nested_quotes_in_strings,
15 extract_types_from_schema,
16 get_parameter_value,
17 handle_single_tool,
18 make_valid_python,
19 normalize_leading_zero_ints,
20 rename_reserved_kwargs,
21 restore_reserved_kwarg_names,
22 )
23
24
25 class TestCoerceToSchemaType:
26 class TestNullHandling:
27 def test_null_converted_when_type_is_null(self):
28 assert coerce_to_schema_type("null", "null") is None
29
30 def test_null_converted_when_null_in_type_list(self):
31 assert coerce_to_schema_type("null", ["string", "null"]) is None
32
33 def test_null_preserved_as_string_when_type_is_string(self):
34 assert coerce_to_schema_type("null", "string") == "null"
35
36 def test_null_case_insensitive(self):
37 assert coerce_to_schema_type("NULL", "null") is None
38 assert coerce_to_schema_type("Null", "null") is None
39
40 def test_none_string_never_converted(self):
41 assert coerce_to_schema_type("none", "null") == "none"
42 assert coerce_to_schema_type("none", "string") == "none"
43 assert coerce_to_schema_type("none", ["string", "null"]) == "none"
44
45 def test_nil_string_never_converted(self):
46 assert coerce_to_schema_type("nil", "string") == "nil"
47 assert coerce_to_schema_type("nil", ["string", "null"]) == "nil"
48
49 def test_non_null_value_with_null_type(self):
50 assert coerce_to_schema_type("hello", ["null", "string"]) == "hello"
51
52 class TestStringType:
53 def test_string_type(self):
54 assert coerce_to_schema_type("hello", "string") == "hello"
55
56 def test_str_alias(self):
57 assert coerce_to_schema_type("hello", "str") == "hello"
58
59 def test_text_alias(self):
60 assert coerce_to_schema_type("hello", "text") == "hello"
61
62 def test_varchar_alias(self):
63 assert coerce_to_schema_type("hello", "varchar") == "hello"
64
65 def test_char_alias(self):
66 assert coerce_to_schema_type("x", "char") == "x"
67
68 def test_enum_alias(self):
69 assert coerce_to_schema_type("option_a", "enum") == "option_a"
70
71 class TestIntegerType:
72 def test_integer_type(self):
73 assert coerce_to_schema_type("42", "integer") == 42
74
75 def test_int_alias(self):
76 assert coerce_to_schema_type("42", "int") == 42
77
78 def test_negative_integer(self):
79 assert coerce_to_schema_type("-7", "integer") == -7
80
81 def test_invalid_integer_fallback(self):
82 assert coerce_to_schema_type("not_a_number", "integer") == "not_a_number"
83
84 def test_uint32_alias(self):
85 assert coerce_to_schema_type("5", "uint32") == 5
86
87 def test_long_alias(self):
88 assert coerce_to_schema_type("100", "long") == 100
89
90 class TestNumberType:
91 def test_number_type(self):
92 assert coerce_to_schema_type("3.14", "number") == 3.14
93
94 def test_float_alias(self):
95 assert coerce_to_schema_type("2.5", "float") == 2.5
96
97 def test_double_alias(self):
98 assert coerce_to_schema_type("2.5", "double") == 2.5
99
100 def test_whole_float_returns_int(self):
101 assert coerce_to_schema_type("5.0", "number") == 5
102 assert isinstance(coerce_to_schema_type("5.0", "number"), int)
103
104 def test_invalid_number_fallback(self):
105 assert coerce_to_schema_type("abc", "number") == "abc"
106
107 class TestNonFiniteNumbers:
108 """Non-finite numeric strings must not crash and must coerce to a
109 JSON-serializable value.
110
111 Regression: ``int(float("inf"))`` raised an uncaught ``OverflowError``
112 (only ``ValueError``/``TypeError`` were handled), and ``"1e999"``
113 round-tripped through ``json.loads`` to a float ``inf`` that
114 ``json.dumps`` renders as invalid JSON ``Infinity``.
115 """
116
117 @pytest.mark.parametrize(
118 "value", ["inf", "-inf", "Infinity", "1e999", "nan", "-nan"]
119 )
120 def test_non_finite_number_does_not_crash(self, value):
121 # Must not raise (previously OverflowError for inf/1e999/Infinity).
122 result = coerce_to_schema_type(value, "number")
123 # Result must serialize to valid, finite JSON and round-trip.
124 assert json.loads(json.dumps(result)) == result
125
126 @pytest.mark.parametrize("value", ["inf", "-inf", "1e999"])
127 def test_non_finite_number_preserved_as_string(self, value):
128 assert coerce_to_schema_type(value, "number") == value
129
130 @pytest.mark.parametrize("value", ["inf", "1e999", "Infinity"])
131 def test_non_finite_integer_not_float_inf(self, value):
132 result = coerce_to_schema_type(value, "integer")
133 assert isinstance(result, str)
134 assert result == value
135
136 class TestNonFiniteContainers:
137 """Non-finite floats nested in object/array values must not produce
138 invalid JSON.
139
140 Regression: the ``object``/``array`` branch returned
141 ``json.loads(value)`` directly, so ``"[1e999]"`` became ``[inf]`` and
142 ``'{"x": Infinity}'`` became ``{"x": inf}`` -- values that
143 ``json.dumps`` later renders as invalid JSON (``Infinity``/``NaN``).
144 """
145
146 @pytest.mark.parametrize(
147 "value", ["[1e999]", "[1, 2, 1e999]", "[NaN]", "[-Infinity]"]
148 )
149 def test_array_with_non_finite_preserved_as_string(self, value):
150 result = coerce_to_schema_type(value, "array")
151 assert result == value
152 assert json.loads(json.dumps(result)) == result
153
154 @pytest.mark.parametrize(
155 "value", ['{"x": 1e999}', '{"x": Infinity}', '{"a": [1e999, 2]}']
156 )
157 def test_object_with_non_finite_preserved_as_string(self, value):
158 result = coerce_to_schema_type(value, "object")
159 assert result == value
160 assert json.loads(json.dumps(result)) == result
161
162 def test_finite_array_still_coerced(self):
163 assert coerce_to_schema_type("[1, 2, 3]", "array") == [1, 2, 3]
164
165 def test_finite_object_still_coerced(self):
166 assert coerce_to_schema_type('{"a": 1}', "object") == {"a": 1}
167
168 def test_unknown_type_non_finite_falls_back_to_string(self):
169 # Exercises the final json.loads fallback path.
170 assert coerce_to_schema_type("1e999", "unknown_type") == "1e999"
171
172 class TestBooleanType:
173 def test_true(self):
174 assert coerce_to_schema_type("true", "boolean") is True
175
176 def test_false(self):
177 assert coerce_to_schema_type("false", "boolean") is False
178
179 def test_bool_alias(self):
180 assert coerce_to_schema_type("true", "bool") is True
181
182 def test_one_is_true(self):
183 assert coerce_to_schema_type("1", "boolean") is True
184
185 def test_zero_is_false(self):
186 assert coerce_to_schema_type("0", "boolean") is False
187
188 def test_invalid_boolean_fallback(self):
189 assert coerce_to_schema_type("maybe", "boolean") == "maybe"
190
191 class TestObjectArrayType:
192 def test_object_type(self):
193 assert coerce_to_schema_type('{"a": 1}', "object") == {"a": 1}
194
195 def test_array_type(self):
196 assert coerce_to_schema_type("[1, 2, 3]", "array") == [1, 2, 3]
197
198 def test_invalid_json_fallback(self):
199 assert coerce_to_schema_type("not json", "object") == "not json"
200
201 def test_dict_alias(self):
202 assert coerce_to_schema_type('{"k": "v"}', "dict") == {"k": "v"}
203
204 def test_list_alias(self):
205 assert coerce_to_schema_type("[1]", "list") == [1]
206
207 class TestMultiType:
208 def test_null_takes_priority_over_string(self):
209 assert coerce_to_schema_type("null", ["string", "null"]) is None
210
211 def test_integer_tried_before_string(self):
212 assert coerce_to_schema_type("42", ["integer", "string"]) == 42
213
214 def test_falls_through_to_string(self):
215 assert coerce_to_schema_type("hello", ["integer", "string"]) == "hello"
216
217 class TestFallback:
218 def test_unknown_type_returns_string(self):
219 assert coerce_to_schema_type("hello", "unknown_type") == "hello"
220
221 def test_json_fallback_for_unknown_type(self):
222 assert coerce_to_schema_type('{"a": 1}', "unknown_type") == {"a": 1}
223
224 @pytest.mark.parametrize("schema_type", ["string", "str", "text"])
225 def test_string_types_preserve_value(self, schema_type):
226 assert coerce_to_schema_type("anything", schema_type) == "anything"
227
228 def test_unrecognized_type_falls_back_to_json(self):
229 assert coerce_to_schema_type("42", "interval") == 42
230
231
232 class TestExtractTypesFromSchema:
233 def test_direct_type_string(self):
234 assert extract_types_from_schema({"type": "string"}) == ["string"]
235
236 def test_direct_type_integer(self):
237 assert extract_types_from_schema({"type": "integer"}) == ["integer"]
238
239 def test_type_array(self):
240 result = set(extract_types_from_schema({"type": ["string", "null"]}))
241 assert result == {"string", "null"}
242
243 def test_anyof(self):
244 schema = {"anyOf": [{"type": "object"}, {"type": "null"}]}
245 result = set(extract_types_from_schema(schema))
246 assert result == {"object", "null"}
247
248 def test_oneof(self):
249 schema = {"oneOf": [{"type": "integer"}, {"type": "string"}]}
250 result = set(extract_types_from_schema(schema))
251 assert result == {"integer", "string"}
252
253 def test_allof(self):
254 schema = {"allOf": [{"type": "object"}]}
255 assert extract_types_from_schema(schema) == ["object"]
256
257 def test_enum_infers_types(self):
258 schema = {"enum": [1, "a", None]}
259 result = set(extract_types_from_schema(schema))
260 assert result == {"integer", "string", "null"}
261
262 def test_enum_with_bool(self):
263 schema = {"enum": [True, False]}
264 assert extract_types_from_schema(schema) == ["boolean"]
265
266 def test_enum_with_float(self):
267 schema = {"enum": [1.5, 2.5]}
268 assert extract_types_from_schema(schema) == ["number"]
269
270 def test_enum_with_list_and_dict(self):
271 schema = {"enum": [[1, 2], {"a": 1}]}
272 result = set(extract_types_from_schema(schema))
273 assert result == {"array", "object"}
274
275 def test_none_schema_defaults_to_string(self):
276 assert extract_types_from_schema(None) == ["string"]
277
278 def test_non_dict_schema_defaults_to_string(self):
279 assert extract_types_from_schema("string") == ["string"]
280
281 def test_empty_dict_defaults_to_string(self):
282 assert extract_types_from_schema({}) == ["string"]
283
284 def test_nested_anyof(self):
285 schema = {
286 "anyOf": [
287 {"anyOf": [{"type": "integer"}, {"type": "null"}]},
288 {"type": "string"},
289 ]
290 }
291 result = set(extract_types_from_schema(schema))
292 assert result == {"integer", "null", "string"}
293
294
295 def _value_of(expr: str):
296 """Parse a single Python expression and run get_parameter_value on it."""
297 return get_parameter_value(ast.parse(expr, mode="eval").body)
298
299
300 def _first_call(text: str) -> ast.Call:
301 """Parse ``[foo(...)]`` and return the single ast.Call node."""
302 statement = ast.parse(text).body[0]
303 assert isinstance(statement, ast.Expr)
304 assert isinstance(statement.value, ast.List)
305 call = statement.value.elts[0]
306 assert isinstance(call, ast.Call)
307 return call
308
309
310 def _bare_call(text: str) -> ast.Call:
311 """Parse ``foo(...)`` (no list wrapper) and return the ast.Call node."""
312 statement = ast.parse(text).body[0]
313 assert isinstance(statement, ast.Expr)
314 assert isinstance(statement.value, ast.Call)
315 return statement.value
316
317
318 def _kwarg_constant(call: ast.Call, index: int = 0):
319 """Return the constant value of the call's ``index``-th keyword arg."""
320 value = call.keywords[index].value
321 assert isinstance(value, ast.Constant)
322 return value.value
323
324
325 class TestMakeValidPythonStringLiterals:
326 def test_bracket_inside_string_is_literal(self):
327 # A bracket inside a string argument must not be counted as a
328 # structural bracket. Regression: `]` inside the string popped the
329 # bracket stack and the whole call raised as mismatched.
330 text = "[exec(command='grep -F \"]\" log.txt')]"
331 assert make_valid_python(text) == (text, "")
332
333 def test_open_bracket_inside_string_is_literal(self):
334 # An unclosed `[` inside a string must not leave a phantom open
335 # bracket on the stack.
336 text = "[exec(command='grep [abc log.txt')]"
337 assert make_valid_python(text) == (text, "")
338
339 def test_partial_string_with_bracket_completes(self):
340 # Streaming prefix ending mid-string after a literal bracket closes
341 # with quote + paren + bracket.
342 result = make_valid_python('[exec(command=\'grep -F "]" lo')
343 assert result is not None
344 completed, added = result
345 assert added == "')]"
346 assert completed == "[exec(command='grep -F \"]\" lo')]"
347
348 def test_real_mismatched_bracket_still_raises(self):
349 with pytest.raises(UnexpectedAstError):
350 make_valid_python("[exec(command=data])")
351
352 def test_multiline_string_argument_recovers_after_escape(self):
353 # A raw newline inside a string argument is invalid Python, so
354 # make_valid_python alone returns None; callers pre-escape control
355 # chars (as the lfm2 parser does) and the escaped value must evaluate
356 # back to the original.
357 text = "[exec(command='line1\nline2')]"
358 assert make_valid_python(text) is None
359 result = make_valid_python(escape_ctrl_chars_in_strings(text))
360 assert result is not None
361 completed, added = result
362 assert added == ""
363 assert _kwarg_constant(_first_call(completed)) == "line1\nline2"
364
365 def test_value_ending_in_backslash_recovers(self):
366 # A string value ending in a literal backslash: the closing quote follows
367 # an escaped backslash (an *even* run), so it closes the string. Checking
368 # only the single preceding char misread it as an escaped quote, left the
369 # string open, and make_valid_python returned None — dropping calls whose
370 # last argument ends in a backslash (common in regex like r'\b').
371 text = "[write(path='x', content='pattern \\\\')]"
372 assert make_valid_python(text) == (text, "")
373
374 def test_escaped_quote_odd_backslashes_stays_open(self):
375 # An escaped quote (an *odd* backslash run) must NOT close the string;
376 # only the final unescaped quote does. Value round-trips to it's fine.
377 text = "[say(msg='it\\'s fine')]"
378 assert make_valid_python(text) == (text, "")
379 assert _kwarg_constant(_first_call(text)) == "it's fine"
380
381
382 class TestEscapeCtrlCharsInStrings:
383 def test_newline_inside_string_escaped(self):
384 assert escape_ctrl_chars_in_strings("f(cmd='a\nb')") == "f(cmd='a\\nb')"
385
386 def test_ctrl_chars_outside_strings_untouched(self):
387 assert escape_ctrl_chars_in_strings("f(a=1,\nb=2)") == "f(a=1,\nb=2)"
388
389 def test_existing_escapes_pass_through(self):
390 text = "f(cmd='a\\nb')"
391 assert escape_ctrl_chars_in_strings(text) == text
392
393 def test_escaped_quote_does_not_close_string(self):
394 assert escape_ctrl_chars_in_strings("f(cmd='a\\'\nb')") == "f(cmd='a\\'\\nb')"
395
396 def test_value_preserved_through_ast(self):
397 # The escaped text parses and evaluates back to the original value.
398 raw = "cat > f.py << EOF\nimport csv\nEOF\techo done"
399 escaped = escape_ctrl_chars_in_strings(f"f(cmd='{raw}')")
400 assert _kwarg_constant(_bare_call(escaped)) == raw
401
402 def test_nul_byte_inside_string_escaped(self):
403 # ast.parse raises ValueError (not SyntaxError) on NUL anywhere in
404 # the source, so an unescaped NUL in a string arg is unrecoverable.
405 raw = "printf a\x00b"
406 escaped = escape_ctrl_chars_in_strings(f"f(cmd='{raw}')")
407 assert "\x00" not in escaped
408 assert _kwarg_constant(_bare_call(escaped)) == raw
409
410 def test_nul_byte_outside_strings_untouched(self):
411 text = "f(a=1,\x00b=2)"
412 assert escape_ctrl_chars_in_strings(text) == text
413
414
415 class TestGetParameterValueNegativeNumbers:
416 # A negative number is parsed by Python as UnaryOp(USub, Constant(n)), not
417 # a plain Constant. Without explicit handling the entire tool call is
418 # dropped. Negative longitudes/deltas/offsets are extremely common tool
419 # arguments (e.g. every Western-hemisphere coordinate).
420 def test_negative_int(self):
421 assert _value_of("-1") == -1
422
423 def test_negative_float(self):
424 assert _value_of("-3.5") == -3.5
425
426 def test_explicit_positive_int(self):
427 assert _value_of("+7") == 7
428
429 def test_negative_longitude(self):
430 assert _value_of("-74.0046539") == -74.0046539
431
432 def test_negative_in_list(self):
433 assert _value_of("[-1, 2, -3]") == [-1, 2, -3]
434
435 def test_negative_in_dict(self):
436 assert _value_of('{"min": -5, "max": 5}') == {"min": -5, "max": 5}
437
438 def test_nested_negative(self):
439 assert _value_of('{"bbox": [-74.0, 40.7, -73.9]}') == {
440 "bbox": [-74.0, 40.7, -73.9]
441 }
442
443 def test_non_numeric_unary_still_raises(self):
444 # ``not x`` / ``~x`` are not literals and must still be rejected.
445 with pytest.raises(UnexpectedAstError):
446 _value_of("~5")
447 with pytest.raises(UnexpectedAstError):
448 _value_of("not True")
449
450
451 class TestHandleSingleToolNegativeNumbers:
452 def test_negative_arg_end_to_end(self):
453 call = _first_call("[searchWeather(latitude=40.84, longitude=-74.0046539)]")
454 tool = handle_single_tool(call)
455 assert tool.function.name == "searchWeather"
456 assert json.loads(tool.function.arguments) == {
457 "latitude": 40.84,
458 "longitude": -74.0046539,
459 }
460
461 def test_negative_delta_end_to_end(self):
462 call = _first_call("[updateInventory(quantity_delta=-20)]")
463 tool = handle_single_tool(call)
464 assert json.loads(tool.function.arguments) == {"quantity_delta": -20}
465
466
467 class TestGetParameterValueSet:
468 # JSON has no set type; a set argument is decoded as a list (preserving
469 # source order) instead of dropping the whole call, mirroring the tuple
470 # handling.
471 def test_set_becomes_list(self):
472 assert _value_of("{'a', 'b'}") == ["a", "b"]
473
474 def test_set_of_numbers(self):
475 assert _value_of("{1, 2, 3}") == [1, 2, 3]
476
477 def test_set_nested_in_dict(self):
478 assert _value_of("{'tags': {'x', 'y'}}") == {"tags": ["x", "y"]}
479
480 def test_set_end_to_end(self):
481 call = _first_call("[label(tags={'urgent', 'bug'})]")
482 tool = handle_single_tool(call)
483 assert json.loads(tool.function.arguments) == {"tags": ["urgent", "bug"]}
484
485
486 class TestMakeValidPythonSets:
487 def test_complete_set_in_model_text_accepted(self):
488 # A set the model wrote and closed itself is a genuine argument.
489 text = "[label(tags={'urgent', 'bug'})]"
490 assert make_valid_python(text) == (text, "")
491
492 def test_truncated_dict_completed_to_set_still_rejected(self):
493 # `{"k` closes to `{"k"}` — a truncated dict, not a set the model
494 # wrote. The completion added the `}`, so it must keep waiting.
495 assert make_valid_python('[f(x={"k') is None
496
497
498 class TestGetParameterValueFString:
499 # A placeholder-free f-string is a plain string constant, but ast parses
500 # it as JoinedStr; it must not drop the call. Real placeholders are not
501 # literals and must still be rejected.
502 def test_constant_fstring(self):
503 assert _value_of("f'hello world'") == "hello world"
504
505 def test_empty_fstring(self):
506 assert _value_of("f''") == ""
507
508 def test_constant_fstring_in_list(self):
509 assert _value_of("[f'a', 'b']") == ["a", "b"]
510
511 def test_fstring_with_placeholder_still_raises(self):
512 with pytest.raises(UnexpectedAstError):
513 _value_of("f'{x}'")
514
515 def test_constant_fstring_end_to_end(self):
516 call = _first_call("[send(msg=f'hello')]")
517 tool = handle_single_tool(call)
518 assert json.loads(tool.function.arguments) == {"msg": "hello"}
519
520
521 class TestGetParameterValueTuple:
522 # JSON has no tuple type, so a tuple argument is decoded as a list rather
523 # than dropping the whole call.
524 def test_tuple_becomes_list(self):
525 assert _value_of("(800, 600)") == [800, 600]
526
527 def test_nested_tuple(self):
528 assert _value_of("[(1, 2), (3, 4)]") == [[1, 2], [3, 4]]
529
530 def test_tuple_with_negative(self):
531 assert _value_of("(-74.0, 40.7)") == [-74.0, 40.7]
532
533 def test_tuple_end_to_end(self):
534 call = _first_call("[resize(size=(800, 600))]")
535 tool = handle_single_tool(call)
536 assert json.loads(tool.function.arguments) == {"size": [800, 600]}
537
538
539 class TestEscapeNestedQuotesInStrings:
540 # Unescaped same-style quotes nested in a string argument (shell
541 # commands like sed -n '1,9p') are Python juxtaposition errors. When
542 # exactly one quote can syntactically close the string, close there and
543 # escape the interior quotes; anything ambiguous is left unchanged.
544 def test_sed_command_recovers_exact_value(self):
545 text = "[bash(command='sed -n '360,450p' /testbed/common.py')]"
546 rewritten, changed = escape_nested_quotes_in_strings(text)
547 assert changed
548 assert _kwarg_constant(_first_call(rewritten)) == (
549 "sed -n '360,450p' /testbed/common.py"
550 )
551
552 def test_double_quote_variant(self):
553 text = '[bash(command="grep "foo" log.txt")]'
554 rewritten, changed = escape_nested_quotes_in_strings(text)
555 assert changed
556 assert _kwarg_constant(_first_call(rewritten)) == 'grep "foo" log.txt'
557
558 def test_mixed_with_already_escaped_quote(self):
559 text = "[bash(command='it\\'s 'x' end')]"
560 rewritten, changed = escape_nested_quotes_in_strings(text)
561 assert changed
562 assert _kwarg_constant(_first_call(rewritten)) == "it's 'x' end"
563
564 def test_doubly_nested_python_c_payload(self):
565 # command='python3 -c "...latex(x, mul_symbol='\,')..."' — two false
566 # closers both followed by ')', but only the real one yields text
567 # that parses; ast-validation disambiguates.
568 text = "[bash(command='python3 -c \"print(latex(x, mul_symbol='\\,'))\"')]"
569 rewritten, changed = escape_nested_quotes_in_strings(text)
570 assert changed
571 assert _kwarg_constant(_first_call(rewritten)) == (
572 "python3 -c \"print(latex(x, mul_symbol='\\,'))\""
573 )
574
575 def test_multiline_python_c_with_inner_string(self):
576 # Raw newlines beyond the phantom close plus a single-quoted inner
577 # string; recovery must survive both (caller re-escapes ctrl chars).
578 inner = "python3 -c \"\ntest_str = 'is <b>fine</b>'\nprint(test_str)\n\""
579 text = f"[bash(command='{inner}')]"
580 rewritten, changed = escape_nested_quotes_in_strings(text)
581 assert changed
582 recovered = escape_ctrl_chars_in_strings(rewritten)
583 assert _kwarg_constant(_first_call(recovered)) == inner
584
585 def test_nested_quotes_before_non_string_argument(self):
586 # A later NON-string argument adds no candidate closer, so the
587 # nested string is still unambiguous.
588 text = "[run(cmd='awk '{print}' f', count=2)]"
589 rewritten, changed = escape_nested_quotes_in_strings(text)
590 assert changed
591 call = _first_call(rewritten)
592 assert _kwarg_constant(call, 0) == "awk '{print}' f"
593
594 @pytest.mark.parametrize(
595 "text",
596 [
597 "[f(a='x')]",
598 "[f(a='x', b='y')]",
599 "[f(a={'k': 'v'})]",
600 "[f(a='it\\'s fine')]",
601 "[a(x='p'), b(y='q')]",
602 ],
603 )
604 def test_valid_text_unchanged(self, text):
605 rewritten, changed = escape_nested_quotes_in_strings(text)
606 assert not changed
607 assert rewritten == text
608
609 @pytest.mark.parametrize(
610 "text",
611 [
612 "[f(a='echo 'hi', b='x')]",
613 "[run(cmd='awk '{print}' f', mode='fast')]",
614 ],
615 )
616 def test_ambiguous_nesting_left_unchanged(self, text):
617 # A later string argument's closing quote is itself a plausible
618 # closer, making the nesting formally ambiguous; no rewrite is
619 # attempted rather than guessing.
620 rewritten, changed = escape_nested_quotes_in_strings(text)
621 assert not changed
622 assert rewritten == text
623
624
625 class TestContainsBrokenStringLiteral:
626 @pytest.mark.parametrize(
627 "text, broken",
628 [
629 ("[bash(command='sed -n '360,450p' /x')]", True),
630 ("[f(a='echo 'hi', b='x')]", True),
631 # Closing quote at the very end of partial text: the follower
632 # is unknown until the next chunk arrives, so hold.
633 ("[f(a='x'", True),
634 ("[f(a='x', b='y')]", False),
635 # Mid-string (no closing quote yet) is normal streaming.
636 ('[f(a=\'grep -F "]" log', False),
637 ("[f(a={'k': 'v'})]", False),
638 ("[f(a='it\\'s fine')]", False),
639 ],
640 )
641 def test_detection(self, text, broken):
642 assert contains_broken_string_literal(text) is broken
643
644
645 class TestGetParameterValueNonJsonConstants:
646 # bytes/Ellipsis/complex are ast.Constant but have no JSON form; they
647 # must raise UnexpectedAstError (like other unsupported nodes) instead
648 # of surfacing later as a TypeError inside json.dumps.
649 @pytest.mark.parametrize("expr", ["b'abc'", "...", "1j"])
650 def test_non_json_constant_raises(self, expr):
651 with pytest.raises(UnexpectedAstError):
652 _value_of(expr)
653
654 def test_handle_single_tool_raises_ast_error_not_type_error(self):
655 call = _first_call("[f(x=b'abc')]")
656 with pytest.raises(UnexpectedAstError):
657 handle_single_tool(call)
658
659 @pytest.mark.parametrize(
660 "expr, expected",
661 [("'s'", "s"), ("1", 1), ("1.5", 1.5), ("True", True), ("None", None)],
662 )
663 def test_json_constants_still_pass(self, expr, expected):
664 assert _value_of(expr) == expected
665
666
667 class TestNormalizeLeadingZeroInts:
668 # Zero-padded ints (month=07) are a SyntaxError no other recovery path
669 # handles; the rewrite must strip the padding without touching tokens
670 # that are already valid Python.
671 @pytest.mark.parametrize(
672 "text, expected",
673 [
674 ("[f(month=07)]", "[f(month=7)]"),
675 ("[f(x=007, y=05)]", "[f(x=7, y=5)]"),
676 ("[f(x=0_7)]", "[f(x=7)]"),
677 ("[f(x=-07)]", "[f(x=-7)]"),
678 ],
679 )
680 def test_leading_zeros_stripped(self, text, expected):
681 assert normalize_leading_zero_ints(text) == expected
682 assert ast.parse(expected)
683
684 @pytest.mark.parametrize(
685 "text",
686 [
687 "[f(x=0)]",
688 "[f(x=00)]",
689 "[f(x=0.5)]",
690 "[f(x=07.5)]",
691 "[f(x=1.07)]",
692 "[f(x=1e07)]",
693 "[f(x=0x1F)]",
694 "[f(x=0o17)]",
695 "[f(x=0b101)]",
696 "[f(s='id 007')]",
697 '[f(s="v0.07")]',
698 ],
699 )
700 def test_valid_tokens_and_strings_untouched(self, text):
701 assert normalize_leading_zero_ints(text) == text
702
703 def test_end_to_end(self):
704 normalized = normalize_leading_zero_ints("[set_date(month=07, day=05)]")
705 tool = handle_single_tool(_first_call(normalized))
706 assert json.loads(tool.function.arguments) == {"month": 7, "day": 5}
707
708
709 class TestRenameReservedKwargs:
710 # A parameter named after a Python keyword (`from=1`) is a SyntaxError
711 # that no escape/retry can recover; rename_reserved_kwargs rewrites it to
712 # a parseable name and restore_reserved_kwarg_names is its exact inverse.
713 def test_reserved_kwarg_renamed(self):
714 text, changed = rename_reserved_kwargs("[memory_get(from=1)]")
715 assert changed
716 assert text == "[memory_get(from_pyreservedkw_=1)]"
717 assert ast.parse(text)
718
719 def test_round_trip_restores_original_name(self):
720 renamed, _ = rename_reserved_kwargs("[memory_get(path='M.md', from=1)]")
721 tool = handle_single_tool(_first_call(renamed))
722 restored = restore_reserved_kwarg_names(json.loads(tool.function.arguments))
723 assert restored == {"path": "M.md", "from": 1}
724
725 def test_multiple_reserved_kwargs(self):
726 text, changed = rename_reserved_kwargs('[search(in="docs/", from=0)]')
727 assert changed
728 args = json.loads(handle_single_tool(_first_call(text)).function.arguments)
729 assert restore_reserved_kwarg_names(args) == {"in": "docs/", "from": 0}
730
731 def test_keyword_inside_string_untouched(self):
732 text, changed = rename_reserved_kwargs('[f(cmd="import x from y")]')
733 assert not changed
734 assert text == '[f(cmd="import x from y")]'
735
736 def test_keyword_with_from_eq_inside_string_untouched(self):
737 text, changed = rename_reserved_kwargs('[f(cmd="SELECT from=1")]')
738 assert not changed
739
740 def test_keyword_value_untouched(self):
741 # `x=True` has a keyword as *value*, not parameter name.
742 text, changed = rename_reserved_kwargs("[f(x=True, y=None)]")
743 assert not changed
744
745 def test_double_equals_untouched(self):
746 text, changed = rename_reserved_kwargs('[f(expr="a", cond=1)]')
747 assert not changed
748 # `in ==` comparison-like text is not kwarg position anyway, but the
749 # `==` guard also protects string-free edge text.
750 text, changed = rename_reserved_kwargs("[f(x=1)]")
751 assert not changed
752
753 def test_non_keyword_names_untouched(self):
754 text, changed = rename_reserved_kwargs("[f(fromage=1, classic=2)]")
755 assert not changed
756
757 def test_spaces_around_equals(self):
758 text, changed = rename_reserved_kwargs("[f( from = 1 )]")
759 assert changed
760 assert ast.parse(text)
761
762 def test_restore_leaves_normal_names_alone(self):
763 assert restore_reserved_kwarg_names({"path": "x", "from": 1}) == {
764 "path": "x",
765 "from": 1,
766 }