diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a37d889 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,51 @@ +name: test + +on: + push: + branches: [main, master] + pull_request: + workflow_dispatch: + +concurrency: + group: test-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} + +permissions: + contents: read + +jobs: + test: + name: build + pytest (Julia ${{ matrix.julia }}, py${{ matrix.python }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + julia: ["1.12.6"] + python: ["3.12"] + steps: + - uses: actions/checkout@v4 + + - uses: julia-actions/setup-julia@v2 + with: + version: ${{ matrix.julia }} + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Resolve Julia bindir + run: echo "JULIA_BINDIR=$(julia -e 'print(Sys.BINDIR)')" >> "$GITHUB_ENV" + + - name: Build libjl2py + run: make -C out/jl2py/csrc + + - name: Install Python deps + run: python -m pip install --upgrade pip "numpy>=2.0" pytest + + # The `jl` fixture (tests/conftest.py) provides a session-scoped Julia. + # Memory-wrap tests that need the experimental libjulia-internal build + # skip themselves in this default build. + - name: Run tests + env: + JL2PY_LIB_PATH: ${{ github.workspace }}/out/jl2py/csrc/build/libjl2py.so + run: python -m pytest out/jl2py/tests -v diff --git a/out/jl2py/tests/conftest.py b/out/jl2py/tests/conftest.py new file mode 100644 index 0000000..5b2b3b1 --- /dev/null +++ b/out/jl2py/tests/conftest.py @@ -0,0 +1,18 @@ +""" +pytest configuration for jl2py tests. +""" +import os +import sys + +# Ensure src/ is on path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +import pytest +import jl2py + + +@pytest.fixture(scope="session") +def jl(): + """Session-scoped jl2py runtime instance.""" + julia_bindir = os.environ.get("JULIA_BINDIR") or None + return jl2py.init(julia_bindir=julia_bindir) diff --git a/out/jl2py/tests/test_array.py b/out/jl2py/tests/test_array.py index be8d69c..fc3e8fc 100644 --- a/out/jl2py/tests/test_array.py +++ b/out/jl2py/tests/test_array.py @@ -10,17 +10,13 @@ import sys, os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +import pytest import numpy as np import jl2py from jl2py.array import JuliaArray, JuliaMemory from jl2py.types import JuliaInt64, JuliaFloat64 -def setup(): - julia_bindir = os.environ.get("JULIA_BINDIR", os.path.expanduser("~/content/workspace/usr/bin")) - return jl2py.init(julia_bindir=julia_bindir) - - def has_internal(): """Detect whether libjl2py was built with JL2PY_HAS_INTERNAL (libjulia-internal). Memory wrap/alloc functions require internal symbols; they return NULL without it.""" @@ -163,6 +159,7 @@ def test_int_array(jl): # ── Memory tests ───────────────────────────────────────────── +@pytest.mark.skipif(not has_internal(), reason="requires JL2PY_INTERNAL build") def test_memory_from_numpy(jl): """Wrap NumPy as Julia Memory{T}.""" np_arr = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64) @@ -173,6 +170,7 @@ def test_memory_from_numpy(jl): print(f"[PASS] numpy -> Memory: len={len(mem)}") +@pytest.mark.skipif(not has_internal(), reason="requires JL2PY_INTERNAL build") def test_memory_numpy_view(jl): """NumPy view of Julia Memory.""" np_arr = np.array([5.0, 6.0, 7.0], dtype=np.float64) @@ -183,6 +181,7 @@ def test_memory_numpy_view(jl): print(f"[PASS] Memory -> numpy: {list(np_view)}") +@pytest.mark.skipif(not has_internal(), reason="requires JL2PY_INTERNAL build") def test_memory_shared_write(jl): """Memory shares buffer with NumPy.""" np_arr = np.array([1.0, 2.0, 3.0], dtype=np.float64) @@ -222,42 +221,3 @@ def test_array_gc_safety(jl): # ── Main ───────────────────────────────────────────────────── -def main(): - print("=" * 60) - print("jl2py Phase 4 tests: Array + Memory interop") - print("=" * 60) - - jl = setup() - - test_array_from_eval(jl) - test_array_getitem_0based(jl) - test_array_setitem(jl) - test_array_jl_getindex(jl) - test_array_iter(jl) - test_array_contains(jl) - test_numpy_to_julia(jl) - test_julia_to_numpy(jl) - test_zero_copy_shared_memory(jl) - test_2d_array(jl) - test_int_array(jl) - # Memory-from-array works in all modes (struct field access only) - test_array_backing_memory(jl) - - # Memory wrap/alloc require libjulia-internal (jl_alloc_genericmemory, jl_ptr_to_genericmemory) - if has_internal(): - test_memory_from_numpy(jl) - test_memory_numpy_view(jl) - test_memory_shared_write(jl) - print(f"[INFO] Memory wrap/alloc tests: PASSED (experimental build)") - else: - print(f"[SKIP] Memory wrap/alloc tests: libjl2py built without JL2PY_HAS_INTERNAL") - - test_array_gc_safety(jl) - - print() - print("All Phase 4 tests passed!") - jl2py.shutdown() - - -if __name__ == "__main__": - main() diff --git a/out/jl2py/tests/test_exceptions.py b/out/jl2py/tests/test_exceptions.py new file mode 100644 index 0000000..2a643c7 --- /dev/null +++ b/out/jl2py/tests/test_exceptions.py @@ -0,0 +1,60 @@ +"""Typed Julia-error mapping: a Julia exception surfaces as the mapped Python type +(see jl2py.exceptions.EXCEPTION_MAP) and carries its message. + +Run: + JULIA_BINDIR=/bin python tests/test_exceptions.py +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +import jl2py +from jl2py.exceptions import ( + JuliaBoundsError, + JuliaDomainError, + JuliaError, + JuliaMethodError, + JuliaUndefVarError, +) + + +def _expect(jl, expr, exc_type, py_base): + """Evaluating `expr` must raise `exc_type`, which is also a `py_base`.""" + try: + jl.eval(expr) + except JuliaError as e: + assert isinstance(e, exc_type), f"{expr!r}: expected {exc_type.__name__}, got {type(e).__name__}: {e}" + assert isinstance(e, py_base), f"{exc_type.__name__} should also be a {py_base.__name__}" + assert str(e) + print(f"[PASS] {expr!r} -> {type(e).__name__} ({py_base.__name__})") + return + raise AssertionError(f"{expr!r}: expected an exception, none raised") + + +def test_boundserror(jl): + _expect(jl, "[1, 2, 3][10]", JuliaBoundsError, IndexError) + + +def test_domainerror(jl): + _expect(jl, "sqrt(-1.0)", JuliaDomainError, ValueError) + + +def test_methoderror(jl): + _expect(jl, '1 + "a"', JuliaMethodError, TypeError) + + +def test_undefvarerror(jl): + _expect(jl, "this_global_does_not_exist_zzz", JuliaUndefVarError, NameError) + + +def test_base_error_carries_message(jl): + try: + jl.eval('error("boom-xyz")') + except JuliaError as e: + assert "boom-xyz" in str(e) + print("[PASS] base JuliaError carries its message") + return + raise AssertionError("expected an exception, none raised") + + diff --git a/out/jl2py/tests/test_identity.py b/out/jl2py/tests/test_identity.py new file mode 100644 index 0000000..3ee5d72 --- /dev/null +++ b/out/jl2py/tests/test_identity.py @@ -0,0 +1,32 @@ +"""Round-trip identity: a Julia value crossing to Python and back keeps its +`jl_value_t*` identity (PRD §4.3 — the ref system must not re-box an existing +Julia value). + +Run: + JULIA_BINDIR=/bin python tests/test_identity.py +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +import jl2py + + +def test_pointer_identity_roundtrip(jl): + """A heap value passed Py->Jl->Py comes back as the same jl_value_t*, not a copy.""" + arr = jl.eval("[1, 2, 3]") # heap-allocated mutable Julia Array + identity = jl.eval("x -> x") + same = identity(arr) + assert same.ptr == arr.ptr + print("[PASS] pointer identity preserved across Py->Jl->Py") + + +def test_objectid_stable(jl): + """objectid of a wrapped value is stable across repeated boundary crossings.""" + x = jl.eval("Ref(42)") + objectid = jl.eval("objectid") + assert int(objectid(x)) == int(objectid(x)) + print("[PASS] objectid stable for a wrapped value") + + diff --git a/out/jl2py/tests/test_juliaany.py b/out/jl2py/tests/test_juliaany.py index 18be74e..40e5661 100644 --- a/out/jl2py/tests/test_juliaany.py +++ b/out/jl2py/tests/test_juliaany.py @@ -14,11 +14,6 @@ from jl2py.types import JuliaAny, LayoutFlags, register_wrapper, unregister_wrapper -def setup(): - julia_bindir = os.environ.get("JULIA_BINDIR", os.path.expanduser("~/content/workspace/usr/bin")) - return jl2py.init(julia_bindir=julia_bindir) - - def test_juliaany_fallback(jl): jl.eval("struct TestImmBits; x::Float64; y::Int64; end") v = jl.eval("TestImmBits(3.14, 42)") @@ -156,27 +151,3 @@ def span(self): unregister_wrapper("UnitRange") -def main(): - print("=" * 60) - print("jl2py JuliaAny + registry + layout tests") - print("=" * 60) - - jl = setup() - - test_juliaany_fallback(jl) - test_layout_isbits(jl) - test_layout_mutable_pointers(jl) - test_layout_singleton(jl) - test_jl_getfield(jl) - test_register_wrapper(jl) - test_register_parametric(jl) - test_juliaany_repr(jl) - test_julia_type_delegation(jl) - - print() - print("All JuliaAny tests passed!") - jl2py.shutdown() - - -if __name__ == "__main__": - main() diff --git a/out/jl2py/tests/test_kwargs_slice.py b/out/jl2py/tests/test_kwargs_slice.py index 71df238..a58ba82 100644 --- a/out/jl2py/tests/test_kwargs_slice.py +++ b/out/jl2py/tests/test_kwargs_slice.py @@ -21,11 +21,6 @@ from jl2py.array import JuliaArray -def setup(): - julia_bindir = os.environ.get("JULIA_BINDIR", os.path.expanduser("~/content/workspace/usr/bin")) - return jl2py.init(julia_bindir=julia_bindir) - - # ── kwargs round-trip ──────────────────────────────────────── def test_kwargs_user_function(jl): @@ -96,26 +91,3 @@ def test_slice_matches_jl_getindex(jl): # ── Main ───────────────────────────────────────────────────── -def main(): - print("=" * 60) - print("jl2py regression tests: kwargs round-trip + slice->Range") - print("=" * 60) - - jl = setup() - - test_kwargs_user_function(jl) - test_kwargs_multiple(jl) - test_kwargs_base_round(jl) - - test_slice_unit_range(jl) - test_slice_full(jl) - test_slice_step(jl) - test_slice_matches_jl_getindex(jl) - - print() - print("All kwargs + slice regression tests passed!") - jl2py.shutdown() - - -if __name__ == "__main__": - main() diff --git a/out/jl2py/tests/test_smoke.py b/out/jl2py/tests/test_smoke.py index 6cd878c..d1bcd7a 100644 --- a/out/jl2py/tests/test_smoke.py +++ b/out/jl2py/tests/test_smoke.py @@ -110,25 +110,3 @@ def test_exception_handling(jl): print(f"[PASS] Exception handling: caught {e!r}") -def main(): - print("=" * 60) - print("jl2py smoke test") - print("=" * 60) - - jl = test_init() - test_eval(jl) - test_module_access(jl) - test_function_call(jl) - test_repr_str(jl) - test_bool(jl) - test_gc_rooting(jl) - test_exception_handling(jl) - - print() - print("All smoke tests passed!") - - jl2py.shutdown() - - -if __name__ == "__main__": - main() diff --git a/out/jl2py/tests/test_struct.py b/out/jl2py/tests/test_struct.py index d7c9614..5fd0923 100644 --- a/out/jl2py/tests/test_struct.py +++ b/out/jl2py/tests/test_struct.py @@ -11,11 +11,27 @@ import sys, os, gc as python_gc, ctypes sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +import pytest import jl2py from jl2py.types import JuliaFloat64, JuliaInt64, JuliaString, wrap_julia_value from jl2py._lib import get_lib +@pytest.fixture(scope="module", autouse=True) +def _define_structs(jl): + """Define the 8 test structs once per module.""" + jl.eval(""" + struct ImmPoint; x::Float64; y::Float64; end + mutable struct MutPoint; x::Float64; y::Float64; end + struct Parametric{T}; val::T; label::String; end + mutable struct MutParametric{T}; val::T; label::String; end + struct Nested{A,B}; a::A; b::B; end + struct Empty; end + struct SingleField; x::Int64; end + mutable struct Counter; n::Int64; end + """) + + def apply_type(tc, *params): """ Python-side parametric type application using jl2py C API. @@ -44,24 +60,6 @@ def apply_type(tc, *params): return wrap_julia_value(result) -def setup(): - julia_bindir = os.environ.get("JULIA_BINDIR", os.path.expanduser("~/content/workspace/usr/bin")) - jl = jl2py.init(julia_bindir=julia_bindir) - - # Define test structs once - jl.eval(""" - struct ImmPoint; x::Float64; y::Float64; end - mutable struct MutPoint; x::Float64; y::Float64; end - struct Parametric{T}; val::T; label::String; end - mutable struct MutParametric{T}; val::T; label::String; end - struct Nested{A,B}; a::A; b::B; end - struct Empty; end - struct SingleField; x::Int64; end - mutable struct Counter; n::Int64; end - """) - return jl - - # ══════════════════════════════════════════════════════════════ # Immutable structs # ══════════════════════════════════════════════════════════════ @@ -484,48 +482,3 @@ def test_gc_stress_mutation_under_pressure(jl): # Main # ══════════════════════════════════════════════════════════════ -def main(): - print("=" * 60) - print("jl2py struct tests: immutable, mutable, parametric, GC stress") - print("=" * 60) - - jl = setup() - - # Immutable - test_immutable_construct_and_fields(jl) - test_immutable_via_constructor_dispatch(jl) - test_immutable_cannot_mutate(jl) - test_immutable_repr(jl) - test_empty_struct(jl) - test_single_field_struct(jl) - - # Mutable - test_mutable_construct_and_fields(jl) - test_mutable_setfield(jl) - test_mutable_via_constructor(jl) - test_mutable_counter(jl) - - # Parametric - test_parametric_float64(jl) - test_parametric_int64(jl) - test_parametric_string(jl) - test_parametric_constructor_chain(jl) - test_parametric_apply_type2(jl) - test_parametric_apply_type_generic(jl) - test_mutable_parametric(jl) - test_nested_parametric(jl) - - # GC stress - test_gc_stress_explicit_collection(jl) - test_gc_stress_interleaved_dispatch(jl) - test_gc_stress_create_and_drop(jl) - test_gc_stress_nested_parametric(jl) - test_gc_stress_mutation_under_pressure(jl) - - print() - print("All struct tests passed!") - jl2py.shutdown() - - -if __name__ == "__main__": - main() diff --git a/out/jl2py/tests/test_types_containers.py b/out/jl2py/tests/test_types_containers.py index daf8168..32ee642 100644 --- a/out/jl2py/tests/test_types_containers.py +++ b/out/jl2py/tests/test_types_containers.py @@ -18,11 +18,6 @@ from jl2py.containers import JuliaTuple, JuliaDict -def setup(): - julia_bindir = os.environ.get("JULIA_BINDIR", os.path.expanduser("~/content/workspace/usr/bin")) - return jl2py.init(julia_bindir=julia_bindir) - - # ── Typed wrapping ─────────────────────────────────────────── def test_typed_wrapping(jl): @@ -362,27 +357,3 @@ def test_constructor_gc_safety(jl): # ── Main ───────────────────────────────────────────────────── -def main(): - print("=" * 60) - print("jl2py Phase 3 tests: type system + containers") - print("=" * 60) - - jl = setup() - - test_typed_wrapping(jl) - test_numeric_ops(jl) - test_string_ops(jl) - test_tuple_ops(jl) - test_dict_ops(jl) - test_struct_fields(jl) - test_explicit_constructors(jl) - test_type_constructors_via_dispatch(jl) - test_constructor_gc_safety(jl) - - print() - print("All Phase 3 tests passed!") - jl2py.shutdown() - - -if __name__ == "__main__": - main()