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
51 changes: 51 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions out/jl2py/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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)
48 changes: 4 additions & 44 deletions out/jl2py/tests/test_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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()
60 changes: 60 additions & 0 deletions out/jl2py/tests/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -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=<julia>/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")


32 changes: 32 additions & 0 deletions out/jl2py/tests/test_identity.py
Original file line number Diff line number Diff line change
@@ -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=<julia>/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")


29 changes: 0 additions & 29 deletions out/jl2py/tests/test_juliaany.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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()
28 changes: 0 additions & 28 deletions out/jl2py/tests/test_kwargs_slice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
22 changes: 0 additions & 22 deletions out/jl2py/tests/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading
Loading