Skip to content

Commit 28af43b

Browse files
tlambert03ctrueden
authored andcommitted
feat: add auto-fetch with cjdk
1 parent aebbfd9 commit 28af43b

File tree

5 files changed

+149
-2
lines changed

5 files changed

+149
-2
lines changed

.github/workflows/build.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ jobs:
2626
'3.10',
2727
'3.12'
2828
]
29+
java-version: ['11']
30+
include:
31+
# one test without java to test cjdk fallback
32+
- os: ubuntu-latest
33+
python-version: '3.12'
34+
java-version: ''
35+
2936

3037
steps:
3138
- uses: actions/checkout@v2
@@ -35,8 +42,9 @@ jobs:
3542
python-version: ${{matrix.python-version}}
3643

3744
- uses: actions/setup-java@v3
45+
if: matrix.java-version != ''
3846
with:
39-
java-version: '11'
47+
java-version: ${{matrix.java-version}}
4048
distribution: 'zulu'
4149
cache: 'maven'
4250

dev-environment.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,6 @@ dependencies:
3737
# Project from source
3838
- pip
3939
- pip:
40+
- cjdk
4041
- git+https://github.com/ninia/jep.git@cfca63f8b3398daa6d2685428660dc4b2bfab67d
4142
- -e .

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ dependencies = [
3939

4040
[project.optional-dependencies]
4141
# NB: Keep this in sync with dev-environment.yml!
42+
cjdk = ["cjdk"]
4243
dev = [
44+
"scyjava[cjdk]",
4345
"assertpy",
4446
"build",
4547
"jep",

src/scyjava/_cjdk_fetch.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import os
5+
from typing import TYPE_CHECKING
6+
7+
if TYPE_CHECKING:
8+
from pathlib import Path
9+
10+
_logger = logging.getLogger(__name__)
11+
_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
12+
_DEFAULT_MAVEN_SHA = "a555254d6b53d267965a3404ecb14e53c3827c09c3b94b5678835887ab404556bfaf78dcfe03ba76fa2508649dca8531c74bca4d5846513522404d48e8c4ac8b" # noqa: E501
13+
_DEFAULT_JAVA_VENDOR = "zulu-jre"
14+
_DEFAULT_JAVA_VERSION = "11"
15+
16+
17+
def cjdk_fetch_java(
18+
vendor: str = "", version: str = "", raise_on_error: bool = True
19+
) -> None:
20+
"""Fetch java using cjdk and add it to the PATH."""
21+
try:
22+
import cjdk
23+
except ImportError as e:
24+
if raise_on_error is True:
25+
raise ImportError(
26+
"No JVM found. Please install `cjdk` to use the fetch_java feature."
27+
) from e
28+
_logger.info("cjdk is not installed. Skipping automatic fetching of java.")
29+
return
30+
31+
if not vendor:
32+
vendor = os.getenv("JAVA_VENDOR", _DEFAULT_JAVA_VENDOR)
33+
version = os.getenv("JAVA_VERSION", _DEFAULT_JAVA_VERSION)
34+
35+
_logger.info(f"No JVM found, fetching {vendor}:{version} using cjdk...")
36+
home = cjdk.java_home(vendor=vendor, version=version)
37+
_add_to_path(str(home / "bin"))
38+
os.environ["JAVA_HOME"] = str(home)
39+
40+
41+
def cjdk_fetch_maven(url: str = "", sha: str = "", raise_on_error: bool = True) -> None:
42+
"""Fetch Maven using cjdk and add it to the PATH."""
43+
try:
44+
import cjdk
45+
except ImportError as e:
46+
if raise_on_error is True:
47+
raise ImportError(
48+
"Please install `cjdk` to use the fetch_java feature."
49+
) from e
50+
_logger.info("cjdk is not installed. Skipping automatic fetching of Maven.")
51+
return
52+
53+
# if url was passed as an argument, or env_var, use it with provided sha
54+
# otherwise, use default values for both
55+
if url := url or os.getenv("MAVEN_URL", ""):
56+
sha = sha or os.getenv("MAVEN_SHA", "")
57+
else:
58+
url = _DEFAULT_MAVEN_URL
59+
sha = _DEFAULT_MAVEN_SHA
60+
61+
# fix urls to have proper prefix for cjdk
62+
if url.startswith("http"):
63+
if url.endswith(".tar.gz"):
64+
url = url.replace("http", "tgz+http")
65+
elif url.endswith(".zip"):
66+
url = url.replace("http", "zip+http")
67+
68+
# determine sha type based on length (cjdk requires specifying sha type)
69+
# assuming hex-encoded SHA, length should be 40, 64, or 128
70+
kwargs = {}
71+
if sha_len := len(sha): # empty sha is fine... we just don't pass it
72+
sha_lengths = {40: "sha1", 64: "sha256", 128: "sha512"}
73+
if sha_len not in sha_lengths:
74+
raise ValueError(
75+
"MAVEN_SHA be a valid sha1, sha256, or sha512 hash."
76+
f"Got invalid SHA length: {sha_len}. "
77+
)
78+
kwargs = {sha_lengths[sha_len]: sha}
79+
80+
maven_dir = cjdk.cache_package("Maven", url, **kwargs)
81+
if maven_bin := next(maven_dir.rglob("apache-maven-*/**/mvn"), None):
82+
_add_to_path(maven_bin.parent, front=True)
83+
else:
84+
raise RuntimeError("Failed to find Maven executable in the downloaded package.")
85+
86+
87+
def _add_to_path(path: Path | str, front: bool = False) -> None:
88+
"""Add a path to the PATH environment variable.
89+
90+
If front is True, the path is added to the front of the PATH.
91+
By default, the path is added to the end of the PATH.
92+
If the path is already in the PATH, it is not added again.
93+
"""
94+
95+
current_path = os.environ.get("PATH", "")
96+
if (path := str(path)) in current_path:
97+
return
98+
new_path = [path, current_path] if front else [current_path, path]
99+
os.environ["PATH"] = os.pathsep.join(new_path)

src/scyjava/_jvm.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import logging
77
import os
88
import re
9+
import shutil
910
import subprocess
1011
import sys
1112
from functools import lru_cache
@@ -16,6 +17,7 @@
1617
import jpype.config
1718
from jgo import jgo
1819

20+
from scyjava._cjdk_fetch import cjdk_fetch_java, cjdk_fetch_maven
1921
import scyjava.config
2022
from scyjava.config import Mode, mode
2123

@@ -106,7 +108,7 @@ def jvm_version() -> str:
106108
return tuple(map(int, m.group(1).split(".")))
107109

108110

109-
def start_jvm(options=None) -> None:
111+
def start_jvm(options=None, *, fetch_java: bool | None = None) -> None:
110112
"""
111113
Explicitly connect to the Java virtual machine (JVM). Only one JVM can
112114
be active; does nothing if the JVM has already been started. Calling
@@ -117,6 +119,13 @@ def start_jvm(options=None) -> None:
117119
:param options:
118120
List of options to pass to the JVM.
119121
For example: ['-Dfoo=bar', '-XX:+UnlockExperimentalVMOptions']
122+
:param fetch_java:
123+
Whether to automatically fetch a JRE (and/or maven) using
124+
[`cjdk`](https://github.com/cachedjdk/cjdk) if java and maven executables are
125+
not found. Requires `cjdk` to be installed. See README for details.
126+
- If `None` (default), then fetching will only occur if `cjdk` is available.
127+
- If `True`, an exception will be raised if `cjdk` is not available.
128+
- If `False`, no attempt to import `cjdk` is be made.
120129
"""
121130
# if JVM is already running -- break
122131
if jvm_started():
@@ -132,8 +141,14 @@ def start_jvm(options=None) -> None:
132141
# use the logger to notify user that endpoints are being added
133142
_logger.debug("Adding jars from endpoints {0}".format(endpoints))
134143

144+
if fetch_java is not False and not is_jvm_available():
145+
cjdk_fetch_java(raise_on_error=fetch_java is True)
146+
135147
# get endpoints and add to JPype class path
136148
if len(endpoints) > 0:
149+
if not shutil.which("mvn") and fetch_java is not False:
150+
cjdk_fetch_maven(raise_on_error=fetch_java is True)
151+
137152
endpoints = endpoints[:1] + sorted(endpoints[1:])
138153
_logger.debug("Using endpoints %s", endpoints)
139154
_, workspace = jgo.resolve_dependencies(
@@ -340,6 +355,28 @@ def is_jvm_headless() -> bool:
340355
return bool(GraphicsEnvironment.isHeadless())
341356

342357

358+
def is_jvm_available() -> bool:
359+
"""
360+
Return True if the JVM is available, suppressing stderr on macos.
361+
"""
362+
from unittest.mock import patch
363+
364+
subprocess_check_output = subprocess.check_output
365+
366+
def _silent_check_output(*args, **kwargs):
367+
# also suppress stderr on calls to subprocess.check_output
368+
kwargs.setdefault("stderr", subprocess.DEVNULL)
369+
return subprocess_check_output(*args, **kwargs)
370+
371+
try:
372+
with patch.object(subprocess, "check_output", new=_silent_check_output):
373+
jpype.getDefaultJVMPath()
374+
# on Darwin, may raise a CalledProcessError when invoking `/user/libexec/java_home`
375+
except (jpype.JVMNotFoundException, subprocess.CalledProcessError):
376+
return False
377+
return True
378+
379+
343380
def is_awt_initialized() -> bool:
344381
"""
345382
Return true iff the AWT subsystem has been initialized.

0 commit comments

Comments
 (0)