PythonEnvironment.java
package org.egothor.methodatlas.discovery.python;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Detects the Python runtime and records its version for audit logging.
*
* <p>
* At most one detection attempt is made per instance. If {@code python3
* --version} (or {@code python --version} as a fallback) exits with a
* non-zero code or is not found on the {@code PATH}, {@link #isAvailable()}
* returns {@code false} and the Python plugin is disabled gracefully.
* </p>
*
* <h2>Minimum version</h2>
*
* <p>
* Python 3.8 or later is required; {@code ast.Node.end_lineno} — used by
* the scanner script to compute per-function line ranges — was added in
* Python 3.8. When an older version is found the plugin logs a
* {@code WARNING} and disables itself.
* </p>
*
* <h2>Audit trail</h2>
*
* <p>
* The detected Python version string is included in every worker-start log
* line so that audit teams can trace exactly which runtime executed the
* scanner script during any given MethodAtlas run.
* </p>
*/
final class PythonEnvironment {
private static final Logger LOG = Logger.getLogger(PythonEnvironment.class.getName());
/** Minimum major version of Python required by this plugin. */
/* default */ static final int MINIMUM_MAJOR_VERSION = 3;
/** Minimum minor version of Python required by this plugin. */
/* default */ static final int MINIMUM_MINOR_VERSION = 8;
private final boolean available;
private final String versionString;
private final String executableName;
/**
* Detects the Python runtime by running {@code python3 --version}
* (falling back to {@code python --version}).
*
* <p>
* The detection result is logged once at {@code INFO} level when Python
* is found, or at {@code WARNING} level when it is absent or too old.
* </p>
*/
/* default */ PythonEnvironment() {
String[] candidates = { "python3", "python" };
String found = null;
String foundVersion = null;
for (String candidate : candidates) {
String version = detectVersion(candidate);
if (version != null) {
found = candidate;
foundVersion = version;
break;
}
}
if (found == null) {
this.available = false;
this.versionString = "unavailable";
this.executableName = "python3";
if (LOG.isLoggable(Level.WARNING)) {
LOG.warning("Python not found on PATH — Python discovery plugin is disabled. "
+ "Install Python " + MINIMUM_MAJOR_VERSION + "." + MINIMUM_MINOR_VERSION
+ " or later to enable Python scanning.");
}
} else {
this.executableName = found;
this.versionString = foundVersion;
int[] parsed = parseMajorMinor(foundVersion);
boolean meetsRequirement = parsed[0] > MINIMUM_MAJOR_VERSION
|| (parsed[0] == MINIMUM_MAJOR_VERSION && parsed[1] >= MINIMUM_MINOR_VERSION);
if (meetsRequirement) {
this.available = true;
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "Python detected: {0} (executable={1})",
new Object[] { versionString, executableName });
}
} else {
this.available = false;
if (LOG.isLoggable(Level.WARNING)) {
LOG.warning("Python " + foundVersion + " is below the minimum required version "
+ MINIMUM_MAJOR_VERSION + "." + MINIMUM_MINOR_VERSION
+ " — Python discovery plugin is disabled.");
}
}
}
}
/**
* Returns {@code true} when Python of a supported version is on the PATH.
*
* @return {@code true} when the Python plugin can be used
*/
/* default */ boolean isAvailable() {
return available;
}
/**
* Returns the Python version string reported by {@code python3 --version}
* (e.g. {@code "Python 3.11.4"}), or {@code "unavailable"}.
*
* @return version string; never {@code null}
*/
/* default */ String versionString() {
return versionString;
}
/**
* Returns the Python executable name that was detected ({@code "python3"}
* or {@code "python"}).
*
* @return executable name; never {@code null}
*/
/* default */ String executableName() {
return executableName;
}
// ── Private helpers ───────────────────────────────────────────────
@SuppressWarnings("PMD.DoNotUseThreads")
private static String detectVersion(String executable) {
try {
ProcessBuilder pb = new ProcessBuilder(executable, "--version");
pb.redirectErrorStream(true);
Process proc = pb.start();
String output;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
output = reader.readLine();
}
int exitCode = proc.waitFor();
if (exitCode != 0 || output == null || output.isBlank()) {
return null;
}
return output.trim();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} catch (IOException e) {
return null;
}
}
/**
* Parses major and minor version numbers from a Python version string
* such as {@code "Python 3.11.4"}. Returns {@code [0, 0]} on parse failure.
*
* @param version Python version string
* @return two-element array {@code [major, minor]}
*/
/* default */ static int[] parseMajorMinor(String version) {
if (version == null || version.isEmpty()) {
return new int[] { 0, 0 };
}
// Strip optional "Python " prefix
String stripped = version.startsWith("Python ") ? version.substring(7) : version;
try {
int firstDot = stripped.indexOf('.');
if (firstDot < 0) {
return new int[] { Integer.parseInt(stripped.trim()), 0 };
}
int major = Integer.parseInt(stripped.substring(0, firstDot).trim());
int secondDot = stripped.indexOf('.', firstDot + 1);
String minorStr = secondDot < 0
? stripped.substring(firstDot + 1)
: stripped.substring(firstDot + 1, secondDot);
int minor = Integer.parseInt(minorStr.trim());
return new int[] { major, minor };
} catch (NumberFormatException e) {
return new int[] { 0, 0 };
}
}
}