jbang icon indicating copy to clipboard operation
jbang copied to clipboard

Jython / GraalPy support in JBang

Open wfouche opened this issue 10 months ago • 43 comments

See https://github.com/orgs/jbangdev/discussions/1909 for a proposal to add Jython support to JBang.

wfouche avatar Jan 23 '25 13:01 wfouche

I still think this would be nice to have.

maxandersen avatar Mar 01 '25 06:03 maxandersen

Link to GraalPy documentation on Jython compatibility

  • https://www.graalvm.org/python/docs/#migrating-jython-scripts

wfouche avatar Apr 08 '25 03:04 wfouche

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 avatar Apr 08 '25 18:04 wfouche

@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 avatar Apr 09 '25 10:04 maxandersen

@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)

Image

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)

wfouche avatar Apr 09 '25 13:04 wfouche

@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.

Image

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

Image

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);
    }
}

wfouche avatar Apr 09 '25 15:04 wfouche

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.

wfouche avatar Apr 09 '25 16:04 wfouche

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 avatar Apr 10 '25 09:04 maxandersen

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?

Image

wfouche avatar Apr 10 '25 15:04 wfouche

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

wfouche avatar Apr 10 '25 16:04 wfouche

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",
# ]
# ///

wfouche avatar Apr 13 '25 18:04 wfouche

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

Image

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()

wfouche avatar Apr 14 '25 08:04 wfouche

@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
}

wfouche avatar Apr 14 '25 21:04 wfouche

Wow :) didn't think you would jump to support the official pep. Very nice! Will definitely take a look :)

maxandersen avatar Apr 15 '25 09:04 maxandersen

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.

wfouche avatar Apr 16 '25 10:04 wfouche

Would it make sense to port to java so could be part of jbang to bootstrap?

maxandersen avatar Apr 16 '25 11:04 maxandersen

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?)

quintesse avatar Apr 16 '25 14:04 quintesse

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 = [
#   "......",
# ]
# ///

wfouche avatar Apr 16 '25 17:04 wfouche

This is how the current Python 3 (file jython-run.py) implementation works.

  1. Python file scriptname.py is translated to a temporary Java file called scriptname_py.java and written to disk.
  2. scriptname_py.java is a valid JBang Java script file with the required //DEPS and //JAVA entries.
  3. scriptname_py.java also contains the contents of file scriptname.py encoded as a Base64 Java String.
  4. jython-run.py spawns a process to execute command "jbang run scriptname_py.java param1 param2 ..."
  5. 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.
  6. Once scriptname_py.java exits, jython-run.py deletes the temporary scriptname_py.java file and terminates.
  7. 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.

wfouche avatar Apr 16 '25 17:04 wfouche

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!

wfouche avatar Apr 17 '25 08:04 wfouche

I found out that the dependency for Graal Python must be specified as:

  • org.graalvm.polyglot:python:24.2.1@pom

wfouche avatar Apr 17 '25 08:04 wfouche

Jbang doesnt (yet) do pom deps - only done as managed. You'll need to list the deps in the pom explicitly

maxandersen avatar Apr 17 '25 08:04 maxandersen

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

wfouche avatar Apr 17 '25 08:04 wfouche

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
}

wfouche avatar Apr 17 '25 09:04 wfouche

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.

wfouche avatar Apr 17 '25 14:04 wfouche

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.

wfouche avatar Apr 17 '25 17:04 wfouche

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.

maxandersen avatar Apr 17 '25 20:04 maxandersen

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.

wfouche avatar Apr 18 '25 09:04 wfouche

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
}

wfouche avatar Apr 19 '25 04:04 wfouche

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
}

wfouche avatar Apr 19 '25 11:04 wfouche