Skip to content

Python bindings export external-function values inconsistently depending on name interning #345

@alexmojaki

Description

@alexmojaki

Summary

The Python bindings export the same logical external-function value differently depending on whether the callable's __name__ was already interned by the compiled Monty source.

This means a tiny source change like mentioning foo earlier in the program changes the final Python result for an unrelated input binding bar.

Repro

import pydantic_monty


def run(code: str, *, inputs=None):
    input_names = list(inputs.keys()) if inputs else None
    return pydantic_monty.Monty(code, inputs=input_names).run(inputs=inputs)


def foo():
    pass


print(run("bar; bar", inputs={"foo": foo, "bar": foo}))
print(run("foo; bar", inputs={"foo": foo, "bar": foo}))

Actual behavior

foo
<function 'foo' external>

The final value in both cases is logically the same external function value, but it exports differently.

I also checked a few nearby cases:

run("bar", inputs={"foo": foo, "bar": foo})      # 'foo'
run("foo", inputs={"foo": foo, "bar": foo})      # "<function 'foo' external>"
run("bar, bar", inputs={"foo": foo, "bar": foo}) # ('foo', 'foo')
run("foo, bar", inputs={"foo": foo, "bar": foo}) # ("<function 'foo' external>", "<function 'foo' external>")

If I rename the Python callable:

foo.__name__ = "baz"
print(run("bar; bar", inputs={"foo": foo, "bar": foo}))
print(run("foo; bar", inputs={"foo": foo, "bar": foo}))

both results become bare 'baz', which suggests the trigger is the callable's own name being interned, not the input binding name.

Expected behavior

The same logical external-function value should export consistently regardless of whether its name happened to appear in the source code.

I would expect one of these:

  • always export the same placeholder string/repr-like value
  • always export a dedicated Python-side wrapper/object
  • reject raw external-function final outputs consistently

But it should not depend on whether the function name was interned during compilation.

Likely cause

Python callables passed through inputs= are converted to MontyObject::Function { name, .. } using callable.__name__.

When that enters the VM, MontyObject::Function is lowered in two different ways:

  • if vm.interns.get_string_id_by_name(&name) succeeds, it becomes inline Value::ExtFunction(StringId)
  • otherwise it becomes heap HeapData::ExtFunction(String)

On the way back out, those two runtime representations are handled differently:

  • HeapData::ExtFunction(String) is converted back to MontyObject::Function { name, .. }, which the Python bindings export as the bare name string
  • Value::ExtFunction(StringId) does not have a dedicated MontyObject arm and falls through to repr_or_error(...), which eventually exports as "<function 'foo' external>"

Relevant code:

  • crates/monty-python/src/convert.rs
  • crates/monty/src/object.rs

This seems separate from #339. #339 is about dispatch using callable.__name__; this issue is about inconsistent final output/export of the resulting external-function value depending on name interning.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions