Skip to content

Dataclass attr-call treats callable-valued fields as non-callable, and comment is misleading #352

Description

@alexmojaki

Bug

Callable-valued dataclass fields behave oddly under attr-call syntax.

If a dataclass field stores a callable, then:

  • x.foo retrieves a function-like placeholder
  • x.foo(1) raises TypeError: 'function' object is not callable
  • f = x.foo; f(1) reaches normal external-function dispatch instead

So x.foo(1) is not behaving like “load the field value, then call it”. Instead, dataclass attr-call syntax is treating field names as data-only and rejecting the call before normal external dispatch happens.

Minimal repro

from __future__ import annotations

import dataclasses
from collections.abc import Callable
from typing import Any

import pydantic_monty


def run(
    code: str,
    *,
    inputs: dict[str, Any] | None = None,
    external_functions: dict[str, Callable[..., Any]] | None = None,
) -> Any:
    input_names = list(inputs) if inputs else None
    return pydantic_monty.Monty(code, inputs=input_names).run(
        inputs=inputs,
        external_functions=external_functions,
    )


def host_inc(value: int) -> int:
    return value + 1


@dataclasses.dataclass
class Box:
    foo: object


x = Box(host_inc)
inputs = {'x': x}
registered = {
    'inputs': inputs,
    'external_functions': {'host_inc': host_inc},
}

assert run('repr(x.foo)', inputs=inputs) == "<function 'host_inc' external>"
assert run('type(x.foo).__name__', inputs=inputs) == 'function'

try:
    run('x.foo(1)', inputs=inputs)
except pydantic_monty.MontyRuntimeError as exc:
    assert str(exc) == "TypeError: 'function' object is not callable"
else:
    assert False, 'expected x.foo(1) to fail'

try:
    run('f = x.foo\nf(1)', inputs=inputs)
except RuntimeError as exc:
    assert str(exc) == "External function 'host_inc' called but no external_functions provided"
else:
    assert False, 'expected f(1) without external_functions to fail'

assert run('f = x.foo\nf(1)', **registered) == 2

Actual behavior

x.foo behaves like a function-like external placeholder, but x.foo(1) does not call it.

Instead, the dataclass attr-call path sees that foo exists in attrs and immediately raises TypeError using the stored value’s type.

Expected behavior

One of these should be true consistently:

  • either callable-valued fields should behave like normal values, so x.foo(1) behaves like tmp = x.foo; tmp(1), or
  • callable-valued dataclass fields should be explicitly unsupported, with docs/comments reflecting that

Right now the behavior is surprising because direct lookup and separated call suggest a callable, but attr-call syntax rejects it.

Comment mismatch

The comment on py_call_attr in crates/monty/src/types/dataclass.rs currently says:

/// Otherwise handles the call directly:
/// - Attributes that exist in attrs but aren't callable produce `TypeError`
/// - Private/dunder attributes that aren't in attrs produce `AttributeError`

That is not quite accurate.

The implementation does not check whether the field value is callable. If the attribute exists in attrs, it always raises TypeError based on the stored value’s type:

if let Some(value) = self.get(vm.heap).attrs.get_by_str(method_name, vm.heap, vm.interns) {
    let type_name = value.py_type(vm);
    Err(ExcType::type_error_not_callable_object(type_name))
}

So a more accurate description would be that attrs-backed fields are treated as data values, not methods, and attr-call syntax rejects them even if the stored value is callable-like.

Likely source

HeapRead<Dataclass>::py_call_attr in:

  • crates/monty/src/types/dataclass.rs

It does:

  • public missing attr -> MethodCall
  • present attr -> always TypeError
  • private missing attr -> AttributeError

which explains the discrepancy between x.foo(1) and f = x.foo; f(1).

Notes

I reproduced this locally on main with:

  • ignoreme/workflow/repro_dataclass_callable_field_attr_call.py

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions