Quarkus GraalPy

Quarkus GraalPy is a Quarkus extension that embeds GraalPy in Java apps. It exposes a managed Context, a fluent programmatic API, and declarative Java-to-Python bindings generated at build time.

Why

Calling Python from Java via the raw GraalVM Polyglot API means managing Context lifecycles, module imports, and type conversions by hand. This extension hides that so Python code can be used like a regular Quarkus bean:

  • src/main/python/ is a source root, like src/main/java/, packaged into the runtime classpath.

  • src/test/python/ is picked up in test mode for Python test helpers.

  • @GraalPyModuleBinding on a Java interface produces a CDI bean at build time that maps methods to Python functions.

  • @GraalPyFunction lets you remap a Java method name to a different Python function name.

  • A CDI-managed GraalPy bean handles ad-hoc programmatic calls.

  • Build-time validation reports missing modules or functions before the app starts.

  • Dev Mode: edits under src/main/python/ trigger a reload.

Getting started

Add the extension to your pom.xml:

<dependency>
    <groupId>io.quarkiverse.graalpy</groupId>
    <artifactId>quarkus-graalpy</artifactId>
    <version>999-SNAPSHOT</version>
</dependency>

Add Python code under src/main/python/:

from types import SimpleNamespace


def predict(features):
    total = sum(features)
    label = "positive" if total >= 0 else "negative"
    confidence = min(1.0, abs(total))
    return SimpleNamespace(label=label, confidence=confidence)

Point the extension at it in src/main/resources/application.properties:

%dev.quarkus.graalpy.validate-bindings=true

Call it from Java, either programmatically or through a declarative binding.

Programmatic API

Inject the GraalPy bean and call a module/function:

package org.acme;

import jakarta.inject.Inject;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

import io.quarkiverse.graalpy.runtime.GraalPy;
import io.quarkiverse.graalpy.runtime.Prediction;

@Path("/classify")
public class GraalPyApplication {

    public record Response(String label, double confidence) {
    }

    @Inject
    GraalPy graalPy;

    @POST
    @Produces(MediaType.APPLICATION_JSON)
    public Response classify(float[] features) {
        Prediction prediction = graalPy.module("classifier")
                .function("predict")
                .invoke(features)
                .as(Prediction.class);
        return new Response(prediction.label(), prediction.confidence());
    }
}

The chain:

graalPy.module("classifier")   // resolves and imports a Python module
       .function("predict")    // picks a callable from that module
       .invoke(features)       // runs it with Java args marshalled to Python
       .as(Prediction.class);  // maps the return value to a Java type

Calls through a single GraalPy bean are serialized because a Polyglot Context cannot be entered concurrently.

Declarative bindings

For typed usage, declare a Java interface:

@GraalPyModuleBinding("classifier")
public interface Classifier {

    Prediction predict(float[] features);

    @GraalPyFunction("calculate_stddev")
    double standardDeviation(double[] values);
}

At build time the extension scans every @GraalPyModuleBinding interface via Jandex and generates an @ApplicationScoped implementation with Gizmo. Each method body resolves to:

graalPy.module(moduleName)
       .function(fnName)
       .invoke(args)
       .as(returnType);

By default the Python function name matches the Java method name. Use @GraalPyFunction("name") to override that. Primitive parameters and returns are boxed/unboxed automatically.

Inject it like any CDI bean:

@Inject
Classifier classifier;

Prediction p = classifier.predict(new float[]{0.2f, -0.1f, 0.9f});

Return results through an interface whose methods map to attributes on the Python object (works with types.SimpleNamespace, a dataclass, or any object with the matching attributes).

Build-time validation

Set quarkus.graalpy.validate-bindings=true to open a GraalPy context during augmentation, import each bound module, and check that every method maps to a callable. Failures abort the build with a report:

GraalPy binding validation failed (2 issue(s)):
  - @GraalPyModuleBinding("classifier") on org.acme.Classifier - missing callable 'predict_v2' (for method predictV2)
  - @GraalPyModuleBinding("statistics") on org.acme.Stats - module not importable: ModuleNotFoundError: No module named 'statistics'

Usually scoped to %dev.quarkus.graalpy.validate-bindings=true so it only runs in dev.

Dev Mode

Any add, remove, or edit of a .py file under src/main/python/ triggers a Quarkus live reload. The managed GraalPy bean is destroyed (its @PreDestroy closes the Polyglot context) and the next call rebuilds a fresh context against the updated sources.

mvn quarkus:dev

Loading Python sources from an arbitrary path

Besides the packaged sources under src/main/python/, you can point the runtime at any directory on disk with quarkus.graalpy.source-path. That directory is prepended to sys.path, so modules there win over packaged ones with the same name. Useful when iterating on scripts kept outside the project.

%dev.quarkus.graalpy.source-path=/home/me/python-scripts

The directory must exist at startup, otherwise the entry is silently skipped.

Test Python sources

In test launch mode the extension also packages src/test/python/ under a separate resource root and adds it to sys.path. Use it for Python fixtures that should not leak into production:

src/
├── main/
│   └── python/
│       └── classifier.py
└── test/
    └── python/
        └── fixtures.py
@QuarkusTest
class FixturesTest {

    @Inject
    GraalPy graalPy;

    @Test
    void loadsTestOnlyModule() {
        Prediction p = graalPy.module("fixtures")
                .function("canned_prediction")
                .invoke()
                .as(Prediction.class);
        assertEquals("canned", p.label());
    }
}

Disable with quarkus.graalpy.include-test-sources=false.

Configuration

Property Default Description

quarkus.graalpy.source-dir

src/main/python

Directory with application Python sources.

quarkus.graalpy.test-source-dir

src/test/python

Directory with test Python sources (test mode only).

quarkus.graalpy.include-test-sources

true

Include test sources when running tests.

quarkus.graalpy.validate-bindings

false

Fail the build on missing modules/functions.

quarkus.graalpy.source-path

unset

Filesystem path prepended to sys.path at runtime.

Testing

The programmatic API works in @QuarkusTest without extra setup. The GraalPy bean is CDI-managed and reads the same configuration:

@QuarkusTest
class ClassifierTest {

    @Inject
    Classifier classifier;

    @Test
    void predictsPositive() {
        Prediction p = classifier.predict(new float[]{0.5f, 0.6f});
        assertEquals("positive", p.label());
    }
}

Limitations

Items from the upstream proposal not yet implemented:

  • Native image support (and docs on C extension / wheel limitations).

  • Full integration with python-embedding for PyPI deps (requirements.txt, venv via graalpy-maven-plugin).

  • Typed GraalPyException hierarchy for import/execution failures (currently PolyglotException).

  • Extended type conversions beyond GraalPy’s default Value.as(Class) (e.g. BigDecimal, LocalDate, custom collection generics).

PRs welcome. See the upstream proposal for the full roadmap.