From 0d74e8251b3e7be1ae467c92fc8f4ee2f5ec4c1a Mon Sep 17 00:00:00 2001 From: David Smith Date: Thu, 15 Oct 2026 23:38:41 -0400 Subject: [PATCH 1/3] Validate container item types in probe inputs; parse signatures bracket-aware (#160) validate_probe_input skipped every parameter whose hint was not a bare primitive, so list[str] accepted integers and bare list accepted a string. It now checks list/tuple/set[T] and dict[K, V] with primitive item types (bool is not int, int is accepted for float), bare forms by shape, and leaves nested and Any items unchecked as before. Hidden signatures keep the generic message. dict keys are checked only when the key type is str, because JSON object keys always arrive as strings. parse_function_signature split the parameter list on every comma, so dict[str, int] became two parameters. It now splits on top-level commas only, honouring brackets and quotes, and both probeable handlers delegate to it instead of carrying their own copy. Co-Authored-By: Claude Fable 5.1 --- .../handlers/probeable_code/handler.py | 28 +- .../handlers/probeable_spec/handler.py | 28 +- .../problems_app/services/probe_service.py | 294 +++++++++++++++++- tests/unit/test_probe_input_containers.py | 285 +++++++++++++++++ tests/unit/test_probe_input_validation.py | 4 +- 5 files changed, 586 insertions(+), 53 deletions(-) create mode 100644 tests/unit/test_probe_input_containers.py diff --git a/purplex/problems_app/handlers/probeable_code/handler.py b/purplex/problems_app/handlers/probeable_code/handler.py index 47740c5..12f4e07 100644 --- a/purplex/problems_app/handlers/probeable_code/handler.py +++ b/purplex/problems_app/handlers/probeable_code/handler.py @@ -15,7 +15,6 @@ """ import logging -import re from typing import TYPE_CHECKING, Any from .. import register_handler @@ -34,22 +33,17 @@ def _parse_function_params(signature: str) -> list[dict[str, str]]: - """Parse function signature to extract parameter names and types.""" - match = re.search(r"\(([^)]*)\)", signature) - if not match: - return [] - params_str = match.group(1).strip() - if not params_str: - return [] - params = [] - for param in params_str.split(","): - param = param.strip() - if ":" in param: - name, type_hint = param.split(":", 1) - params.append({"name": name.strip(), "type": type_hint.strip()}) - else: - params.append({"name": param, "type": "Any"}) - return params + """Parse function signature to extract parameter names and types. + + Delegates to probe_service.parse_function_signature so the probe panel and + the probe validator agree on how parameters are split. + """ + # Imported here: the services package imports the handler registry. + from purplex.problems_app.services.probe_service import ( + parse_function_signature, + ) + + return parse_function_signature(signature) @register_handler("probeable_code") diff --git a/purplex/problems_app/handlers/probeable_spec/handler.py b/purplex/problems_app/handlers/probeable_spec/handler.py index fb6ae16..881b33f 100644 --- a/purplex/problems_app/handlers/probeable_spec/handler.py +++ b/purplex/problems_app/handlers/probeable_spec/handler.py @@ -16,7 +16,6 @@ """ import logging -import re from typing import TYPE_CHECKING, Any from .. import register_handler @@ -34,22 +33,17 @@ def _parse_function_params(signature: str) -> list[dict[str, str]]: - """Parse function signature to extract parameter names and types.""" - match = re.search(r"\(([^)]*)\)", signature) - if not match: - return [] - params_str = match.group(1).strip() - if not params_str: - return [] - params = [] - for param in params_str.split(","): - param = param.strip() - if ":" in param: - name, type_hint = param.split(":", 1) - params.append({"name": name.strip(), "type": type_hint.strip()}) - else: - params.append({"name": param, "type": "Any"}) - return params + """Parse function signature to extract parameter names and types. + + Delegates to probe_service.parse_function_signature so the probe panel and + the probe validator agree on how parameters are split. + """ + # Imported here: the services package imports the handler registry. + from purplex.problems_app.services.probe_service import ( + parse_function_signature, + ) + + return parse_function_signature(signature) @register_handler("probeable_spec") diff --git a/purplex/problems_app/services/probe_service.py b/purplex/problems_app/services/probe_service.py index f1ca709..5147ac9 100644 --- a/purplex/problems_app/services/probe_service.py +++ b/purplex/problems_app/services/probe_service.py @@ -9,7 +9,6 @@ import json import logging -import re from typing import TYPE_CHECKING, Any from django.conf import settings @@ -468,33 +467,135 @@ def _build_cooldown_message( return f"No probes remaining. Submit {submissions_to_next} more times to unlock more probes." +_OPENERS = {"(": ")", "[": "]", "{": "}"} +_CLOSERS = frozenset(_OPENERS.values()) + + +def _split_top_level(text: str, sep: str = ",") -> list[str]: + """Split ``text`` on ``sep`` where it is not nested or quoted. + + Brackets ``()``, ``[]`` and ``{}`` raise the nesting depth; single- and + double-quoted strings (with backslash escapes) are skipped. Unbalanced + input is tolerated: depth never drops below zero. + + Args: + text: Text to split, such as a parameter list. + sep: Single-character separator. + + Returns: + The pieces between top-level separators, unstripped. + """ + parts: list[str] = [] + depth = 0 + quote: str | None = None + current: list[str] = [] + i = 0 + while i < len(text): + ch = text[i] + if quote: + current.append(ch) + if ch == "\\" and i + 1 < len(text): + current.append(text[i + 1]) + i += 1 + elif ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + current.append(ch) + elif ch in _OPENERS: + depth += 1 + current.append(ch) + elif ch in _CLOSERS: + depth = max(depth - 1, 0) + current.append(ch) + elif ch == sep and depth == 0: + parts.append("".join(current)) + current = [] + else: + current.append(ch) + i += 1 + parts.append("".join(current)) + return parts + + +def _extract_params_str(signature: str) -> str | None: + """Return the text between the signature's outermost parentheses. + + Scans from the first ``(`` to its matching ``)``, so parentheses inside + defaults (``x: tuple = (1, 2)``) do not end the parameter list early. + + Args: + signature: Function signature like ``"f(x: int) -> bool"``. + + Returns: + The parameter list text, or None when there is no ``(``. + """ + open_idx = signature.find("(") + if open_idx == -1: + return None + depth = 0 + quote: str | None = None + i = open_idx + while i < len(signature): + ch = signature[i] + if quote: + if ch == "\\": + i += 1 + elif ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + elif ch in _OPENERS: + depth += 1 + elif ch in _CLOSERS: + depth -= 1 + if depth == 0: + return signature[open_idx + 1 : i] + i += 1 + # Unterminated: take everything after "(". + return signature[open_idx + 1 :] + + def parse_function_signature(signature: str) -> list[dict[str, str]]: """ Parse function signature to extract parameter names and types. + Parameters are split on top-level commas only, so bracketed annotations + such as ``dict[str, int]`` or ``tuple[int, str]`` and bracketed defaults + stay one parameter (issue #160). The type keeps any default text + (``"int = 3"``); normalize_primitive_type and normalize_container_type + strip it. + Args: signature: Function signature like "f(x: int, y: str) -> bool" Returns: List of dicts with 'name' and 'type' keys """ - # Extract the part between parentheses - match = re.search(r"\(([^)]*)\)", signature) - if not match: + params_str = _extract_params_str(signature) + if params_str is None: return [] - params_str = match.group(1).strip() + params_str = params_str.strip() if not params_str: return [] params = [] - for param in params_str.split(","): + for param in _split_top_level(params_str): param = param.strip() - if ":" in param: - name, type_hint = param.split(":", 1) + if not param: + continue # trailing comma + # Typed only when the colon comes before any default, so an untyped + # default containing a colon (sep="a:b") stays one name (mirrors + # parseSignatureParams in probeArgs.ts) + colon = param.find(":") + equals = param.find("=") + if colon != -1 and (equals == -1 or colon < equals): + name, type_hint = param[:colon], param[colon + 1 :] params.append({"name": name.strip(), "type": type_hint.strip()}) else: - params.append({"name": param, "type": "Any"}) + name = param.split("=", 1)[0] + params.append({"name": name.strip(), "type": "Any"}) return params @@ -545,15 +646,167 @@ def _matches_primitive(value: Any, kind: str) -> bool: return True +_CONTAINER_ALIASES: dict[str, str] = { + "list": "list", + "tuple": "tuple", + "set": "set", + "dict": "dict", +} + + +def _split_type_hint(type_hint: str | None) -> str | None: + """Drop a default from a type hint; None for missing or implicit Optional. + + Args: + type_hint: Raw type hint such as ``"list[int] = []"``. + + Returns: + The stripped annotation, or None when the hint is empty or the default + is ``None`` (an implicit ``Optional``, which is not checked). + """ + if not type_hint: + return None + # "=" never appears inside an annotation, but may appear in a default + # string, so split at the first top-level "=" only. + base = _split_top_level(type_hint, "=")[0] + default = type_hint[len(base) + 1 :] if len(base) < len(type_hint) else "" + if default.strip() == "None": + return None + base = base.strip() + return base or None + + +def normalize_container_type(type_hint: str | None) -> dict[str, Any] | None: + """Map a parameter type hint to a one-level container description. + + Recognises ``list``, ``tuple``, ``set`` and ``dict`` (and the ``typing`` + aliases ``List``, ``Tuple``, ``Set``, ``Dict``; case-insensitive), bare or + subscripted. Item, key and value kinds are canonical primitive kinds from + the alias table, or None when the argument is not a primitive (nested + containers, ``Any``, classes), in which case only the shape is checked. + + ``tuple[T]`` and ``tuple[T, ...]`` are treated as homogeneous tuples of + ``T``. A fixed-shape tuple such as ``tuple[int, str]`` is checked as shape + only (no per-position checks). + + ``Optional[...]``, ``X | None`` and a ``None`` default are not containers + here, so they stay unchecked. + + Args: + type_hint: Raw type hint such as ``"list[int]"`` or ``"Dict[str, int]"``. + + Returns: + None when the hint is not a checked container, otherwise + ``{"kind": "list"|"tuple"|"set", "item": kind|None}`` or + ``{"kind": "dict", "key": kind|None, "value": kind|None}``. + """ + base = _split_type_hint(type_hint) + if base is None: + return None + if len(_split_top_level(base, "|")) > 1: + return None + + bracket = base.find("[") + if bracket == -1: + head, args = base, None + else: + if not base.endswith("]"): + return None + head, args = base[:bracket], base[bracket + 1 : -1] + + kind = _CONTAINER_ALIASES.get(head.strip().lower()) + if kind is None: + return None + + arg_list = [a.strip() for a in _split_top_level(args)] if args is not None else [] + + def _primitive(arg: str) -> str | None: + return _PRIMITIVE_ALIASES.get(arg.lower()) + + if kind == "dict": + if len(arg_list) == 2: + return { + "kind": "dict", + "key": _primitive(arg_list[0]), + "value": _primitive(arg_list[1]), + } + return {"kind": "dict", "key": None, "value": None} + + item = None + if len(arg_list) == 1 or ( + kind == "tuple" and len(arg_list) == 2 and arg_list[1] == "..." + ): + item = _primitive(arg_list[0]) + return {"kind": kind, "item": item} + + +def _matches_container(value: Any, spec: dict[str, Any]) -> bool: + """Check a JSON-decoded value against a normalize_container_type spec. + + list, tuple and set values arrive as JSON arrays. Dict keys arrive as JSON + object keys, which are always strings, so keys are only checked against a + ``str`` key kind; other key kinds are not checkable on the wire. + + Args: + value: The probe value. + spec: Output of normalize_container_type. + + Returns: + True if the value has the container shape and every checked item, + key and value matches its primitive kind. + """ + if spec["kind"] == "dict": + if not isinstance(value, dict): + return False + key_kind, value_kind = spec["key"], spec["value"] + if key_kind == "str" and not all(_matches_primitive(k, "str") for k in value): + return False + if value_kind and not all( + _matches_primitive(v, value_kind) for v in value.values() + ): + return False + return True + + if not isinstance(value, list): + return False + item_kind = spec["item"] + if item_kind and not all(_matches_primitive(v, item_kind) for v in value): + return False + return True + + +def _container_description(spec: dict[str, Any]) -> str: + """Describe a container spec for a revealed-type error message. + + Args: + spec: Output of normalize_container_type. + + Returns: + Text such as ``"a list of str"``, ``"a dict of str to int"`` or + ``"a list"`` when the item kinds are unchecked. + """ + kind = spec["kind"] + if kind == "dict": + if spec["key"] and spec["value"]: + return f"a dict of {spec['key']} to {spec['value']}" + return "a dict" + if spec["item"]: + return f"a {kind} of {spec['item']}" + return f"a {kind}" + + def validate_probe_input( signature: str, probe_input: dict[str, Any], *, reveal_types: bool = True ) -> tuple[bool, str | None]: """ Validate probe input against function signature. - Parameter names are checked first (missing, then unknown). Then each - parameter with a primitive type hint (int, float, bool, str and their - aliases) is checked against its value; other hints are not checked. + Parameter names are checked first (missing, then unknown). Then, in + signature order, each parameter with a primitive type hint (int, float, + bool, str and their aliases) is checked against its value, and each + one-level container hint (list/tuple/set/dict, see + normalize_container_type) is checked for shape and primitive items. + Other hints (Any, Optional, nested item types, classes) are not checked. Args: signature: The function signature @@ -583,15 +836,22 @@ def validate_probe_input( if extra: return False, f"Unknown parameters: {', '.join(extra)}" - # Check primitive types in signature order + # Check primitive and container types in signature order for param in params: - kind = normalize_primitive_type(param["type"]) - if kind is None: - continue name = param["name"] - if not _matches_primitive(probe_input[name], kind): + value = probe_input[name] + kind = normalize_primitive_type(param["type"]) + if kind is not None: + if _matches_primitive(value, kind): + continue if not reveal_types: return False, f"That value is not valid for parameter '{name}'" return False, f"Parameter '{name}' must be {_KIND_ARTICLES[kind]} {kind}" + spec = normalize_container_type(param["type"]) + if spec is None or _matches_container(value, spec): + continue + if not reveal_types: + return False, f"That value is not valid for parameter '{name}'" + return False, f"Parameter '{name}' must be {_container_description(spec)}" return True, None diff --git a/tests/unit/test_probe_input_containers.py b/tests/unit/test_probe_input_containers.py new file mode 100644 index 0000000..bfd3740 --- /dev/null +++ b/tests/unit/test_probe_input_containers.py @@ -0,0 +1,285 @@ +"""Unit tests for container probe input validation and signature parsing. + +Covers bracket-aware ``parse_function_signature`` (issue #160) and the +one-level container checks in ``validate_probe_input``: ``list[str]`` used to +accept integers silently. No database access. + +Fixed-shape tuples such as ``tuple[int, str]`` are checked as shape only +(must be a JSON array); per-position checks are deliberately not done. +""" + +import pytest + +from purplex.problems_app.services.probe_service import ( + normalize_container_type, + parse_function_signature, + validate_probe_input, +) + +pytestmark = [pytest.mark.unit] + +HIDDEN = "That value is not valid for parameter '{}'" + + +class TestSignatureParsing: + def test_dict_annotation_is_one_parameter(self): + assert parse_function_signature("def f(m: dict[str, int], k: str)") == [ + {"name": "m", "type": "dict[str, int]"}, + {"name": "k", "type": "str"}, + ] + + def test_tuple_annotation_is_one_parameter(self): + assert parse_function_signature("def f(p: tuple[int, str]) -> int") == [ + {"name": "p", "type": "tuple[int, str]"} + ] + + def test_bracketed_defaults(self): + sig = "def f(items: list[int] = [], pair: tuple = (1, 2), x: int = 5)" + assert parse_function_signature(sig) == [ + {"name": "items", "type": "list[int] = []"}, + {"name": "pair", "type": "tuple = (1, 2)"}, + {"name": "x", "type": "int = 5"}, + ] + + def test_quoted_comma_in_default(self): + assert parse_function_signature('def f(sep: str = ",", n: int)') == [ + {"name": "sep", "type": 'str = ","'}, + {"name": "n", "type": "int"}, + ] + + def test_untyped_default_keeps_bare_name(self): + assert parse_function_signature("def f(x=5, y)") == [ + {"name": "x", "type": "Any"}, + {"name": "y", "type": "Any"}, + ] + + def test_untyped_default_with_colon_keeps_bare_name(self): + assert parse_function_signature('def f(sep="a:b", n: int)') == [ + {"name": "sep", "type": "Any"}, + {"name": "n", "type": "int"}, + ] + + def test_trailing_comma_and_empty(self): + assert parse_function_signature("def f(a: int,)") == [ + {"name": "a", "type": "int"} + ] + assert parse_function_signature("def f()") == [] + assert parse_function_signature("no parens") == [] + + +class TestNormalizeContainer: + @pytest.mark.parametrize( + ("hint", "expected"), + [ + ("list[int]", {"kind": "list", "item": "int"}), + ("List[String]", {"kind": "list", "item": "str"}), + ("set[float]", {"kind": "set", "item": "float"}), + ("tuple[int]", {"kind": "tuple", "item": "int"}), + ("tuple[int, ...]", {"kind": "tuple", "item": "int"}), + ("tuple[int, str]", {"kind": "tuple", "item": None}), + ("list", {"kind": "list", "item": None}), + ("list[list[int]]", {"kind": "list", "item": None}), + ("list[Any]", {"kind": "list", "item": None}), + ("Dict[str, int]", {"kind": "dict", "key": "str", "value": "int"}), + ("dict", {"kind": "dict", "key": None, "value": None}), + ("list[int] = []", {"kind": "list", "item": "int"}), + ], + ) + def test_containers(self, hint, expected): + assert normalize_container_type(hint) == expected + + @pytest.mark.parametrize( + "hint", + [ + None, + "", + "int", + "Any", + "Optional[list[int]]", + "list[int] | None", + "list[int] = None", + "MyClass[int]", + ], + ) + def test_not_checked(self, hint): + assert normalize_container_type(hint) is None + + +class TestListItems: + def test_list_int_accepts_ints(self): + assert validate_probe_input("def f(x: list[int])", {"x": [1, -2, 3]}) == ( + True, + None, + ) + + def test_empty_list_accepted(self): + assert validate_probe_input("def f(x: list[str])", {"x": []}) == (True, None) + + def test_list_int_rejects_string_item(self): + assert validate_probe_input("def f(x: list[int])", {"x": [1, "2"]}) == ( + False, + "Parameter 'x' must be a list of int", + ) + + def test_list_str_rejects_int_item(self): + """The maintainer's bug: list[str] silently accepted integers.""" + ok, err = validate_probe_input( + "def schedule(tasks: list[str])", {"tasks": ["a", 2]} + ) + assert ok is False + assert err == "Parameter 'tasks' must be a list of str" + + def test_list_int_rejects_bool_item(self): + assert validate_probe_input("def f(x: list[int])", {"x": [1, True]}) == ( + False, + "Parameter 'x' must be a list of int", + ) + + def test_list_float_accepts_ints(self): + assert validate_probe_input("def f(x: list[float])", {"x": [1, 2.5]}) == ( + True, + None, + ) + + @pytest.mark.parametrize("value", ["[1, 2]", 5, None, {"a": 1}]) + def test_list_rejects_non_array(self, value): + assert validate_probe_input("def f(x: list[int])", {"x": value}) == ( + False, + "Parameter 'x' must be a list of int", + ) + + def test_typing_alias(self): + assert validate_probe_input("def f(x: List[str])", {"x": [1]}) == ( + False, + "Parameter 'x' must be a list of str", + ) + + +class TestTupleAndSet: + def test_tuple_of_int(self): + assert validate_probe_input("def f(x: tuple[int, ...])", {"x": [1, 2]}) == ( + True, + None, + ) + assert validate_probe_input("def f(x: tuple[int])", {"x": ["a"]}) == ( + False, + "Parameter 'x' must be a tuple of int", + ) + + def test_set_of_str(self): + assert validate_probe_input("def f(x: set[str])", {"x": ["a", 1]}) == ( + False, + "Parameter 'x' must be a set of str", + ) + + def test_fixed_shape_tuple_is_shape_only(self): + sig = "def f(p: tuple[int, str], n: int)" + assert validate_probe_input(sig, {"p": [1, "a"], "n": 2}) == (True, None) + # Positions are not checked, only that the value is an array. + assert validate_probe_input(sig, {"p": ["a", 1], "n": 2}) == (True, None) + assert validate_probe_input(sig, {"p": "1,a", "n": 2}) == ( + False, + "Parameter 'p' must be a tuple", + ) + + +class TestBareAndNested: + def test_bare_list_rejects_dict(self): + assert validate_probe_input("def f(x: list)", {"x": {"a": 1}}) == ( + False, + "Parameter 'x' must be a list", + ) + + def test_bare_list_accepts_mixed_items(self): + assert validate_probe_input("def f(x: list)", {"x": [1, "a", None]}) == ( + True, + None, + ) + + def test_bare_dict_rejects_list(self): + assert validate_probe_input("def f(x: dict)", {"x": [1]}) == ( + False, + "Parameter 'x' must be a dict", + ) + + def test_nested_list_checked_for_shape_only(self): + sig = "def f(x: list[list[int]])" + assert validate_probe_input(sig, {"x": [["a"], [1]]}) == (True, None) + assert validate_probe_input(sig, {"x": [1, 2]}) == (True, None) + assert validate_probe_input(sig, {"x": "nope"}) == ( + False, + "Parameter 'x' must be a list", + ) + + +class TestDict: + SIG = "def f(counts: dict[str, int], label: str)" + + def test_accepts_matching(self): + assert validate_probe_input( + self.SIG, {"counts": {"a": 1, "b": 2}, "label": "x"} + ) == (True, None) + + def test_rejects_bad_value(self): + assert validate_probe_input(self.SIG, {"counts": {"a": "1"}, "label": "x"}) == ( + False, + "Parameter 'counts' must be a dict of str to int", + ) + + def test_rejects_non_object(self): + assert validate_probe_input(self.SIG, {"counts": [], "label": "x"}) == ( + False, + "Parameter 'counts' must be a dict of str to int", + ) + + def test_non_str_keys_not_checkable_on_the_wire(self): + """JSON object keys are always strings, so int keys are not checked.""" + sig = "def f(m: dict[int, str])" + assert validate_probe_input(sig, {"m": {"1": "a"}}) == (True, None) + assert validate_probe_input(sig, {"m": {"1": 2}}) == ( + False, + "Parameter 'm' must be a dict of int to str", + ) + + def test_signature_order_first_mismatch(self): + assert validate_probe_input( + "def f(a: list[int], b: str)", {"b": 5, "a": ["x"]} + ) == (False, "Parameter 'a' must be a list of int") + + +class TestHiddenAndDefaults: + @pytest.mark.parametrize( + ("hint", "bad_value"), + [ + ("list[str]", [1]), + ("list", {"a": 1}), + ("dict[str, int]", {"a": "b"}), + ("tuple[int, str]", "x"), + ], + ) + def test_hidden_message_is_generic(self, hint, bad_value): + ok, err = validate_probe_input( + f"def f(tasks: {hint})", {"tasks": bad_value}, reveal_types=False + ) + assert ok is False + assert err == HIDDEN.format("tasks") + for word in ("list", "dict", "tuple", "int", "str"): + assert word not in err + + def test_default_with_brackets_still_checked(self): + sig = "def f(items: list[int] = [], n: int = 0)" + assert validate_probe_input(sig, {"items": [1], "n": 2}) == (True, None) + assert validate_probe_input(sig, {"items": ["a"], "n": 2}) == ( + False, + "Parameter 'items' must be a list of int", + ) + + def test_none_default_is_unchecked(self): + sig = "def f(items: list[int] = None)" + assert validate_probe_input(sig, {"items": None}) == (True, None) + assert validate_probe_input(sig, {"items": ["a"]}) == (True, None) + + def test_name_checks_still_first(self): + assert validate_probe_input( + "def f(m: dict[str, int], k: str)", {"m": "bad"} + ) == (False, "Missing required parameters: k") diff --git a/tests/unit/test_probe_input_validation.py b/tests/unit/test_probe_input_validation.py index af2a740..4352dba 100644 --- a/tests/unit/test_probe_input_validation.py +++ b/tests/unit/test_probe_input_validation.py @@ -129,14 +129,14 @@ class TestUncheckedHints: [ "def f(x)", "def f(x: Any)", - "def f(x: list[int])", - "def f(x: dict)", "def f(x: Optional[int])", "def f(x: MyClass)", ], ) @pytest.mark.parametrize("value", ["abc", 5, None, [1, 2], {"k": 1}]) def test_non_primitive_hints_skipped(self, signature, value): + # Container hints (list[int], dict, ...) are checked since the call + # line; see test_probe_input_containers.py. assert _valid(signature, {"x": value}) == (True, None) @pytest.mark.parametrize("hint", [None, "", "Any", "list[int]", "Optional[int]"]) From 11ce32fb69ca72dcff734beb9117c4b85d9e2db4 Mon Sep 17 00:00:00 2001 From: David Smith Date: Thu, 15 Oct 2026 23:38:51 -0400 Subject: [PATCH 2/3] Probe panel: replace per-parameter boxes with a call line The probe panel showed one 72px text box per parameter inside a rendered call, which clipped lists and strings and wrapped badly past two arguments. The function name and parentheses are now fixed text and the student edits one inline, wrapping argument list between them, as Python literals, positional or keyword: mystery([3, 1, 2]) or greet("Ann", times=2). Tab on an empty line or the "Fill in argument names" button drops in name= placeholders; Enter probes; paste is plain text. The region is a role=textbox with aria-label, aria-placeholder, aria-invalid and aria-describedby, and the closing parenthesis trails the last character. Parsing lives in probeArgs.ts and pythonLiteral.ts: a top-level comma split that honours nesting and quotes, keyword/positional mapping with line errors (unknown, duplicate, too many positional, positional after keyword, empty argument), and a Python-literal reader (True/False/None, single quotes, escapes, tuples, sets, trailing commas). Mapped values go through the existing per-parameter parser, which now validates container item types to match the backend, so list[str] given integers shows "Every item must be str" and Probe stays disabled; a quoted str argument is read as the Python literal, not kept with its quotes. The request body is unchanged. Co-Authored-By: Claude Fable 5.1 --- .../activities/inputs/ProbeableCodeInput.vue | 12 +- .../activities/inputs/ProbeableSpecInput.vue | 12 +- .../activities/inputs/shared/ProbePanel.vue | 364 ++++++++++--- .../shared/__tests__/parseInputValue.test.ts | 203 +++++++- .../shared/__tests__/probeArgsLine.test.ts | 484 ++++++++++++++++++ .../__tests__/probeBudgetGuards.test.ts | 10 +- .../activities/inputs/shared/probeArgs.ts | 393 ++++++++++++++ .../activities/inputs/shared/pythonLiteral.ts | 266 ++++++++++ .../activities/inputs/shared/useProbeState.ts | 181 ++----- .../client/src/i18n/locales/en/problems.json | 20 +- .../client/src/i18n/locales/es/problems.json | 20 +- 11 files changed, 1739 insertions(+), 226 deletions(-) create mode 100644 purplex/client/src/components/activities/inputs/shared/__tests__/probeArgsLine.test.ts create mode 100644 purplex/client/src/components/activities/inputs/shared/probeArgs.ts create mode 100644 purplex/client/src/components/activities/inputs/shared/pythonLiteral.ts diff --git a/purplex/client/src/components/activities/inputs/ProbeableCodeInput.vue b/purplex/client/src/components/activities/inputs/ProbeableCodeInput.vue index bf01991..21ea9b6 100644 --- a/purplex/client/src/components/activities/inputs/ProbeableCodeInput.vue +++ b/purplex/client/src/components/activities/inputs/ProbeableCodeInput.vue @@ -19,7 +19,9 @@ :probe-count-display="probeCountDisplay" :probe-status-text="probeStatusHint" :probe-params="parameters" - :probe-inputs="probeInputs" + :args-line="argsLine" + :args-template="argsTemplate" + :line-error="lineError" :can-probe="canProbe" :is-executing="probing" :has-valid-inputs="hasValidInputs" @@ -31,7 +33,7 @@ :format-function-call="formatFunctionCall" :format-output="formatOutput" @execute-probe="executeProbe" - @update-input="updateProbeInput" + @update-args="setArgsLine" /> @@ -140,7 +142,7 @@ const { probing, probeError, probeHistory, - probeInputs, + argsLine, functionName, parameters, canProbe, @@ -149,12 +151,14 @@ const { probeStatusClass, hasValidInputs, inputErrors, + lineError, + argsTemplate, isDuplicate, cachedResult, executeProbe, formatFunctionCall, formatOutput, - updateProbeInput, + setArgsLine, } = useProbeState(() => props.problem, { isSubmitting: () => props.disabled }) const { t } = useI18n() diff --git a/purplex/client/src/components/activities/inputs/ProbeableSpecInput.vue b/purplex/client/src/components/activities/inputs/ProbeableSpecInput.vue index b2503e3..b59e111 100644 --- a/purplex/client/src/components/activities/inputs/ProbeableSpecInput.vue +++ b/purplex/client/src/components/activities/inputs/ProbeableSpecInput.vue @@ -19,7 +19,9 @@ :probe-count-display="probeCountDisplay" :probe-status-text="probeStatusHint" :probe-params="parameters" - :probe-inputs="probeInputs" + :args-line="argsLine" + :args-template="argsTemplate" + :line-error="lineError" :can-probe="canProbe" :is-executing="probing" :has-valid-inputs="hasValidInputs" @@ -31,7 +33,7 @@ :format-function-call="formatFunctionCall" :format-output="formatOutput" @execute-probe="executeProbe" - @update-input="updateProbeInput" + @update-args="setArgsLine" /> @@ -142,7 +144,7 @@ const { probing, probeError, probeHistory, - probeInputs, + argsLine, functionName, parameters, canProbe, @@ -151,12 +153,14 @@ const { probeStatusClass, hasValidInputs, inputErrors, + lineError, + argsTemplate, isDuplicate, cachedResult, executeProbe, formatFunctionCall, formatOutput, - updateProbeInput, + setArgsLine, } = useProbeState(() => props.problem, { isSubmitting: () => props.disabled }) const { t } = useI18n() diff --git a/purplex/client/src/components/activities/inputs/shared/ProbePanel.vue b/purplex/client/src/components/activities/inputs/shared/ProbePanel.vue index 2687eaf..e67a4ee 100644 --- a/purplex/client/src/components/activities/inputs/shared/ProbePanel.vue +++ b/purplex/client/src/components/activities/inputs/shared/ProbePanel.vue @@ -3,36 +3,35 @@
- {{ functionName }} - ( - - ) + id="probe-args" + ref="argsRef" + class="call-args" + :contenteditable="editable ? EDITABLE_MODE : 'false'" + role="textbox" + aria-multiline="false" + spellcheck="false" + :data-placeholder="argsPlaceholder" + :aria-placeholder="argsPlaceholder" + :aria-label="$t('problems.probe.argsAriaLabel', { name: functionName })" + :aria-disabled="editable ? undefined : 'true'" + :aria-invalid="hasErrors ? 'true' : undefined" + :aria-describedby="hasErrors ? 'probe-field-errors probe-args-help' : 'probe-args-help'" + @input="onArgsInput" + @keydown="onArgsKeydown" + @paste="onArgsPaste" + /> + ) +
→
- + + +
    +
  • + {{ lineError }} +
  • + +
    + {{ $t('problems.probe.argsHelp') }} +
    +