diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b246a0..a6dbb29 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: @@ -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 @@ -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' diff --git a/bin/test.sh b/bin/test.sh index da27567..db00333 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -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 diff --git a/dev-environment.yml b/dev-environment.yml index d8d2568..bbacab4 100644 --- a/dev-environment.yml +++ b/dev-environment.yml @@ -18,7 +18,7 @@ name: scyjava-dev channels: - conda-forge dependencies: - - python >= 3.8 + - python >= 3.9 # Project dependencies - jpype1 >= 1.3.0 - jgo @@ -37,5 +37,6 @@ dependencies: # Project from source - pip - pip: + - cjdk - git+https://github.com/ninia/jep.git@cfca63f8b3398daa6d2685428660dc4b2bfab67d - -e . diff --git a/environment.yml b/environment.yml index c1038c5..d3e3af9 100644 --- a/environment.yml +++ b/environment.yml @@ -19,7 +19,7 @@ name: scyjava channels: - conda-forge dependencies: - - python >= 3.8 + - python >= 3.9 # Project dependencies - jpype1 >= 1.3.0 - jgo diff --git a/pyproject.toml b/pyproject.toml index 1332f1a..8da9830 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] @@ -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", @@ -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] diff --git a/src/scyjava/_cjdk_fetch.py b/src/scyjava/_cjdk_fetch.py new file mode 100644 index 0000000..27c8035 --- /dev/null +++ b/src/scyjava/_cjdk_fetch.py @@ -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) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 1a6c5ca..2035e34 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -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 @@ -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(): @@ -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:])