This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git
The following commit(s) were added to refs/heads/dev by this push:
new be8a310b25 [Feature][Client] Add Java detection utility to Python SDK
(#12740)
be8a310b25 is described below
commit be8a310b251251e5dc76b9d49c488b06e38c0159
Author: Karthik Thota <[email protected]>
AuthorDate: Fri Oct 16 17:31:14 2026 +0000
[Feature][Client] Add Java detection utility to Python SDK (#12740)
---
.../seatunnel/java_detector.py | 157 ++++++++++++
.../tests/test_java_detector.py | 268 +++++++++++++++++++++
2 files changed, 425 insertions(+)
diff --git a/tools/seatunnel-python-sdk/seatunnel/java_detector.py
b/tools/seatunnel-python-sdk/seatunnel/java_detector.py
new file mode 100644
index 0000000000..7e0e11630a
--- /dev/null
+++ b/tools/seatunnel-python-sdk/seatunnel/java_detector.py
@@ -0,0 +1,157 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+import os
+import re
+import shutil
+import subprocess
+import sys
+from dataclasses import dataclass
+from typing import Optional
+
+
+class JavaDetectionError(Exception):
+ """Raised when Java cannot be found, is too old, or its version cannot be
determined."""
+
+
+@dataclass(frozen=True)
+class JavaInfo:
+ """Holds the resolved Java executable path and its parsed major version."""
+
+ executable: str
+ major_version: int
+
+
+# Matches the quoted version string in `java -version` output, e.g.:
+# java version "1.8.0_292"
+# openjdk version "11.0.2"
+# openjdk version "17-ea"
+_VERSION_RE = re.compile(r'version "(\d+)(?:\.(\d+))?[^"]*"')
+
+_TIMEOUT = 10
+DEFAULT_MIN_JAVA_VERSION = 11
+
+
+def _find_java_executable(min_version: int = DEFAULT_MIN_JAVA_VERSION) ->
Optional[str]:
+ """Return the path to the java binary, or None if not found.
+
+ Resolution order:
+ 1. $JAVA_HOME/bin/java (raises JavaDetectionError if JAVA_HOME is set
but invalid)
+ 2. First 'java' found on PATH (only when JAVA_HOME is unset or empty)
+ """
+ java_home = os.environ.get("JAVA_HOME", "").strip()
+ if java_home:
+ binary = "java.exe" if sys.platform == "win32" else "java"
+ candidate = os.path.join(java_home, "bin", binary)
+ if os.path.isfile(candidate):
+ return candidate
+ raise JavaDetectionError(
+ f"JAVA_HOME is set to '{java_home}' but no java executable was
found there. "
+ f"Fix JAVA_HOME or unset it, or install JDK {min_version}+."
+ )
+ return shutil.which("java")
+
+
+def _parse_major_version(output: str) -> Optional[int]:
+ """Extract the Java major version from `java -version` output.
+
+ Handles old-style "1.X.Y_ZZZ" (returns X) and new-style "X.Y.Z" / "X-ea"
(returns X).
+ Lines starting with "Picked up" are skipped (JVM tool options noise).
+ Returns None if no version line is found.
+ """
+ for line in output.splitlines():
+ if line.strip().startswith("Picked up"):
+ continue
+ match = _VERSION_RE.search(line)
+ if match:
+ first, second = match.group(1), match.group(2)
+ if first == "1" and second is not None:
+ # Old format: "1.8.0_292" → Java 8
+ return int(second)
+ return int(first)
+ return None
+
+
+def detect_java(min_version: int = DEFAULT_MIN_JAVA_VERSION) -> JavaInfo:
+ """Locate a Java executable, verify it meets *min_version*, and return a
:class:`JavaInfo`.
+
+ Resolution order: ``$JAVA_HOME/bin/java`` first, then the first ``java``
on ``PATH``.
+
+ :param min_version: Minimum required Java major version (default 11).
+ :returns: :class:`JavaInfo` with the path and major version.
+ :raises JavaDetectionError: If Java is not found, is below *min_version*,
its version
+ cannot be parsed, or the subprocess call fails.
+ """
+ executable = _find_java_executable(min_version)
+
+ if executable is None:
+ raise JavaDetectionError(
+ f"Java not found. Set JAVA_HOME or install JDK {min_version}+."
+ )
+
+ # Run `java -version`; output goes to stderr by convention, but also check
stdout.
+ try:
+ result = subprocess.run(
+ [executable, "-version"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=_TIMEOUT,
+ )
+ except subprocess.TimeoutExpired as exc:
+ raise JavaDetectionError(
+ f"Timed out running 'java -version' at '{executable}'. "
+ f"Set JAVA_HOME or install JDK {min_version}+."
+ ) from exc
+ except OSError as exc:
+ raise JavaDetectionError(
+ f"Failed to run 'java -version' at '{executable}': {exc}. "
+ f"Set JAVA_HOME or install JDK {min_version}+."
+ ) from exc
+
+ if result.returncode != 0:
+ raw = result.stderr.decode(errors="replace").strip() or
result.stdout.decode(errors="replace").strip()
+ raise JavaDetectionError(
+ f"'java -version' exited with code {result.returncode}: {raw!r}. "
+ f"Set JAVA_HOME or install JDK {min_version}+."
+ )
+
+ stderr_text = result.stderr.decode(errors="replace")
+ stdout_text = result.stdout.decode(errors="replace")
+
+ # Try stderr first (standard), fall back to stdout.
+ major = _parse_major_version(stderr_text) or
_parse_major_version(stdout_text)
+
+ if major is None:
+ raw = (stderr_text + stdout_text).strip()
+ raise JavaDetectionError(
+ f"Could not determine Java version from output: {raw!r}. "
+ f"Set JAVA_HOME or install JDK {min_version}+."
+ )
+
+ if major < min_version:
+ java_home = os.environ.get("JAVA_HOME", "").strip()
+ if java_home:
+ raise JavaDetectionError(
+ f"Java {major} found at '{executable}' (from
JAVA_HOME='{java_home}'), "
+ f"but Java {min_version}+ is required. "
+ f"Fix or unset JAVA_HOME, or install JDK {min_version}+."
+ )
+ raise JavaDetectionError(
+ f"Java {major} found at '{executable}', but Java {min_version}+ is
required. "
+ f"Set JAVA_HOME or install JDK {min_version}+."
+ )
+
+ return JavaInfo(executable=executable, major_version=major)
diff --git a/tools/seatunnel-python-sdk/tests/test_java_detector.py
b/tools/seatunnel-python-sdk/tests/test_java_detector.py
new file mode 100644
index 0000000000..690ec98328
--- /dev/null
+++ b/tools/seatunnel-python-sdk/tests/test_java_detector.py
@@ -0,0 +1,268 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+import os
+import subprocess
+import sys
+import unittest
+from unittest import mock
+
+from seatunnel.java_detector import JavaDetectionError, JavaInfo, detect_java
+
+
+def _make_completed_process(stderr: str = "", stdout: str = "", returncode:
int = 0):
+ """Build a fake CompletedProcess without running a real subprocess."""
+ return subprocess.CompletedProcess(
+ args=["java", "-version"],
+ returncode=returncode,
+ stdout=stdout.encode(),
+ stderr=stderr.encode(),
+ )
+
+
+class TestDetectJava(unittest.TestCase):
+
+ # ------------------------------------------------------------------
helpers
+
+ def _patch_which(self, path):
+ """Patch shutil.which to return *path*."""
+ return mock.patch("seatunnel.java_detector.shutil.which",
return_value=path)
+
+ def _patch_run(self, stderr="", stdout="", returncode=0):
+ """Patch subprocess.run to return a fake CompletedProcess."""
+ return mock.patch(
+ "seatunnel.java_detector.subprocess.run",
+ return_value=_make_completed_process(stderr=stderr, stdout=stdout,
returncode=returncode),
+ )
+
+ def _patch_env(self, java_home=None):
+ """Patch os.environ so JAVA_HOME is set to *java_home* (or absent if
None)."""
+ env = {}
+ if java_home is not None:
+ env["JAVA_HOME"] = java_home
+ return mock.patch.dict("os.environ", env, clear=True)
+
+ # -------------------------------------------------------- version-parsing
cases
+
+ def test_java_8_old_format_raises(self):
+ """'1.8.0_292' must be detected as Java 8 and rejected (< 11)."""
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with self._patch_run(stderr='openjdk version "1.8.0_292"
2021-04-20'):
+ with self.assertRaises(JavaDetectionError) as ctx:
+ detect_java()
+ self.assertIn("Java 8", str(ctx.exception))
+ self.assertIn("Java 11+", str(ctx.exception))
+
+ def test_java_7_old_format_raises(self):
+ """'1.7.0_80' must be detected as Java 7 and rejected."""
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with self._patch_run(stderr='java version "1.7.0_80"'):
+ with self.assertRaises(JavaDetectionError) as ctx:
+ detect_java()
+ self.assertIn("Java 7", str(ctx.exception))
+
+ def test_java_11_on_path_returns_info(self):
+ """Java 11 on PATH returns correct JavaInfo."""
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with self._patch_run(stderr='openjdk version "11.0.2" 2019-01-15'):
+ info = detect_java()
+ self.assertIsInstance(info, JavaInfo)
+ self.assertEqual(11, info.major_version)
+ self.assertEqual("/usr/bin/java", info.executable)
+
+ def test_java_17_via_java_home_returns_info(self):
+ """Java 17 via JAVA_HOME returns correct JavaInfo with JAVA_HOME-based
path."""
+ fake_home = "/opt/jdk17"
+ expected_binary = "java.exe" if sys.platform == "win32" else "java"
+ expected_exe = os.path.join(fake_home, "bin", expected_binary)
+
+ with self._patch_env(java_home=fake_home):
+ with mock.patch("os.path.isfile", return_value=True):
+ with self._patch_run(stderr='openjdk version "17.0.1"
2021-10-19'):
+ info = detect_java()
+
+ self.assertEqual(17, info.major_version)
+ self.assertEqual(expected_exe, info.executable)
+
+ def test_java_17_prerelease_ea(self):
+ """'17-ea' pre-release string must resolve to major version 17."""
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with self._patch_run(stderr='openjdk version "17-ea" 2021-09-14'):
+ info = detect_java()
+ self.assertEqual(17, info.major_version)
+
+ def test_java_21_returns_info(self):
+ """Java 21 returns correct major version."""
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with self._patch_run(stderr='openjdk version "21.0.1" 2023-10-17'):
+ info = detect_java()
+ self.assertEqual(21, info.major_version)
+
+ def test_bare_version_no_patch(self):
+ """'11' with no minor/patch component still resolves correctly."""
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with self._patch_run(stderr='openjdk version "11"'):
+ info = detect_java()
+ self.assertEqual(11, info.major_version)
+
+ def test_picked_up_line_is_ignored(self):
+ """A 'Picked up JAVA_TOOL_OPTIONS' line before the version line must
be ignored."""
+ stderr = (
+ "Picked up JAVA_TOOL_OPTIONS: -Xmx512m\n"
+ 'openjdk version "17.0.1" 2021-10-19\n'
+ "OpenJDK Runtime Environment (build 17.0.1+12)\n"
+ )
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with self._patch_run(stderr=stderr):
+ info = detect_java()
+ self.assertEqual(17, info.major_version)
+
+ # --------------------------------------------------- not-found /
bad-JAVA_HOME cases
+
+ def test_no_java_anywhere_raises(self):
+ """No JAVA_HOME and nothing on PATH must raise with a 'not found'
message."""
+ with self._patch_env(), self._patch_which(None):
+ with self.assertRaises(JavaDetectionError) as ctx:
+ detect_java()
+ self.assertIn("Java not found", str(ctx.exception))
+ self.assertIn("JAVA_HOME", str(ctx.exception))
+
+ def test_java_home_set_but_binary_missing_raises(self):
+ """JAVA_HOME set but binary absent must raise immediately, not fall
back to PATH."""
+ with self._patch_env(java_home="/bad/path"):
+ with mock.patch("os.path.isfile", return_value=False):
+ # shutil.which must NOT be consulted — if it is, the test
would fail
+ with mock.patch("seatunnel.java_detector.shutil.which",
side_effect=AssertionError("should not reach PATH")):
+ with self.assertRaises(JavaDetectionError) as ctx:
+ detect_java()
+ msg = str(ctx.exception)
+ self.assertIn("/bad/path", msg)
+ self.assertIn("JAVA_HOME is set to", msg)
+
+ # ----------------------------------------------- subprocess failure cases
+
+ def test_nonzero_exit_code_raises(self):
+ """Non-zero return code from java -version must raise
JavaDetectionError."""
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with self._patch_run(stderr="Error: JVM crashed", returncode=1):
+ with self.assertRaises(JavaDetectionError) as ctx:
+ detect_java()
+ self.assertIn("exited with code 1", str(ctx.exception))
+
+ def test_unparseable_output_raises(self):
+ """Output with no recognisable version string must raise with 'Could
not determine'."""
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with self._patch_run(stderr="some garbled output"):
+ with self.assertRaises(JavaDetectionError) as ctx:
+ detect_java()
+ self.assertIn("Could not determine Java version", str(ctx.exception))
+
+ def test_timeout_raises(self):
+ """subprocess.TimeoutExpired must be caught and re-raised as
JavaDetectionError."""
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with mock.patch(
+ "seatunnel.java_detector.subprocess.run",
+ side_effect=subprocess.TimeoutExpired(cmd=["java",
"-version"], timeout=10),
+ ):
+ with self.assertRaises(JavaDetectionError) as ctx:
+ detect_java()
+ self.assertIn("Timed out", str(ctx.exception))
+
+ def test_oserror_raises(self):
+ """OSError from subprocess.run must be caught and re-raised as
JavaDetectionError."""
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with mock.patch(
+ "seatunnel.java_detector.subprocess.run",
+ side_effect=OSError("Permission denied"),
+ ):
+ with self.assertRaises(JavaDetectionError) as ctx:
+ detect_java()
+ self.assertIn("Failed to run", str(ctx.exception))
+
+ # ------------------------------------------------- custom min_version
cases
+
+ def test_custom_min_version_java8_accepted(self):
+ """With min_version=8, Java 8 must succeed."""
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with self._patch_run(stderr='java version "1.8.0_292"'):
+ info = detect_java(min_version=8)
+ self.assertEqual(8, info.major_version)
+
+ def test_error_message_uses_min_version_argument(self):
+ """Too-old error must mention the *min_version* argument, not a
hardcoded 11."""
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with self._patch_run(stderr='openjdk version "11.0.2"'):
+ with self.assertRaises(JavaDetectionError) as ctx:
+ detect_java(min_version=17)
+ self.assertIn("Java 17+", str(ctx.exception))
+ self.assertIn("Java 11", str(ctx.exception)) # the found version
+
+ def test_min_version_no_java_anywhere_error_contains_min_version(self):
+ """With min_version=17 and no Java, error must say '17+' and must NOT
say '11+'."""
+ with self._patch_env(), self._patch_which(None):
+ with self.assertRaises(JavaDetectionError) as ctx:
+ detect_java(min_version=17)
+ msg = str(ctx.exception)
+ self.assertIn("17+", msg)
+ self.assertNotIn("11+", msg)
+
+ def test_min_version_bad_java_home_error_contains_min_version(self):
+ """With min_version=17 and a bad JAVA_HOME, error must say '17+'."""
+ with self._patch_env(java_home="/bad/path"):
+ with mock.patch("os.path.isfile", return_value=False):
+ with self.assertRaises(JavaDetectionError) as ctx:
+ detect_java(min_version=17)
+ self.assertIn("17+", str(ctx.exception))
+
+ def test_note_picked_up_jdk_java_options_line_is_ignored(self):
+ """A 'NOTE: Picked up JDK_JAVA_OPTIONS' line with embedded quotes is
skipped."""
+ stderr = (
+ 'NOTE: Picked up JDK_JAVA_OPTIONS: -Dfoo="1"\n'
+ 'openjdk version "17.0.1" 2021-10-19\n'
+ "OpenJDK Runtime Environment (build 17.0.1+12)\n"
+ )
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with self._patch_run(stderr=stderr):
+ info = detect_java()
+ self.assertEqual(17, info.major_version)
+
+ def test_version_in_stdout_only_is_parsed(self):
+ """When stderr is empty but stdout contains the version line, it must
be parsed."""
+ with self._patch_env(), self._patch_which("/usr/bin/java"):
+ with self._patch_run(stderr="", stdout='openjdk version "11.0.2"
2019-01-15'):
+ info = detect_java()
+ self.assertEqual(11, info.major_version)
+
+ def test_java_home_too_old_error_mentions_java_home_value(self):
+ """Java found via JAVA_HOME but too old: error must mention JAVA_HOME
value, 'Java 8', and 'unset JAVA_HOME'."""
+ fake_home = "/opt/jdk8"
+ expected_binary = "java.exe" if sys.platform == "win32" else "java"
+ expected_exe = os.path.join(fake_home, "bin", expected_binary)
+
+ with self._patch_env(java_home=fake_home):
+ with mock.patch("os.path.isfile", return_value=True):
+ with self._patch_run(stderr='java version "1.8.0_292"
2021-04-20'):
+ with self.assertRaises(JavaDetectionError) as ctx:
+ detect_java()
+ msg = str(ctx.exception)
+ self.assertIn(fake_home, msg)
+ self.assertIn("Java 8", msg)
+ self.assertIn(expected_exe, msg)
+ self.assertIn("unset JAVA_HOME", msg)
+
+
+if __name__ == "__main__":
+ unittest.main()