Stateful Python, embedded with Jep, in Kotlin scripts
xemantic-kotlin-script runs Kotlin snippets in stateful sessions, with diagnostics an LLM can act on. Some tasks are better served by the Python ecosystem (numpy, pandas, scikit-learn, …), so this project embeds a CPython interpreter into a Kotlin script session, keeping the Python state between the snippets as well.
Jep is a native CPython extension:
the Java library comes from Maven Central,
but its native part comes with the jep Python package, which has to be installed in the Python environment in use:
pip install jep==4.3.2The version of the jep Python package must match the version of the org.ninia:jep Java library.
Building it requires a JDK, found through JAVA_HOME.
The Python executable is, in this order:
PythonEnvironment.pythonExecutable, if set before the first use ofPython,- the one of the virtual environment of the
VIRTUAL_ENVenvironment variable, python3on thePATH.
The Python code imports the modules from the same paths as this executable,
e.g. the site-packages of a virtual environment, but not the ones of its base Python.
In build.gradle.kts add:
dependencies {
implementation("com.xemantic.kotlin:xemantic-kotlin-script-python:0.1.0")
}Then:
Python().use { python ->
ScriptSession {
"python" provides python
}.use { session ->
session.execute("""python.exec("import numpy as np; a = np.arange(10)")""")
session.execute("""python["factor"] = 3""")
val result = session.execute("""python.eval<List<Long>>("(a * factor).tolist()")""")
println((result as ScriptResult.Value).value) // [0, 3, 6, 9, 12, 15, 18, 21, 24, 27]
}
}The examples assume numpy installed in the Python environment,
otherwise the code can declare it as a dependency, see below.
- A
Pythonis stateful: the global variables, functions, classes and imports of the executed code stay visible to the code executed after it, also across the snippets of aScriptSession. exec(code)executes Python code,eval<T>(expression)evaluates an expression converted toT, andpython["name"]reads or writes a global variable. The values are converted by Jep, e.g. a Pythonintbecomes aLong, astraString, and alistaList.python(code)executes Python code like a cell of a Jupyter notebook, returning the value of its last statement, if it is an expression, ornullotherwise, so that the code written by an LLM can either provide a result, or be executed only for its effects:The value is converted by Jep to its default Java type, and a value which Jep cannot convert is returned as its Pythonpython("import numpy as np; a = np.arange(10)") // null python("a.sum()") // 45L
repr.- For an AI agent, the
Pythoncan be started lazily, only when a script uses it, as the first one initializes the Python environment:Withclass Agency : AutoCloseable { private val lazyPython = lazy { Python() } val python: Python by lazyPython override fun close() { if (lazyPython.isInitialized()) python.close() } }
"agency" provides agency, a script can then callagency.python("..."), or setagency.python["name"] = value. - Each
Pythonhas its own global variables, while the imported modules are shared by all of them, so that native extension modules, likenumpy, can be used. - A Jep interpreter can be used only by the thread which created it,
so each
Pythonruns its code on a thread of its own, and can be called from any thread. - A
ScriptSessiondoes not close its dependencies, close thePythonwhen no longer needed. - The code passed to
exec, orpython(code), can declare its dependencies, like a@file:DependsOnof a Kotlin snippet, with inline script metadata, as withuv run:The missing dependencies are installed before executing the code, with uv if it is on the# /// script # dependencies = ["rdkit"] # /// from rdkit import Chem
PATH, or withpipof the Python executable otherwise, intoPythonEnvironment.dependencyDirectory, by default the directory of thexemantic.kotlin.script.python.dependencyDirectorysystem property, if set, or otherwise~/.cache/xemantic-kotlin-script-python/<python>-<hash of the environment>, which becomes importable once any code declares its dependencies. The installed dependencies are shared by all thePythoninterpreters of the JVM, and they cannot change the versions of the distributions of the Python environment itself, nor the ones already imported from the dependency directory, as a module cannot be replaced once imported. A dependency which cannot be installed fails the call withPythonDependencyException, with the output of the installer. - A Python exception fails the call with
jep.JepException, reported by theScriptSessionas anExecutionPhase.EVALUATIONerror, like:jep.JepException: <class 'ValueError'>: boom at <string>.<module>(<string>:1)
The tests run in the .venv Python virtual environment of the project,
created by Gradle with python3, with only the jep package of the version of the library installed,
like a clean environment of an agent.
The tests declare the Python packages they use, like numpy, as dependencies,
installed from PyPI into build/python-dependencies, which Gradle cleans before every test run,
so the tests need network access.
See DEVELOPMENT.md for the documentation conventions, and for maintenance notes on updating the gradle wrapper and the project dependencies.