jbang
jbang copied to clipboard
Jython / GraalPy support in JBang
See https://github.com/orgs/jbangdev/discussions/1909 for a proposal to add Jython support to JBang.
I still think this would be nice to have.
Link to GraalPy documentation on Jython compatibility
- https://www.graalvm.org/python/docs/#migrating-jython-scripts
Note: Java, Kotlin and Groovy all have compilers that can compile source code to class files. Jython used to have a working jythonc compiler, but this compiler is not supported anymore and does not work with Jython 2.7.4 (latest stable version). It is unclear whether Jython 3.0 will have a jythonc compiler.
@wfouche doesn't even need a compiler but some kind of boot strap generation to package things and make it runnable would be relevant I reckon?
@maxandersen, here's an initial attempt at creating a prototype solution to bootstrap a Jython program with JBang. The prototype is written in Python, but can easily be translated to Java.
$ python3 jython-cli.py test.py
test.py (Jython program augmented with JBang-like annotations)
from __future__ import print_function
##DEPS io.leego:banana:2.1.0
##JYTHON 2.7.4
import io.leego.banana.BananaUtils as BananaUtils
import io.leego.banana.Font as Font
text0 = "Jython 2.7"
text1 = BananaUtils.bananaify(text0, Font.STANDARD)
print(text1)
jython-cli.py:
from __future__ import print_function
import os
import sys
text = """
import org.python.util.jython;
public class __CLASSNAME__ {
public static void main(String... args) {
jython.main(args);
}
}
"""
def main():
scriptFilename = sys.argv[1]
javaClassname = "_" + os.path.basename(scriptFilename)[:-3]
javaFilename = javaClassname + ".java"
deps = []
version = "2.7.4"
with open(scriptFilename) as f:
lines = f.readlines()
tag1 = "##DEPS"
tag2 = "##JYTHON"
for line in lines:
if len(line) > len(tag1):
if line[:len(tag1)] == tag1:
list = line.split()
dep = list[1]
deps.append(dep)
if len(line) > len(tag2):
if line[:len(tag2)] == tag2:
version = line.split()[1]
dep = "org.python:jython-standalone:" + version
deps.append(dep)
jf = open(javaFilename,"w+")
jf.write('///usr/bin/env jbang "$0" "$@" ; exit $?' + "\n\n")
for dep in deps:
jf.write("//DEPS " + dep + "\n")
#print(dep)
jf.write(text.replace("__CLASSNAME__",javaClassname))
jf.close()
#print(sys.argv[1:])
params = ""
for e in sys.argv[1:]:
if len(params) > 0:
params += " "
params += e
os.system("jbang run " + javaFilename + " " + params)
main()
test.py can also be bootstrapped using Jython itself.
$ java -jar jython-standalone-2.7.4.jar jython-cli.py test.py # Just to show that it is possible (smile)
@maxandersen , the prototype is a bit more advanced now.
Given script test.py
from __future__ import print_function
import sys
##DEPS io.leego:banana:2.1.0
##JYTHON 2.7.4
##JAVA 21
import io.leego.banana.BananaUtils as BananaUtils
import io.leego.banana.Font as Font
def main():
print(sys.argv)
text0 = "Jython 2.7"
text1 = BananaUtils.bananaify(text0, Font.STANDARD)
print(text1)
main()
and running command
$ python3 jython-cli.py test.py 1 2 3
the test.py script is encoded into file test_py.java, and run via jbang, and the following output is produced.
However, we can now also re-run the precompiled script as:
$ jbang run test_py.java 1 2 3
without recompiling anything, and producing output
File test_py.java
///usr/bin/env jbang "$0" "$@" ; exit $?
//DEPS io.leego:banana:2.1.0
//DEPS org.python:jython-standalone:2.7.4
//JAVA 21
import org.python.util.jython;
import org.python.util.PythonInterpreter;
import java.util.Base64;
public class test_py {
public static String mainScriptTextBase64 = "ZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBwcmludF9mdW5jdGlvbgppbXBvcnQgc3lzCgojIGh0dHBzOi8vZ2l0aHViLmNvbS9qYmFuZ2Rldi9qYmFuZy9pc3N1ZXMvMTkxMQojIGh0dHBzOi8vZ2l0aHViLmNvbS9qeXRob24vanl0aG9uL2lzc3Vlcy8zNzEKCiMgaHR0cHM6Ly9zdGFja292ZXJmbG93LmNvbS9xdWVzdGlvbnMvMTY3MDE5NzkvcGFja2FnaW5nLWEtanl0aG9uLXByb2dyYW0taW4tYW4tZXhlY3V0YWJsZS1qYXIKCiMjREVQUyBpby5sZWVnbzpiYW5hbmE6Mi4xLjAKIyNKWVRIT04gMi43LjQKIyNKQVZBIDIxCgppbXBvcnQgaW8ubGVlZ28uYmFuYW5hLkJhbmFuYVV0aWxzIGFzIEJhbmFuYVV0aWxzCmltcG9ydCBpby5sZWVnby5iYW5hbmEuRm9udCBhcyBGb250CgoKZGVmIG1haW4oKToKICAgIHByaW50KHN5cy5hcmd2KQoKICAgIHRleHQwID0gIkp5dGhvbiAyLjciCiAgICB0ZXh0MSA9IEJhbmFuYVV0aWxzLmJhbmFuYWlmeSh0ZXh0MCwgRm9udC5TVEFOREFSRCkKCiAgICBwcmludCh0ZXh0MSkKCm1haW4oKQ==";
public static void main(String... args) {
String mainScriptFilename = "test.py";
String mainScript = "";
String jythonArgsScript = "";
for (String arg: args) {
//System.out.println("Java: " + arg);
if (jythonArgsScript.length() == 0) {
if (!arg.equals(mainScriptFilename)) {
jythonArgsScript += "'" + mainScriptFilename + "', ";
}
} else {
jythonArgsScript += ", ";
}
jythonArgsScript += "'" + arg + "'";
}
if (jythonArgsScript.length() == 0) {
jythonArgsScript = "'" + mainScriptFilename + "'";
}
jythonArgsScript = "import sys; sys.argv = [" + jythonArgsScript + "]";
{
byte[] decodedBytes = Base64.getDecoder().decode(mainScriptTextBase64);
String text = new String(decodedBytes);
//System.out.println("===");
//System.out.println(text);
//System.out.println("===");
mainScript = text;
}
//System.out.println("args --> " + jythonArgsScript);
{
// run script
PythonInterpreter pyInterp = new PythonInterpreter();
// initialize args
pyInterp.exec(jythonArgsScript);
// run script
pyInterp.exec(mainScript);
}
//jython.main(args);
}
}
More work needs to be done to support //SOURCES as well, or ##SOURCES for Jython, but even if we just support a single main script initially it will be a big jump forward.
is the code somewhere to see/try?
btw. are you aware python has its own inline metadata format now ? https://packaging.python.org/en/latest/specifications/inline-script-metadata/#inline-script-metadata
is the code somewhere to see/try?
btw. are you aware python has its own inline metadata format now ? https://packaging.python.org/en/latest/specifications/inline-script-metadata/#inline-script-metadata
@maxandersen , the code and small test program is in repo https://github.com/wfouche/jython-tests.
I was not aware of the Python metadata format, but it makes sense to adopt it.
I looked at file Source.java in JBang, and thought that one might be able to translate the Jython source code to a Java file on-the-fly, and then jump to the normal JBang logic for processing a Java script as indicated by the one line change below. What do you think of this approach? Or is a differnent approach required?
jython_cli.java is a pure Java implementation of jython-cli.py
$ jbang run jython_cli.java test.py 1 2 3
https://github.com/wfouche/jython-tests/blob/main/jython_cli.java
btw. are you aware python has its own inline metadata format now ? https://packaging.python.org/en/latest/specifications/inline-script-metadata/#inline-script-metadata
Experimenting with Python's inline script metadata adapted for Jython scripts.
# /// script
# requires-jython = ">=2.7.4"
# requires-java = ">=21"
# dependencies = [
# "io.leego:banana:2.1.0",
# ]
# ///
Implemented support for inline script metadata. The updates files are in folder
- https://github.com/wfouche/jython-tests/tree/main/metadata
This is that the output looks like now when running the updated code.
$ python3 jython-cli.py test.py 1 2 3
The source for file test.py.
from __future__ import print_function
import sys
# /// script
# requires-jython = ">=2.7.4"
# requires-java = ">=21"
# dependencies = [
# "io.leego:banana:2.1.0",
# ]
# ///
import io.leego.banana.BananaUtils as BananaUtils
import io.leego.banana.Font as Font
def main():
print(sys.argv)
text0 = "Jython 2.7"
text1 = BananaUtils.bananaify(text0, Font.STANDARD)
print(text1)
main()
@maxandersen , self-contained executable Jython scripts be now written that are run with jython-run.py which uses JBang in the background.
#!/usr/bin/env ./jython-run.py
# restclient.py
from __future__ import print_function
# /// script
# requires-jython = ">=2.7.4"
# requires-java = ">=21"
# dependencies = [
# "org.springframework.boot:spring-boot-starter-web:3.4.4",
# ]
# ///
import org.springframework.web.client.RestClient as RestClient
import java.lang.String as String
def main():
restClient = RestClient.create()
rsp = restClient.get().uri("https://jsonplaceholder.typicode.com/todos/{id}", 1).retrieve().body(String)
print(rsp)
main()
Output:
$ ./restclient.py
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
Wow :) didn't think you would jump to support the official pep. Very nice! Will definitely take a look :)
Wow :) didn't think you would jump to support the official pep. Very nice! Will definitely take a look :)
The Python documentation for inline script metadata provides a regex to extract the information, so it actually simplified the code because I could remove the custom parsing logic that was previously used.
Would it make sense to port to java so could be part of jbang to bootstrap?
Would it make sense to port to java so could be part of jbang to bootstrap?
Then we'd need a different kind of parser. The system is somewhat configurable, but this is so different that it's not something a simple configuration can handle. Which means creating something that's more "pluggable". Not sure right now how easy that will be.
Also still somewhat unclear on how we'd handle the fact that several "engines" exist for running Python code with Java. Do we just pick Jython and too bad for those that want to use GraalPy? Do we somehow make that configurable as well? If so, how? (Is that something we have to decide now, is that a decision we could postpone to some point in the future?)
The brand and version of Python support that is required could potentially be specified with the "requires-
# /// script
# requires-jython = "2.7.4"
# requires-java = "21"
# dependencies = [
# "org.springframework.boot:spring-boot-starter-web:3.4.4",
# ]
# ///
or
# /// script
# requires-graalpy = "24.2.1"
# requires-java = "21"
# dependencies = [
# "......",
# ]
# ///
This is how the current Python 3 (file jython-run.py) implementation works.
- Python file
scriptname.py is translated to a temporary Java file calledscriptname_py.java and written to disk. scriptname_py.java is a valid JBang Java script file with the required //DEPS and //JAVA entries.scriptname_py.java also contains the contents of filescriptname.py encoded as a Base64 Java String.- jython-run.py spawns a process to execute command "jbang run
scriptname_py.java param1 param2 ..." - At runtime
scriptname_py.java instantiates a PythonInterpreter object, decodes the Base64 string into a multi-line string that is passed to the Python interpreter object to execute. - Once
scriptname_py.java exits, jython-run.py deletes the temporaryscriptname_py.java file and terminates. - If the Jython script is run once more (without any changes being made to the script), the same
scriptname_py.java file is created again, but JBang will ensure that the code is not recompiled because the contents of the Java file is identical to the previous run.
I'm experimenting with Graal Python, but encountered an issue.
///usr/bin/env jbang "$0" "$@" ; exit $?
//DEPS org.graalvm.polyglot:polyglot:24.2.1
//DEPS org.graalvm.polyglot:python:24.2.1
//JAVA 21
import org.graalvm.polyglot.*;
class App {
public static void main(String[] args) {
try (var context = Context.create()) {
System.out.println(context.eval("python", "'Hello Python!'").asString());
}
}
}
Output when run with JBang.
$ jbang run App.java
[jbang] Resolving dependencies...
[jbang] org.graalvm.polyglot:polyglot:24.2.1
[jbang] org.graalvm.polyglot:python:24.2.1
[jbang] [ERROR] Could not resolve dependencies
App.java works when run from Gradle with dependencies:
- implementation("org.graalvm.polyglot:polyglot:24.2.1")
- implementation("org.graalvm.polyglot:python:24.2.1")
$ gradle run
Reusing configuration cache.
> Task :app:run
Hello Python!
I found out that the dependency for Graal Python must be specified as:
- org.graalvm.polyglot:python:24.2.1@pom
Jbang doesnt (yet) do pom deps - only done as managed. You'll need to list the deps in the pom explicitly
Discovered that GraalPython already has support for JBang. It is very very new (fresh) .
App.java:
///usr/bin/env jbang "$0" "$@" ; exit $?
//DEPS org.graalvm.python:jbang:24.2.1
//JAVA 21
import org.graalvm.polyglot.*;
class App {
public static void main(String[] args) {
try (var context = Context.create()) {
System.out.println(context.eval("python", "'Hello Python!'").asString());
System.out.println(context.eval("python", "1+1"));
}
}
}
Output:
$ jbang run App.java
Hello Python!
2
Maven:
$ mcs search org.graalvm.python:jbang
Searching for org.graalvm.python:jbang...
Found 2 results
Coordinates Last updated
=========== ============
org.graalvm.python:jbang:24.2.1 15 Apr 2025 at 10:11 (SAST)
org.graalvm.python:jbang:24.2.0 18 Mar 2025 at 12:46 (SAST)
https://mvnrepository.com/artifact/org.graalvm.python/jbang
I could also use the new "export gradle" functionality to create a working Gradle project from App.java (first had to move it to package org.example)
$ $JBANG export gradle org/example/App.java
$ cd App
$ ./gradlew run
> Task: run
Hello Python!
2
App/build.gradle:
plugins {
id 'java'
id 'application'
}
repositories {
mavenCentral()
mavenLocal()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
implementation 'org.graalvm.python:jbang:24.2.1'
}
application {
mainClass = 'org.example.App'
applicationDefaultJvmArgs = []
}
compileJava {
options.compilerArgs += ['-g', '-parameters']
}
jar {
manifest {
attributes(
'Main-Class': 'org.example.App',
)
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
The brand and version of Python support that is required could potentially be specified with the "requires-
" property. # /// script # requires-jython = "2.7.4" # requires-java = "21" # dependencies = [ # "org.springframework.boot:spring-boot-starter-web:3.4.4", # ] # ///or
# /// script # requires-graalpy = "24.2.1" # requires-java = "21" # dependencies = [ # "......", # ] # ///
This proposal still seems to be valid given what now know about GraalPy.
Script python-run.py (previously called jython-run.py) now supports both Jython and Graalpy.
test1.py (Jython)
#!/usr/bin/env ./python-run.py
from __future__ import print_function
import json
import sys
import platform
# /// jbang
# requires-jython = ">=2.7.4"
# requires-java = ">=21"
# ///
def main():
print("version:", platform.python_version())
print("args:", sys.argv)
text = '{"k1": "v1", "k2": "v2", "k3": "v3"}'
jobj = json.loads(text)
print(jobj["k2"])
main()
Output:
$ ./test1.py
[jbang] Resolving dependencies...
[jbang] org.python:jython-standalone:2.7.4
[jbang] Dependencies resolved
[jbang] Building jar for test1_py.java...
version: 2.7.4
args: ['./test1.py']
v2
test2.py (GraalPy) - we do not import the print_function because GraalPy is Python 3.x!
#!/usr/bin/env ./python-run.py
import json
import sys
import platform
# /// jbang
# requires-graalpy = ">=24.2.1"
# requires-java = ">=21"
# ///
def main():
print("version:", platform.python_version())
print("args:", sys.argv)
text = '{"k1": "v1", "k2": "v2", "k3": "v3"}'
jobj = json.loads(text)
print(jobj["k2"])
main()
Output:
$ ./test2.py
[jbang] Resolving dependencies...
[jbang] org.graalvm.python:jbang:24.2.1
[jbang] Dependencies resolved
[jbang] Building jar for test2_py.java...
[jbang] Post build with org.graalvm.python.jbang.JBangIntegration
version: 3.11.7
args: ['./test2.py']
v2
The Jython and GraalPy programs test1.py and test2.py were run on OpenJDK 21.0.6. The Python code will be better optimized if the real GraalVM is used as runtime. This is a future planned experiment. But it is nice to know what GraalPy can be used with JBang without requiring the GraalVM as runtime.
Yes - graalpy was what I mentioned early as having integration - but it's not for pure .py as you are heading towards. Would be nice to have that bootstrapped without requiring python installed first.
Yes - graalpy was what I mentioned early as having integration - but it's not for pure .py as you are heading towards. Would be nice to have that bootstrapped without requiring python installed first.
A pure Java solution is being developed.
Graalpy by default restricts access to most JVM classes, that's is why my initial test program only contained pure Python code. Fortunately, the access restrictions can be disabled by setting allowAllAccess to true.
var context = Context.newBuilder().allowAllAccess(true).build();
With the access restrictions disabled GraalPy can invoke the HttpClient class (for example). Note that because Graalpy supports Python 3 we can specify types for parameters def httpclient(uri: str).
#!/usr/bin/env ./python-run.py
import json
import sys
import platform
import java.net.http.HttpClient as HttpClient
import java.net.http.HttpRequest as HttpRequest
import java.net.http.HttpResponse as HttpResponse
import java.net.URI as URI
# /// jbang
# requires-graalpy = ">=24.2.1"
# requires-java = ">=21"
# ///
def httpclient(uri: str):
print("httpclient:")
client = HttpClient.newHttpClient()
request = HttpRequest.newBuilder().uri(URI.create(uri)).build()
response = client.send(request, HttpResponse.BodyHandlers.ofString())
print(response.body())
def main():
print("version:", platform.python_version())
print("args:", sys.argv)
text = '{"k1": "v1", "k2": "v2", "k3": "v3"}'
jobj = json.loads(text)
print(jobj["k2"])
httpclient("https://jsonplaceholder.typicode.com/todos/1")
main()
Output:
version: 3.11.7
args: ['./test2.py']
v2
httpclient:
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
Graalpy cannot directly import external Java classes. In Jython we can make RestClient available with:
import org.springframework.web.client.RestClient as RestClient
but this does not work in GraalPy; rather use the following statement instead.
RestClient = java.type('org.springframework.web.client.RestClient')
restclient_graalpy:
#!/usr/bin/env ./python-run.py
# restclient.py
# /// jbang
# requires-graalpy = "==24.2.1"
# requires-java = ">=21"
# dependencies = [
# "org.springframework.boot:spring-boot-starter-web:3.4.4",
# ]
# ///
import java
import java.lang.String as String
RestClient = java.type('org.springframework.web.client.RestClient')
def restApiCall(uri: str, id: int):
restClient = RestClient.create()
rsp = restClient.get().uri(uri, id).retrieve().body(String)
print(rsp)
def main():
restApiCall("https://jsonplaceholder.typicode.com/todos/{id}", 1)
main()
Output:
[jbang] Building jar for restclient_graalpy_py.java...
[jbang] Running external post build for org.graalvm.python.jbang.JBangIntegration
[jbang] Post build with org.graalvm.python.jbang.JBangIntegration
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}