Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ on:

jobs:
build-cross-platform:
name: test ${{matrix.os}} - ${{matrix.python-version}}
name: test ${{matrix.os}} - ${{matrix.python-version}} - ${{matrix.java-version}}
runs-on: ${{ matrix.os }}
strategy:
matrix:
Expand All @@ -22,10 +22,15 @@ jobs:
macos-latest
]
python-version: [
'3.8',
'3.10',
'3.12'
'3.9',
'3.13'
]
java-version: ['11']
include:
# one test without java to test cjdk fallback
- os: ubuntu-latest
python-version: '3.9'
java-version: ''

steps:
- uses: actions/checkout@v2
Expand All @@ -35,8 +40,9 @@ jobs:
python-version: ${{matrix.python-version}}

- uses: actions/setup-java@v3
if: matrix.java-version != ''
with:
java-version: '11'
java-version: ${{matrix.java-version}}
distribution: 'zulu'
cache: 'maven'

Expand Down
7 changes: 6 additions & 1 deletion bin/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ then
else
argString=""
fi
if [ "$(uname -s)" = "Darwin" ]
if ! java -version 2>&1 | grep -q '^openjdk version "\(1\.8\|9\|10\|11\|12\|13\|14\|15\|16\)\.'
then
echo "Skipping jep tests due to unsupported Java version:"
java -version || true
jepCode=0
elif [ "$(uname -s)" = "Darwin" ]
then
echo "Skipping jep tests on macOS due to flakiness"
jepCode=0
Expand Down
3 changes: 2 additions & 1 deletion dev-environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ name: scyjava-dev
channels:
- conda-forge
dependencies:
- python >= 3.8
- python >= 3.9
# Project dependencies
- jpype1 >= 1.3.0
- jgo
Expand All @@ -37,5 +37,6 @@ dependencies:
# Project from source
- pip
- pip:
- cjdk
- git+https://github.com/ninia/jep.git@cfca63f8b3398daa6d2685428660dc4b2bfab67d
- -e .
2 changes: 1 addition & 1 deletion environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ name: scyjava
channels:
- conda-forge
dependencies:
- python >= 3.8
- python >= 3.9
# Project dependencies
- jpype1 >= 1.3.0
- jgo
Expand Down
11 changes: 6 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
[build-system]
requires = ["setuptools>=61.2"]
requires = ["setuptools>=77.0.0"]
build-backend = "setuptools.build_meta"

[project]
name = "scyjava"
version = "1.10.3.dev0"
version = "1.11.0.dev0"
description = "Supercharged Java access from Python"
license = {text = "Unlicense"}
license = "Unlicense"
authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}]
readme = "README.md"
keywords = ["java", "maven", "cross-language"]
Expand All @@ -16,11 +16,11 @@ classifiers = [
"Intended Audience :: Education",
"Intended Audience :: Science/Research",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Operating System :: Microsoft :: Windows",
"Operating System :: Unix",
"Operating System :: MacOS",
Expand All @@ -31,10 +31,11 @@ classifiers = [
]

# NB: Keep this in sync with environment.yml AND dev-environment.yml!
requires-python = ">=3.8"
requires-python = ">=3.9"
dependencies = [
"jpype1 >= 1.3.0",
"jgo",
"cjdk",
]

[project.optional-dependencies]
Expand Down
113 changes: 113 additions & 0 deletions src/scyjava/_cjdk_fetch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from __future__ import annotations

import logging
import os
import shutil
import subprocess
from typing import TYPE_CHECKING, Union

import cjdk
import jpype

if TYPE_CHECKING:
from pathlib import Path

_logger = logging.getLogger(__name__)
_DEFAULT_MAVEN_URL = "tgz+https://dlcdn.apache.org/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz" # noqa: E501
_DEFAULT_MAVEN_SHA = "a555254d6b53d267965a3404ecb14e53c3827c09c3b94b5678835887ab404556bfaf78dcfe03ba76fa2508649dca8531c74bca4d5846513522404d48e8c4ac8b" # noqa: E501
_DEFAULT_JAVA_VENDOR = "zulu-jre"
_DEFAULT_JAVA_VERSION = "11"


def ensure_jvm_available() -> None:
"""Ensure that the JVM is available and Maven is installed."""
if not is_jvm_available():
cjdk_fetch_java()
if not shutil.which("mvn"):
cjdk_fetch_maven()


def is_jvm_available() -> bool:
"""Return True if the JVM is available, suppressing stderr on macos."""
from unittest.mock import patch

subprocess_check_output = subprocess.check_output

def _silent_check_output(*args, **kwargs):
# also suppress stderr on calls to subprocess.check_output
kwargs.setdefault("stderr", subprocess.DEVNULL)
return subprocess_check_output(*args, **kwargs)

try:
with patch.object(subprocess, "check_output", new=_silent_check_output):
jpype.getDefaultJVMPath()
# on Darwin, may raise a CalledProcessError when invoking `/user/libexec/java_home`
except (jpype.JVMNotFoundException, subprocess.CalledProcessError):
return False
return True


def cjdk_fetch_java(vendor: str = "", version: str = "") -> None:
"""Fetch java using cjdk and add it to the PATH."""
if not vendor:
vendor = os.getenv("JAVA_VENDOR", _DEFAULT_JAVA_VENDOR)
version = os.getenv("JAVA_VERSION", _DEFAULT_JAVA_VERSION)

_logger.info(f"No JVM found, fetching {vendor}:{version} using cjdk...")
home = cjdk.java_home(vendor=vendor, version=version)
_add_to_path(str(home / "bin"))
os.environ["JAVA_HOME"] = str(home)


def cjdk_fetch_maven(url: str = "", sha: str = "") -> None:
"""Fetch Maven using cjdk and add it to the PATH."""
# if url was passed as an argument, or env_var, use it with provided sha
# otherwise, use default values for both
if url := url or os.getenv("MAVEN_URL", ""):
sha = sha or os.getenv("MAVEN_SHA", "")
else:
url = _DEFAULT_MAVEN_URL
sha = _DEFAULT_MAVEN_SHA

# fix urls to have proper prefix for cjdk
if url.startswith("http"):
if url.endswith(".tar.gz"):
url = url.replace("http", "tgz+http")
elif url.endswith(".zip"):
url = url.replace("http", "zip+http")

# determine sha type based on length (cjdk requires specifying sha type)
# assuming hex-encoded SHA, length should be 40, 64, or 128
kwargs = {}
if sha_len := len(sha): # empty sha is fine... we just don't pass it
sha_lengths = {40: "sha1", 64: "sha256", 128: "sha512"}
if sha_len not in sha_lengths: # pragma: no cover
raise ValueError(
"MAVEN_SHA be a valid sha1, sha256, or sha512 hash."
f"Got invalid SHA length: {sha_len}. "
)
kwargs = {sha_lengths[sha_len]: sha}

maven_dir = cjdk.cache_package("Maven", url, **kwargs)
if maven_bin := next(maven_dir.rglob("apache-maven-*/**/mvn"), None):
_add_to_path(maven_bin.parent, front=True)
else: # pragma: no cover
raise RuntimeError(
"Failed to find Maven executable on system "
"PATH, and download via cjdk failed."
)


def _add_to_path(path: Union[Path, str], front: bool = False) -> None:
"""Add a path to the PATH environment variable.

If front is True, the path is added to the front of the PATH.
By default, the path is added to the end of the PATH.
If the path is already in the PATH, it is not added again.
"""

current_path = os.environ.get("PATH", "")
if (path := str(path)) in current_path:
return
new_path = [path, current_path] if front else [current_path, path]
os.environ["PATH"] = os.pathsep.join(new_path)
20 changes: 19 additions & 1 deletion src/scyjava/_jvm.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def jvm_version() -> str:
return tuple(map(int, m.group(1).split(".")))


def start_jvm(options=None) -> None:
def start_jvm(options=None, *, fetch_java: bool = True) -> None:
"""
Explicitly connect to the Java virtual machine (JVM). Only one JVM can
be active; does nothing if the JVM has already been started. Calling
Expand All @@ -117,6 +117,19 @@ def start_jvm(options=None) -> None:
:param options:
List of options to pass to the JVM.
For example: ['-Dfoo=bar', '-XX:+UnlockExperimentalVMOptions']
:param fetch_java:
If True (default), when a JVM/or maven cannot be located on the system,
[`cjdk`](https://github.com/cachedjdk/cjdk) will be used to download
a JRE distribution and set up the JVM. The following environment variables
may be used to configure the JRE and Maven distributions to download:
* `JAVA_VENDOR`: The vendor of the JRE distribution to download.
Defaults to "zulu-jre".
* `JAVA_VERSION`: The version of the JRE distribution to download.
Defaults to "11".
* `MAVEN_URL`: The URL of the Maven distribution to download.
Defaults to https://dlcdn.apache.org/maven/maven-3/3.9.9/
* `MAVEN_SHA`: The SHA512 hash of the Maven distribution to download, if
providing a custom MAVEN_URL.
"""
# if JVM is already running -- break
if jvm_started():
Expand All @@ -132,6 +145,11 @@ def start_jvm(options=None) -> None:
# use the logger to notify user that endpoints are being added
_logger.debug("Adding jars from endpoints {0}".format(endpoints))

if fetch_java:
from scyjava._cjdk_fetch import ensure_jvm_available

ensure_jvm_available()

# get endpoints and add to JPype class path
if len(endpoints) > 0:
endpoints = endpoints[:1] + sorted(endpoints[1:])
Expand Down
Loading