diff --git a/out/jl2py/csrc/jl2py.h b/out/jl2py/csrc/jl2py.h index 0014c95..1790676 100644 --- a/out/jl2py/csrc/jl2py.h +++ b/out/jl2py/csrc/jl2py.h @@ -137,6 +137,8 @@ jl_value_t *jl2py_box_string(const char *s, size_t len); jl_value_t *jl2py_box_symbol(const char *s); jl_value_t *jl2py_box_nothing(void); jl_value_t *jl2py_box_complex128(double re, double im); +/* Build a Julia Tuple from n pre-boxed elements (Core.tuple(elts...)). */ +jl_value_t *jl2py_box_tuple(jl_value_t **elts, int32_t n); int8_t jl2py_unbox_bool(jl_value_t *v); int64_t jl2py_unbox_int64(jl_value_t *v); diff --git a/out/jl2py/csrc/jl2py_convert.c b/out/jl2py/csrc/jl2py_convert.c index 859035f..8cf3648 100644 --- a/out/jl2py/csrc/jl2py_convert.c +++ b/out/jl2py/csrc/jl2py_convert.c @@ -63,6 +63,37 @@ jl_value_t *jl2py_box_complex128(double re, double im) { return res; } +/* Python tuple -> Julia Tuple, via Core.tuple(elts...). Elements are pre-boxed + * by the caller; we root them (+ the result) across the constructing call. */ +jl_value_t *jl2py_box_tuple(jl_value_t **elts, int32_t n) { + if (n < 0) return NULL; + + static jl_function_t *tuplef = NULL; + if (!tuplef) { + tuplef = jl_get_function(jl_core_module, "tuple"); + } + if (!tuplef) return NULL; + + jl_value_t **roots; + JL_GC_PUSHARGS(roots, (size_t)n + 1); /* n args + result */ + for (int32_t i = 0; i < n; i++) roots[i] = elts[i]; + roots[n] = jl_call(tuplef, roots, (uint32_t)n); + + /* Core.tuple(elts...) is normally total for valid pre-boxed elements, + * but if it ever throws, clear the exception here rather than leaving + * it pending for the next unrelated jl2py_call* to trip over. */ + jl_value_t *exc = jl_exception_occurred(); + if (exc) { + jl_exception_clear(); + JL_GC_POP(); + return NULL; + } + + jl_value_t *res = roots[n]; + JL_GC_POP(); + return res; +} + /* ── Unboxing ────────────────────────────────────────────── */ int8_t jl2py_unbox_bool(jl_value_t *v) { diff --git a/out/jl2py/src/jl2py/_lib.py b/out/jl2py/src/jl2py/_lib.py index 9fab2be..53d12b0 100644 --- a/out/jl2py/src/jl2py/_lib.py +++ b/out/jl2py/src/jl2py/_lib.py @@ -122,6 +122,8 @@ def _register_functions(lib): lib.jl2py_box_nothing.restype = VP lib.jl2py_box_complex128.argtypes = [F64, F64] lib.jl2py_box_complex128.restype = VP + lib.jl2py_box_tuple.argtypes = [ctypes.POINTER(VP), I32] + lib.jl2py_box_tuple.restype = VP # Unboxing lib.jl2py_unbox_bool.argtypes = [VP] diff --git a/out/jl2py/src/jl2py/convert.py b/out/jl2py/src/jl2py/convert.py index c791ed1..b2b699f 100644 --- a/out/jl2py/src/jl2py/convert.py +++ b/out/jl2py/src/jl2py/convert.py @@ -66,6 +66,18 @@ def box_python_value(val): if isinstance(val, str): encoded = val.encode("utf-8") return lib.jl2py_box_string(encoded, len(encoded)) + if type(val) is tuple: + # Exact type only: a collections.namedtuple is a tuple subclass and + # would silently lose its field names if boxed as a plain + # positional Tuple here. Let it fall through to the TypeError below + # instead (NamedTuple boxing is a separate, unimplemented feature — + # see TAG_NAMEDTUPLE). + # + # Recursively box each element, then build a Julia Tuple via Core.tuple. + elts = [auto_convert_arg(v) for v in val] + n = len(elts) + arr = (ctypes.c_void_p * n)(*elts) + return lib.jl2py_box_tuple(arr, n) # Not a primitive — caller must handle explicitly return None diff --git a/out/jl2py/tests/test_tuple_boxing.py b/out/jl2py/tests/test_tuple_boxing.py new file mode 100644 index 0000000..ca1bafe --- /dev/null +++ b/out/jl2py/tests/test_tuple_boxing.py @@ -0,0 +1,60 @@ +"""Python tuple -> Julia Tuple autoboxing (convert.box_python_value -> jl2py_box_tuple). + +Regression for #12: tuple arg/kwarg VALUES used to raise +`TypeError: Cannot auto-convert tuple` (broke e.g. pypiccolo `Δt_bounds=(a, b)`). +""" +import os +import sys +from collections import namedtuple + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + + +def test_tuple_is_julia_tuple(jl): + is_tuple = jl.eval("x -> x isa Tuple") + assert bool(is_tuple((1, 2, 3))) + print("[PASS] python tuple -> Julia Tuple") + + +def test_tuple_element_access(jl): + f = jl.eval("t -> t[1] + t[2]") # Julia is 1-indexed + assert int(f((3, 4))) == 7 + print("[PASS] tuple element access") + + +def test_float_tuple_type(jl): + # the Δt_bounds shape: a 2-tuple of Float64 + ty = jl.eval("x -> string(typeof(x))") + s = str(ty((0.1, 0.5))) + assert s.startswith("Tuple{") and "Float64" in s, s + print("[PASS] (0.1, 0.5) ->", s) + + +def test_tuple_as_kwarg_value(jl): + # exactly the failing pypiccolo pattern: a tuple passed as a keyword value + g = jl.eval("f(x; bounds=(0.0, 1.0)) = bounds[2] - bounds[1]") + assert abs(float(g(0, bounds=(0.1, 0.5))) - 0.4) < 1e-9 + print("[PASS] tuple as a keyword-arg value") + + +def test_empty_and_nested_tuple(jl): + assert int(jl.eval("length")(())) == 0 + nested = jl.eval("t -> t[2][1]") + assert int(nested((1, (7, 8)))) == 7 + print("[PASS] empty + nested tuple") + + +def test_namedtuple_rejected_not_silently_flattened(jl): + # A collections.namedtuple is a tuple subclass. isinstance(val, tuple) + # would match it and silently box it as a plain positional Julia Tuple, + # dropping its field names. The tuple branch must use type(val) is + # tuple (exact match) so this still raises TypeError, exactly like any + # other unsupported type -- not a plain tuple, and not (yet) a + # NamedTuple either. + Point = namedtuple("Point", ["x", "y"]) + f = jl.eval("identity") + with pytest.raises(TypeError): + f(Point(x=1, y=2)) + print("[PASS] namedtuple raises TypeError instead of losing field names")