diff --git a/docs/release_notes.md b/docs/release_notes.md index e1840bd1..202bde6d 100644 --- a/docs/release_notes.md +++ b/docs/release_notes.md @@ -2,6 +2,11 @@ ## 0.3.0b1 (unreleased) +Constructor signature errors now identify an unexpected keyword in the primary +`SignatureError` message and `slot`, including through `Definition(...).build()`. +The stable `"argument binding failed"` reason and original exception cause are +retained; argument values are not rendered. See [Signatures](signatures.md#errors). + Maintained dense CPU estimator adapters now provide XGBoost classification and scalar regression (`dryml.models.xgb`) and statsmodels OLS/binary Logit (`dryml.models.statsmodels`). Imports and configuration are passive; fitting and diff --git a/docs/signatures.md b/docs/signatures.md index 31a11581..bafeca86 100644 --- a/docs/signatures.md +++ b/docs/signatures.md @@ -293,6 +293,14 @@ binding, discovery, context ownership, wrapper conflicts, and unavailable or ambiguous authority. Repo and lifecycle errors retain their own types and cleanup semantics during Mat realization. +For a closed constructor signature, a known unexpected keyword is included in +`SignatureError.slot` and the primary message, including through +`Definition(...).build()`. For example, a misspelled `chlid` keyword reports +`Signature normalization failed: argument binding failed at chlid.` The reason +remains `"argument binding failed"`, argument values are excluded, and the +original Python `TypeError` remains the chained cause. Accepted variadic keyword +names are not labeled as unexpected when another binding requirement fails. + Symbolic Definition failures use `ParameterizationError(ValueError)`. Its specializations are `UnresolvedDefinitionError` for required resolution, `ParameterizationLimitError` for bounded traversal or generation work, and diff --git a/src/dryml/core/signatures.py b/src/dryml/core/signatures.py index b3cbf223..fa998256 100644 --- a/src/dryml/core/signatures.py +++ b/src/dryml/core/signatures.py @@ -29,7 +29,8 @@ class SignatureError(TypeError): Args: reason: Fixed diagnostic category describing the failed contract. - slot: Optional parameter or return slot path. + slot: Optional parameter or return slot path, including a known + unexpected keyword name when binding a closed constructor signature. Errors never include caller value representations. Chained lower-level errors remain available as causes. @@ -547,6 +548,8 @@ def prepare_args(self, args: tuple[Any, ...], kwargs: Mapping[str, Any], *, repo Raises: SignatureError: If binding, controls, or assertions are invalid. + Unexpected keywords in closed constructor signatures identify + the keyword in the error's slot without rendering its value. Exception: Propagates explicitly requested Repo metadata failures without changing their type. @@ -566,7 +569,16 @@ def _prepare_args(self, args: tuple[Any, ...], kwargs: Mapping[str, Any], bound = self.signature.bind(*args, **dict(kwargs)) bound.apply_defaults() except TypeError as error: - raise SignatureError("argument binding failed") from error + slot = None + if self.constructor and not any( + parameter.kind is inspect.Parameter.VAR_KEYWORD + for parameter in self.signature.parameters.values() + ): + slot = next(( + name for name in kwargs + if type(name) is str and name not in self.signature.parameters + ), None) + raise SignatureError("argument binding failed", slot) from error controls = _validate_controls( controls, _selection_keys(self.signature, bound.arguments) ) diff --git a/tests/core/test_signatures.py b/tests/core/test_signatures.py index fc7220aa..20158fee 100644 --- a/tests/core/test_signatures.py +++ b/tests/core/test_signatures.py @@ -105,6 +105,64 @@ def __init__(self, value: Ref[ConcreteDefinition]): assert compile_signature(ConstructorTarget, constructor=True).slots["value"].role == "ref" +def test_definition_build_names_unexpected_constructor_keyword_without_values() -> None: + """Definition builds expose the typo in the primary error before construction.""" + + with pytest.raises(SignatureError) as caught: + Definition(SignatureContainer, child=None, chlid="private argument value").build() + + assert caught.value.reason == "argument binding failed" + assert caught.value.slot == "chlid" + assert "at chlid" in str(caught.value) + assert "private argument value" not in str(caught.value) + assert isinstance(caught.value.__cause__, TypeError) + assert "chlid" in str(caught.value.__cause__) + + +def test_constructor_keyword_diagnostic_does_not_render_argument_values() -> None: + """Finding a constructor typo never invokes a rejected value's repr hook.""" + + class Unprintable: + def __repr__(self): + pytest.fail("binding diagnostics must not render argument values") + + plan = compile_signature(SignatureContainer, constructor=True) + with pytest.raises(SignatureError) as caught: + plan.prepare_args((), {"child": None, "chlid": Unprintable()}) + + assert caught.value.slot == "chlid" + assert "at chlid" in str(caught.value) + + +@pytest.mark.parametrize("mode", ("function", "variadic-constructor", "nonstring-key")) +def test_binding_errors_do_not_guess_unexpected_constructor_keywords(mode) -> None: + """Function calls, accepted variadic keys and malformed keys keep generic errors.""" + + def target(child=None): + return child + + class VariadicConstructor: + def __init__(self, required, **options): + pytest.fail("invalid calls must fail before the constructor runs") + + if mode == "function": + plan = compile_signature(target) + kwargs = {"chlid": "private argument value"} + elif mode == "variadic-constructor": + plan = compile_signature(VariadicConstructor, constructor=True) + kwargs = {"valid_extra": "private argument value"} + else: + plan = compile_signature(SignatureContainer, constructor=True) + kwargs = {1: "private argument value"} + + with pytest.raises(SignatureError) as caught: + plan.prepare_args((), kwargs) + + assert caught.value.reason == "argument binding failed" + assert caught.value.slot is None + assert str(caught.value) == "Signature normalization failed: argument binding failed." + + @pytest.mark.parametrize( "annotation", (