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
19 changes: 17 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,26 @@ libvcell is a Python package that wraps a subset of VCell (Virtual Cell) Java al
## Build & Development Commands

### Setup

```bash
make install # Install poetry env + pre-commit hooks
```

### Build the native shared library (requires GraalVM JDK 23 with native-image)

```bash
scripts/local_build_native.sh # Full local native build (macOS)
poetry build # Build Python wheel (triggers build.py which builds Java/native)
```

### Quality checks

```bash
make check # Runs: poetry check --lock, pre-commit, mypy, deptry
```

### Tests

```bash
poetry run pytest # Run all tests
poetry run pytest tests/test_libvcell.py # Run specific test file
Expand All @@ -33,13 +37,16 @@ poetry run pytest --cov --cov-config=pyproject.toml --cov-report=xml # With cov
```

The Java/native unit tests (`vcell-native/src/test/`) run separately via Maven and exercise the entry points against vcell-core directly (no native-image step):

```bash
mvn -o test -f vcell-native # All vcell-native Java tests (offline)
mvn -o test -f vcell-native -Dtest=MiscTests # Single test class
```

These require the submodule artifacts to already be installed in `.m2` (see Key dependencies); otherwise compilation fails with errors like `cannot find symbol` for vcell-core methods. The CI `quality` job is what runs these (it's the first to fail on a broken vcell-native test). Avoid asserting against full exception stack traces in these tests — frames embed vcell-core line numbers (drift on submodule bumps) and differ between IDE and Surefire runners.

### Type checking & linting

```bash
poetry run mypy # Type check (strict mode, covers libvcell/, tests/, build.py)
```
Expand All @@ -51,18 +58,22 @@ Pre-commit hooks run ruff (lint + format) and prettier automatically.
### Two-layer design: Python wrapper over GraalVM native library

**Python layer** (`libvcell/`):
- `__init__.py` — Public API: `vcml_to_finite_volume_input`, `sbml_to_finite_volume_input`, `sbml_to_vcml`, `vcml_to_sbml`, `vcml_to_vcml`, `vcell_infix_to_python_infix`, `vcell_infix_to_num_expr_infix`. Also exposes `__version__` (read from installed package metadata; `"0.0.0"` when running from source tree)

- `__init__.py` — Public API: `vcml_to_finite_volume_input`, `sbml_to_finite_volume_input`, `vcml_to_moving_boundary_input`, `sbml_to_vcml`, `vcml_to_sbml`, `vcml_to_vcml`, `vcell_infix_to_python_infix`, `vcell_infix_to_num_expr_infix`. Also exposes `__version__` (read from installed package metadata; `"0.0.0"` when running from source tree)
- `solver_utils.py` / `model_utils.py` — Thin wrappers that instantiate `VCellNativeCalls` and delegate to native methods
- `_internal/native_utils.py` — Loads the platform-specific shared library (`.so`/`.dylib`/`.dll`) from `libvcell/lib/` via ctypes; defines `IsolateManager` context manager for GraalVM isolate lifecycle
- `_internal/native_calls.py` — ctypes FFI calls to the native library entry points; handles GraalVM isolate creation/teardown per call, JSON deserialization of `ReturnValue`

**Native/Java layer** (`vcell-native/`):
- `Entrypoints.java` — `@CEntryPoint` methods exposed as C symbols (`vcmlToFiniteVolumeInput`, `sbmlToFiniteVolumeInput`, `vcmlToSbml`, `sbmlToVcml`, `vcmlToVcml`, `vcellInfixToPythonInfix`)

- `Entrypoints.java` — `@CEntryPoint` methods exposed as C symbols (`vcmlToFiniteVolumeInput`, `sbmlToFiniteVolumeInput`, `vcmlToMovingBoundaryInput`, `vcmlToSbml`, `sbmlToVcml`, `vcmlToVcml`, `vcellInfixToPythonInfix`)
- `ModelUtils.java` / `SolverUtils.java` — Java implementation using vcell-core from the `vcell_submodule`
- `solvers/LocalFVSolverStandalone.java` / `solvers/LocalMovingBoundarySolverStandalone.java` — thin subclasses of the vcell-core solvers that expose an input-only write path (they skip the native-executable lookup that the stock `initialize()` performs), so libvcell can generate solver input files without the solver binaries. The Moving Boundary input is a `MovingBoundarySetup` XML consumed downstream by the `vcell-mbsolver` package (`MovingBoundarySolver.from_xml`)
- Built with Maven, then compiled to a shared library via GraalVM `native-maven-plugin` using the `shared-dll` profile
- `MainRecorder.java` — Used with `native-image-agent` to record dynamic reflection/resource configs before native compilation

**Build pipeline** (`build.py`):

1. `mvn clean install -DskipTests` on `vcell_submodule/` (full VCell Java project)
2. `mvn clean install` on `vcell-native/` (builds the shaded JAR)
3. Run JAR with `native-image-agent` to record native-image config into `target/recording/`
Expand All @@ -72,19 +83,23 @@ Pre-commit hooks run ruff (lint + format) and prettier automatically.
Linux wheels are built inside the `docker/Dockerfile_manylinux_*` images (manylinux 2_28 and 2_34, for both `aarch64` and `x86_64`), which provide the GraalVM toolchain needed for native compilation in CI.

### Key dependencies

- `vcell_submodule/` — Git submodule pointing to the full VCell Java repository (provides vcell-core). After cloning or pulling a submodule pointer bump, run `git submodule update --init --recursive` — git does NOT auto-update the submodule working tree, so it can sit at an older commit than the recorded pointer (shows as `M vcell_submodule` in `git status`). A stale checkout causes `vcell-native` to fail compiling against vcell-core. To make vcell-core/math available for a local `vcell-native` build, install them into `.m2` first: `mvn -DskipTests clean install -f vcell_submodule` (or run the full `scripts/local_build_native.sh`).
- GraalVM JDK 23 with `native-image` tool required for building native library (`.java-version` pins `graalvm64-23.0.2`)
- Python >=3.10,<4.0, pydantic for data models

### FFI pattern

Each Python API call: creates `VCellNativeCalls` → loads native lib → creates GraalVM isolate → calls C entry point → receives JSON string → deserializes to `ReturnValue(success, message)` → tears down isolate. The `IsolateManager` context manager handles isolate lifecycle.

### Test fixtures

Test data lives in `tests/fixtures/data/` (VCML and SBML XML files). Fixtures are defined in `tests/fixtures/data_fixtures.py` and imported via `tests/conftest.py`.

## CI

GitHub Actions (`.github/workflows/main.yml`) runs on push to main and PRs:

- Quality checks (pre-commit, mypy, deptry) on ubuntu
- Tests + type checking across matrix: macOS (Intel + ARM), Windows, Ubuntu
- All CI jobs require GraalVM setup for native library compilation
13 changes: 9 additions & 4 deletions libvcell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
vcml_to_sbml,
vcml_to_vcml,
)
from libvcell.solver_utils import sbml_to_finite_volume_input, vcml_to_finite_volume_input
from libvcell.solver_utils import (
sbml_to_finite_volume_input,
vcml_to_finite_volume_input,
vcml_to_moving_boundary_input,
)

try:
__version__ = version("libvcell")
Expand All @@ -16,11 +20,12 @@

__all__ = [
"__version__",
"vcml_to_finite_volume_input",
"sbml_to_finite_volume_input",
"sbml_to_vcml",
"vcell_infix_to_num_expr_infix",
"vcell_infix_to_python_infix",
"vcml_to_finite_volume_input",
"vcml_to_moving_boundary_input",
"vcml_to_sbml",
"vcml_to_vcml",
"vcell_infix_to_python_infix",
"vcell_infix_to_num_expr_infix",
]
23 changes: 23 additions & 0 deletions libvcell/_internal/native_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,29 @@ def vcml_to_finite_volume_input(
logging.exception("Error in vcml_to_finite_volume_input()", exc_info=e)
raise

def vcml_to_moving_boundary_input(
self, vcml_content: str, simulation_name: str, output_dir_path: Path
) -> ReturnValue:
try:
with IsolateManager(self.lib) as isolate_thread:
json_ptr: ctypes.c_char_p = self.lib.vcmlToMovingBoundaryInput(
isolate_thread,
ctypes.c_char_p(vcml_content.encode("utf-8")),
ctypes.c_char_p(simulation_name.encode("utf-8")),
ctypes.c_char_p(str(output_dir_path).encode("utf-8")),
)

value: bytes | None = ctypes.cast(json_ptr, ctypes.c_char_p).value
if value is None:
logging.error("Failed to convert vcml to moving boundary input")
return ReturnValue(success=False, message="Failed to convert vcml to moving boundary input")
json_str: str = value.decode("utf-8")
# self.lib.freeString(json_ptr)
return ReturnValue.model_validate_json(json_data=json_str)
except Exception as e:
logging.exception("Error in vcml_to_moving_boundary_input()", exc_info=e)
raise

def sbml_to_finite_volume_input(self, sbml_content: str, output_dir_path: Path) -> ReturnValue:
try:
with IsolateManager(self.lib) as isolate_thread:
Expand Down
11 changes: 11 additions & 0 deletions libvcell/_internal/native_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ def _define_entry_points(self) -> None:
self.lib.vcmlToVcml.restype = ctypes.c_char_p
self.lib.vcmlToVcml.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p]

# vcmlToMovingBoundaryInput is only present in native libraries built with moving-boundary
# support; guard so the package still loads against older shared libraries.
if hasattr(self.lib, "vcmlToMovingBoundaryInput"):
self.lib.vcmlToMovingBoundaryInput.restype = ctypes.c_char_p
self.lib.vcmlToMovingBoundaryInput.argtypes = [
ctypes.c_void_p,
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.c_char_p,
]

self.lib.vcellInfixToPythonInfix.restype = ctypes.c_char_p
self.lib.vcellInfixToPythonInfix.argtypes = [
ctypes.c_void_p,
Expand Down
20 changes: 20 additions & 0 deletions libvcell/solver_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,26 @@ def vcml_to_finite_volume_input(vcml_content: str, simulation_name: str, output_
return return_value.success, return_value.message


def vcml_to_moving_boundary_input(vcml_content: str, simulation_name: str, output_dir_path: Path) -> tuple[bool, str]:
"""
Convert VCML content to Moving Boundary solver input (a MovingBoundarySetup XML file)

The named simulation must be configured for the Moving Boundary solver. The generated
MovingBoundarySetup XML can be consumed by the vcell-mbsolver package (``MovingBoundarySolver.from_xml``).

Args:
vcml_content (str): VCML content
simulation_name (str): simulation name (must use the Moving Boundary solver)
output_dir_path (Path): output directory path

Returns:
tuple[bool, str]: A tuple containing the success status and a message
"""
native = VCellNativeCalls()
return_value: ReturnValue = native.vcml_to_moving_boundary_input(vcml_content, simulation_name, output_dir_path)
return return_value.success, return_value.message


def sbml_to_finite_volume_input(sbml_content: str, output_dir_path: Path) -> tuple[bool, str]:
"""
Convert SBML content to finite volume input files
Expand Down
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from tests.fixtures.data_fixtures import ( # noqa: F401
moving_boundary_sim_name,
moving_boundary_vcml_file_path,
sbml_file_path,
temp_output_dir,
vcml_app_name,
Expand Down
Loading
Loading