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.
Based on quarkusio/quarkus#56029.
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, likesrc/main/java/, packaged into the runtime classpath. -
src/test/python/is picked up in test mode for Python test helpers. -
@GraalPyModuleBindingon a Java interface produces a CDI bean at build time that maps methods to Python functions. -
@GraalPyFunctionlets you remap a Java method name to a different Python function name. -
A CDI-managed
GraalPybean 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 |
|---|---|---|
|
|
Directory with application Python sources. |
|
|
Directory with test Python sources (test mode only). |
|
|
Include test sources when running tests. |
|
|
Fail the build on missing modules/functions. |
|
unset |
Filesystem path prepended to |
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-embeddingfor PyPI deps (requirements.txt, venv viagraalpy-maven-plugin). -
Typed
GraalPyExceptionhierarchy for import/execution failures (currentlyPolyglotException). -
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.