Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions out/jl2py/csrc/jl2py.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
31 changes: 31 additions & 0 deletions out/jl2py/csrc/jl2py_convert.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions out/jl2py/src/jl2py/_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
12 changes: 12 additions & 0 deletions out/jl2py/src/jl2py/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions out/jl2py/tests/test_tuple_boxing.py
Original file line number Diff line number Diff line change
@@ -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")
Loading