GraalPy is a Fabric mod that embeds the Truffle Python runtime once, shared across the whole game instance, so other mods can write gameplay logic in Python without bundling a runtime themselves.
GraalPy v1 has no sandboxing. Python mods execute with
allowAllAccess(true) — full JVM host trust, identical to a Java mod. If you
install a Python mod you are granting it the same trust as any other Fabric
mod. This will be stated on every startup in the log. Do not assume isolation.
graalpy/ ← root multi-project
├── graalpy-core/ ← the mod that ships the Truffle runtime
│ └── src/main/java/dev/graalpy/core/
│ ├── GraalPyMod.java Fabric entrypoint, wires all events
│ ├── api/
│ │ ├── GraalPyApi.java Public service interface (other mods use this)
│ │ └── GraalPyApiHolder.java Internal singleton holder
│ ├── engine/
│ │ ├── SharedEngine.java Singleton Engine + Context, cold-start timer
│ │ └── LoggerOutputStream.java Routes Python print() into MC logs
│ ├── loader/
│ │ ├── PythonModLoader.java Classpath discovery, topo-sort, eval, hooks
│ │ ├── PythonModManifest.java Parsed graalpy.mod.toml data class
│ │ ├── PythonModInstance.java Runtime state + cached hook Value handles
│ │ └── TopologicalSort.java Kahn's algorithm dep ordering
│ └── bridge/
│ └── PythonTracebackFormatter.java Clean Python-only tracebacks
│
└── example-python-mod/ ← ships ZERO Truffle runtime
└── src/main/resources/
└── graalpy/mods/example_hello_block/
├── graalpy.mod.toml Python mod manifest
├── main.py Entry-point + lifecycle hooks
├── hello_block.py Custom block (Python subclass of Java Block)
└── broken_handler.py Intentional error — traceback demo
| Phase | Goal | Status |
|---|---|---|
| 0 | Embedding spike — prove Truffle Python runs in a Fabric jar | ✅ Done |
| 1 | Loader Core — shared engine, mod discovery, lifecycle hooks | ✅ Done (this branch) |
| 2 | Pythonic SDK v0 — graalpy.blocks, graalpy.items, events |
🔲 Next |
| 3 | Entities, commands, GUIs | 🔲 Planned |
| 4 | Dev experience — hot reload, type stubs, REPL | 🔲 Planned |
| 5 | Packaging, build, docs | 🔲 Planned |
| 6 | NeoForge port, sandboxed mode, curated pip packages | 🔲 Stretch |
Truffle Python (GraalPy Community Edition) runs on stock OpenJDK — no custom GraalVM JDK required. Since GraalVM Polyglot API 23.1, all language artifacts are published to Maven Central as plain jars. This project targets Java 21 (dropped from the spike's Java 25 to reduce MC version risk).
GraalPy Community Edition is Universal Permissive License (UPL) v1.0 —
OSI-approved, permissive, no copyleft, explicit patent protection. Bundling and
redistributing inside a third-party mod jar is explicitly permitted. The -community
Maven artifacts are used specifically to stay on UPL. (Oracle's commercial
GraalPy distribution uses GFTC instead — do not use those artifacts.)
Not yet measured — run ./gradlew :graalpy-core:runServer and look for:
[GraalPy] Cold-start (Engine + Context init + python warmup): <N> ms
Community benchmarks suggest several hundred ms to low seconds on JVM-mode HotSpot (JIT warmup cost). A few hundred ms added to server/client startup is acceptable; multiple seconds on every world load would not be (but the context is kept alive across world loads — it only initialises once).
Fabric Loader uses its own classloader, not a plain JPMS application. Whether
--add-modules=org.graalvm.polyglot is needed is genuinely unconfirmed for
a distributed (non-dev-env) mod. If Context.create() throws
NoClassDefFoundError or a module-access error in your log, that is a real
finding. The build.gradle has commented-out jvmArgs lines for
runClient/runServer ready to uncomment.
PythonModLoader.discoverAndLoad() does the following at game init time:
- Calls
ClassLoader.getResources("graalpy.mod.toml")on the context classloader, which returns everygraalpy.mod.tomlfound across all jars on the classpath — including jars from other Fabric mods. - Filters to only those whose URL path contains
graalpy/mods/(to avoid false matches from unrelated jars). - Parses each manifest with
toml4jinto aPythonModManifest. - Topologically sorts manifests by
[dependencies].python_modsusing Kahn's algorithm (TopologicalSort). Cycles and missing deps raise loud errors. - For each manifest in order: adds the mod's resource base to
sys.path, evaluates the entry-point script, cacheson_load / on_world_load / on_tick / on_unloadfunction handles, callson_load().
A Python mod jar only needs resources — no Java code is required unless the mod author also wants a Java entrypoint (e.g. for Fabric API access outside the Python lifecycle):
your-python-mod.jar
└── graalpy/
└── mods/
└── <your-mod-id>/
├── graalpy.mod.toml ← required: manifest
├── main.py ← required: entry-point (configurable in manifest)
└── *.py ← optional: modules imported by main.py
Define any of these functions at module level in your entry-point script.
All are optional — GraalPy checks with canExecute() before calling.
def on_load():
"""Runs once after your script is evaluated. Register blocks/items here."""
def on_world_load(world):
"""Runs when any world/dimension is loaded. `world` is a Java ServerWorld."""
def on_tick(server):
"""Runs every server tick (20/s). Rate-limit heavy logic."""
def on_unload():
"""Runs during server shutdown, in reverse load order."""import dev.graalpy.core.api.GraalPyApi;
import org.graalvm.polyglot.Context;
GraalPyApi api = GraalPyApi.getInstance(); // null if called before GraalPyMod.onInitialize
Context ctx = api.getSharedContext();
ctx.eval("python", "print('hello from a Java mod!')");-
Create a new Fabric mod project (or a resources-only jar).
-
Add
graalpyas a dependency infabric.mod.json:"depends": { "graalpy": "*" }
-
Create the manifest at
src/main/resources/graalpy/mods/<your-mod-id>/graalpy.mod.toml:[mod] id = "your_mod_id" name = "Your Mod Name" version = "1.0.0" main = "main.py" [dependencies] python_mods = [] # list other Python mod IDs you depend on
-
Write your entry-point at
src/main/resources/graalpy/mods/<your-mod-id>/main.py:import java def on_load(): print("Hello from my Python mod!")
-
Build and drop both jars (
graalpy-core-*.jarandyour-mod-*.jar) into themods/folder. No extra runtime jars needed.
That's it. GraalPy discovers your graalpy.mod.toml automatically.
# Build everything
./gradlew build
# Run the dev client (graalpy-core + example mod on classpath)
./gradlew :graalpy-core:runClient
# Run the dev server
./gradlew :graalpy-core:runServerGradle wrapper: If
gradlewis missing, rungradle wrapper --gradle-version latestonce, or open the project in IntelliJ IDEA and let it generate the wrapper.
[GraalPy] SECURITY: Python mods have FULL JVM HOST TRUST. No sandboxing in v1.
[GraalPy] Cold-start (Engine + Context init + python warmup): <N> ms
[GraalPy] Discovered 1 Python mod(s): [example_hello_block]
[GraalPy] Load order: [example_hello_block]
[GraalPy] Loading Python mod 'example_hello_block' v0.1.0
[Python] [HelloBlock] Module loaded. Running on Minecraft 26.1.2
[Python] [HelloBlock] on_load() — registering Hello Block via Fabric registry
[Python] [HelloBlock] Registered block: example_hello_block:hello_block → ...
[GraalPy] Python mod 'example_hello_block' loaded successfully.
[GraalPy] 1 Python mod(s) loaded.
To see PythonTracebackFormatter in action, temporarily add this to the
example mod's main.py on_load():
from broken_handler import trigger_error
trigger_error()You will see this in the log (no Java frames):
[GraalPy] Unhandled exception in Python mod 'example_hello_block'
Traceback (most recent call last):
File "main.py", line N, in on_load
File "broken_handler.py", line 32, in trigger_error
File "broken_handler.py", line 40, in _inner
ZeroDivisionError: integer division or modulo by zero
- Whether Truffle Python's
java.extend()for Block subclasses requires any special Fabric mixin or AT to override protected methods — needs a running test. - Exact
[dependencies]TOML schema for declaring Java mod dependencies (currently only Python mod deps are supported). - Whether
sys.pathinjection via resource base is sufficient for all classpath layouts (fat jar, dev classpath, etc.) or whether a customimportlibfinder is needed.