From 4eddf1c6cf0eeff3d2091541986ad5532464a72d Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 1 Jul 2026 02:12:21 -0400 Subject: [PATCH 1/2] Autobox Python tuple to Julia Tuple --- out/jl2py/csrc/jl2py.h | 2 ++ out/jl2py/csrc/jl2py_convert.c | 15 ++++++++++ out/jl2py/src/jl2py/_lib.py | 2 ++ out/jl2py/src/jl2py/convert.py | 6 ++++ out/jl2py/tests/test_tuple_boxing.py | 43 ++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+) create mode 100644 out/jl2py/tests/test_tuple_boxing.py 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..a2b1f73 100644 --- a/out/jl2py/csrc/jl2py_convert.c +++ b/out/jl2py/csrc/jl2py_convert.c @@ -63,6 +63,21 @@ 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; + jl_function_t *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); + 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..0a664f7 100644 --- a/out/jl2py/src/jl2py/convert.py +++ b/out/jl2py/src/jl2py/convert.py @@ -66,6 +66,12 @@ def box_python_value(val): if isinstance(val, str): encoded = val.encode("utf-8") return lib.jl2py_box_string(encoded, len(encoded)) + if isinstance(val, tuple): + # 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..30e0100 --- /dev/null +++ b/out/jl2py/tests/test_tuple_boxing.py @@ -0,0 +1,43 @@ +"""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 + +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") From cf452bc533f5b1472bda95efff10bfa42a3161ea Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Wed, 1 Jul 2026 03:14:55 -0400 Subject: [PATCH 2/2] Tighten tuple autobox: exact type, exception check, cached lookup - box_python_value: match type(val) is tuple exactly, not isinstance. A collections.namedtuple is a tuple subclass; isinstance matched it and silently boxed it as a plain positional Julia Tuple, dropping its field names. Now it falls through to the existing TypeError. - jl2py_box_tuple: check jl_exception_occurred() after jl_call and clear it, matching every other jl_call site in this codebase. Core.tuple is normally total, but an unhandled exception here would otherwise leak into whatever unrelated jl2py_call* runs next. - jl2py_box_tuple: cache the Core.tuple function pointer in a static instead of re-resolving it on every call, matching getproperty_fn's existing pattern in this file. Added a regression test for the namedtuple case; existing suite unaffected (86 passed, 3 skipped, up from 85/3 baseline). --- out/jl2py/csrc/jl2py_convert.c | 18 +++++++++++++++++- out/jl2py/src/jl2py/convert.py | 8 +++++++- out/jl2py/tests/test_tuple_boxing.py | 17 +++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/out/jl2py/csrc/jl2py_convert.c b/out/jl2py/csrc/jl2py_convert.c index a2b1f73..8cf3648 100644 --- a/out/jl2py/csrc/jl2py_convert.c +++ b/out/jl2py/csrc/jl2py_convert.c @@ -67,12 +67,28 @@ jl_value_t *jl2py_box_complex128(double re, double im) { * 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; - jl_function_t *tuplef = jl_get_function(jl_core_module, "tuple"); + + 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; diff --git a/out/jl2py/src/jl2py/convert.py b/out/jl2py/src/jl2py/convert.py index 0a664f7..b2b699f 100644 --- a/out/jl2py/src/jl2py/convert.py +++ b/out/jl2py/src/jl2py/convert.py @@ -66,7 +66,13 @@ def box_python_value(val): if isinstance(val, str): encoded = val.encode("utf-8") return lib.jl2py_box_string(encoded, len(encoded)) - if isinstance(val, tuple): + 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) diff --git a/out/jl2py/tests/test_tuple_boxing.py b/out/jl2py/tests/test_tuple_boxing.py index 30e0100..ca1bafe 100644 --- a/out/jl2py/tests/test_tuple_boxing.py +++ b/out/jl2py/tests/test_tuple_boxing.py @@ -5,6 +5,9 @@ """ import os import sys +from collections import namedtuple + +import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) @@ -41,3 +44,17 @@ def test_empty_and_nested_tuple(jl): 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")