This is an automated email from the ASF dual-hosted git repository.
zclll pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new e8bc244aa56 [Enhancement](pyudf) add helper command to show more py
info (#60751)
e8bc244aa56 is described below
commit e8bc244aa5675067e68663c9e47cf17432f8cf32
Author: linrrarity <[email protected]>
AuthorDate: Thu Feb 26 17:11:23 2026 +0800
[Enhancement](pyudf) add helper command to show more py info (#60751)
doc: https://github.com/apache/doris-website/pull/3402
Add the auxiliary commands `SHOW PYTHON VERSIONS` and `SHOW PYTHON
PACKAGES IN '<VERSION>'` to display more PythonUDF-related information.
```sql
Doris> show python versions;
+---------+---------+---------+---------------------------------------------+--------------------------------------------------------+
| Version | EnvName | EnvType | BasePath
| ExecutablePath |
+---------+---------+---------+---------------------------------------------+--------------------------------------------------------+
| 3.9.18 | py39 | conda | /mnt/disk7/linzhenqi/miniconda3/envs/py39
| /mnt/disk7/linzhenqi/miniconda3/envs/py39/bin/python |
| 3.8.10 | py3810 | conda | /mnt/disk7/linzhenqi/miniconda3/envs/py3810
| /mnt/disk7/linzhenqi/miniconda3/envs/py3810/bin/python |
| 3.12.11 | py312 | conda | /mnt/disk7/linzhenqi/miniconda3/envs/py312
| /mnt/disk7/linzhenqi/miniconda3/envs/py312/bin/python |
+---------+---------+---------+---------------------------------------------+--------------------------------------------------------+
3 rows in set (0.02 sec)
Doris> show python packages in '3.9.18';
+-----------------+-------------+
| Package | Version |
+-----------------+-------------+
| pyarrow | 21.0.0 |
| Bottleneck | 1.4.2 |
| jieba | 0.42.1 |
| six | 1.17.0 |
| wheel | 0.45.1 |
| python-dateutil | 2.9.0.post0 |
| tzdata | 2025.3 |
| setuptools | 80.9.0 |
| numpy | 2.0.1 |
| psutil | 7.0.0 |
| pandas | 2.3.3 |
| mkl_random | 1.2.8 |
| pip | 25.3 |
| snownlp | 0.12.3 |
| pytz | 2025.2 |
| mkl_fft | 1.3.11 |
| mkl-service | 2.4.0 |
| numexpr | 2.10.1 |
+-----------------+-------------+
```
---
be/src/service/backend_service.cpp | 16 ++
be/src/service/backend_service.h | 5 +
be/src/udf/python/python_env.cpp | 96 +++++++++++
be/src/udf/python/python_env.h | 18 ++
be/test/udf/python/python_env_test.cpp | 102 ++++++++++++
.../antlr4/org/apache/doris/nereids/DorisLexer.g4 | 3 +
.../antlr4/org/apache/doris/nereids/DorisParser.g4 | 5 +
.../doris/nereids/parser/LogicalPlanBuilder.java | 13 ++
.../apache/doris/nereids/trees/plans/PlanType.java | 2 +
.../plans/commands/ShowPythonPackagesCommand.java | 183 +++++++++++++++++++++
.../plans/commands/ShowPythonVersionsCommand.java | 140 ++++++++++++++++
.../trees/plans/visitor/CommandVisitor.java | 10 ++
.../org/apache/doris/common/GenericPoolTest.java | 12 ++
.../apache/doris/utframe/MockedBackendFactory.java | 12 ++
gensrc/thrift/BackendService.thrift | 19 +++
.../show/test_show_python_packages_command.groovy | 55 +++++++
.../show/test_show_python_versions_command.groovy | 59 +++++++
17 files changed, 750 insertions(+)
diff --git a/be/src/service/backend_service.cpp
b/be/src/service/backend_service.cpp
index dd71e2c92b2..240707aca70 100644
--- a/be/src/service/backend_service.cpp
+++ b/be/src/service/backend_service.cpp
@@ -71,6 +71,7 @@
#include "runtime/routine_load/routine_load_task_executor.h"
#include "runtime/stream_load/stream_load_context.h"
#include "runtime/stream_load/stream_load_recorder.h"
+#include "udf/python/python_env.h"
#include "util/arrow/row_batch.h"
#include "util/defer_op.h"
#include "util/runtime_profile.h"
@@ -1311,5 +1312,20 @@ void
BaseBackendService::test_storage_connectivity(TTestStorageConnectivityRespo
response.__set_status(status.to_thrift());
}
+void BaseBackendService::get_python_envs(std::vector<TPythonEnvInfo>& result) {
+ result = PythonVersionManager::instance().env_infos_to_thrift();
+}
+
+void BaseBackendService::get_python_packages(std::vector<TPythonPackageInfo>&
result,
+ const std::string&
python_version) {
+ PythonVersion version;
+ auto& manager = PythonVersionManager::instance();
+ THROW_IF_ERROR(manager.get_version(python_version, &version));
+
+ std::vector<std::pair<std::string, std::string>> packages;
+ THROW_IF_ERROR(list_installed_packages(version, &packages));
+ result = manager.package_infos_to_thrift(packages);
+}
+
#include "common/compile_check_end.h"
} // namespace doris
diff --git a/be/src/service/backend_service.h b/be/src/service/backend_service.h
index 6c43f69170d..4796412ef5f 100644
--- a/be/src/service/backend_service.h
+++ b/be/src/service/backend_service.h
@@ -122,6 +122,11 @@ public:
void test_storage_connectivity(TTestStorageConnectivityResponse& response,
const TTestStorageConnectivityRequest&
request) override;
+ void get_python_envs(std::vector<TPythonEnvInfo>& result) override;
+
+ void get_python_packages(std::vector<TPythonPackageInfo>& result,
+ const std::string& python_version) override;
+
////////////////////////////////////////////////////////////////////////////
// begin cloud backend functions
////////////////////////////////////////////////////////////////////////////
diff --git a/be/src/udf/python/python_env.cpp b/be/src/udf/python/python_env.cpp
index 2d122a235f1..4b26b29614c 100644
--- a/be/src/udf/python/python_env.cpp
+++ b/be/src/udf/python/python_env.cpp
@@ -18,6 +18,7 @@
#include "python_env.h"
#include <fmt/core.h>
+#include <rapidjson/document.h>
#include <filesystem>
#include <memory>
@@ -25,6 +26,7 @@
#include <vector>
#include "common/status.h"
+#include "gen_cpp/BackendService_types.h"
#include "udf/python/python_server.h"
#include "util/string_util.h"
@@ -32,6 +34,16 @@ namespace doris {
namespace fs = std::filesystem;
+static std::string _python_env_type_to_string(PythonEnvType env_type) {
+ switch (env_type) {
+ case PythonEnvType::CONDA:
+ return "conda";
+ case PythonEnvType::VENV:
+ return "venv";
+ }
+ return "unknown";
+}
+
// extract python version by executing `python --version` and extract "3.9.16"
from "Python 3.9.16"
// @param python_path: path to python executable, e.g.
"/opt/miniconda3/envs/myenv/bin/python"
// @param version: extracted python version, e.g. "3.9.16"
@@ -288,4 +300,88 @@ Status PythonVersionManager::init(PythonEnvType env_type,
const fs::path& python
return Status::OK();
}
+std::vector<TPythonEnvInfo> PythonVersionManager::env_infos_to_thrift() const {
+ std::vector<TPythonEnvInfo> infos;
+ const auto& envs = _env_scanner->get_envs();
+ infos.reserve(envs.size());
+
+ const auto env_type_str =
_python_env_type_to_string(_env_scanner->env_type());
+ for (const auto& env : envs) {
+ TPythonEnvInfo info;
+ info.__set_env_name(env.env_name);
+ info.__set_full_version(env.python_version.full_version);
+ info.__set_env_type(env_type_str);
+ info.__set_base_path(env.python_version.base_path);
+ info.__set_executable_path(env.python_version.executable_path);
+ infos.emplace_back(std::move(info));
+ }
+
+ return infos;
+}
+
+std::vector<TPythonPackageInfo> PythonVersionManager::package_infos_to_thrift(
+ const std::vector<std::pair<std::string, std::string>>& packages)
const {
+ std::vector<TPythonPackageInfo> infos;
+ infos.reserve(packages.size());
+ for (const auto& [name, ver] : packages) {
+ TPythonPackageInfo info;
+ info.__set_package_name(name);
+ info.__set_version(ver);
+ infos.emplace_back(std::move(info));
+ }
+ return infos;
+}
+
+Status list_installed_packages(const PythonVersion& version,
+ std::vector<std::pair<std::string,
std::string>>* packages) {
+ DCHECK(packages != nullptr);
+ if (!version.is_valid()) {
+ return Status::InvalidArgument("Invalid python version: {}",
version.to_string());
+ }
+
+ // Run pip list --format=json to get installed packages
+ std::string cmd =
+ fmt::format("\"{}\" -m pip list --format=json 2>/dev/null",
version.executable_path);
+ FILE* pipe = popen(cmd.c_str(), "r");
+ if (!pipe) {
+ return Status::InternalError("Failed to run pip list for python
version: {}",
+ version.full_version);
+ }
+
+ std::string result;
+ char buf[4096];
+ while (fgets(buf, sizeof(buf), pipe)) {
+ result += buf;
+ }
+ int ret = pclose(pipe);
+ if (ret != 0) {
+ return Status::InternalError(
+ "pip list failed for python version: {}, exit code: {},
output: {}",
+ version.full_version, ret, result);
+ }
+
+ // Parse JSON output: [{"name": "pkg", "version": "1.0"}, ...]
+ // Simple JSON parsing without external library
+ // Each entry looks like: {"name": "package_name", "version": "1.2.3"}
+ rapidjson::Document doc;
+ if (doc.Parse(result.data(), result.size()).HasParseError() ||
!doc.IsArray()) [[unlikely]] {
+ return Status::InternalError("Failed to parse pip list json output for
python version: {}",
+ version.full_version);
+ }
+
+ packages->reserve(packages->size() + doc.Size());
+ for (const auto& item : doc.GetArray()) {
+ auto name_it = item.FindMember("name");
+ auto version_it = item.FindMember("version");
+ if (name_it == item.MemberEnd() || version_it == item.MemberEnd() ||
+ !name_it->value.IsString() || !version_it->value.IsString())
[[unlikely]] {
+ return Status::InternalError("Invalid pip list json format for
python version: {}",
+ version.full_version);
+ }
+ packages->emplace_back(name_it->value.GetString(),
version_it->value.GetString());
+ }
+
+ return Status::OK();
+}
+
} // namespace doris
diff --git a/be/src/udf/python/python_env.h b/be/src/udf/python/python_env.h
index 4d3a5acca60..6fb148ab234 100644
--- a/be/src/udf/python/python_env.h
+++ b/be/src/udf/python/python_env.h
@@ -18,8 +18,10 @@
#pragma once
#include <filesystem>
+#include <utility>
#include "common/status.h"
+#include "gen_cpp/BackendService_types.h"
namespace doris {
@@ -90,6 +92,8 @@ public:
Status get_version(const std::string& runtime_version, PythonVersion*
version) const;
+ const std::vector<PythonEnvironment>& get_envs() const { return _envs; }
+
std::string root_path() const { return _env_root_path.string(); }
virtual PythonEnvType env_type() const = 0;
@@ -146,12 +150,26 @@ public:
return _env_scanner->get_version(runtime_version, version);
}
+ const std::vector<PythonEnvironment>& get_envs() const { return
_env_scanner->get_envs(); }
+
+ PythonEnvType env_type() const { return _env_scanner->env_type(); }
+
std::string to_string() const { return _env_scanner->to_string(); }
+ std::vector<TPythonEnvInfo> env_infos_to_thrift() const;
+
+ std::vector<TPythonPackageInfo> package_infos_to_thrift(
+ const std::vector<std::pair<std::string, std::string>>& packages)
const;
+
private:
std::unique_ptr<PythonEnvScanner> _env_scanner;
};
+// List installed pip packages for a given Python version.
+// Returns pairs of (package_name, version).
+Status list_installed_packages(const PythonVersion& version,
+ std::vector<std::pair<std::string,
std::string>>* packages);
+
} // namespace doris
namespace std {
diff --git a/be/test/udf/python/python_env_test.cpp
b/be/test/udf/python/python_env_test.cpp
index ab1494867cd..0ccbb63cf1d 100644
--- a/be/test/udf/python/python_env_test.cpp
+++ b/be/test/udf/python/python_env_test.cpp
@@ -19,6 +19,7 @@
#include <gtest/gtest.h>
+#include <csignal>
#include <filesystem>
#include <fstream>
#include <string>
@@ -28,17 +29,41 @@ namespace doris {
namespace fs = std::filesystem;
+static PythonVersion create_fake_python_version_for_pip_list(const
std::string& base_path,
+ const
std::string& script_body,
+ const
std::string& full_version) {
+ const std::string bin_path = base_path + "/bin";
+ const std::string exec_path = bin_path + "/python3";
+ fs::create_directories(bin_path);
+
+ {
+ std::ofstream ofs(exec_path);
+ ofs << "#!/bin/bash\n";
+ ofs << script_body;
+ }
+ fs::permissions(exec_path, fs::perms::owner_all);
+
+ return PythonVersion(full_version, base_path, exec_path);
+}
+
class PythonEnvTest : public ::testing::Test {
protected:
std::string test_dir_;
+ // Some test frameworks set SIGCHLD to SIG_IGN,
+ // which causes pclose() to get ECHILD because the kernel auto-reaps
children.
+ // We reset SIGCHLD to SIG_DFL for the duration of each test to mimic
production
+ // behaviour, and restore the original handler afterwards.
+ sighandler_t old_sigchld_ = SIG_DFL;
void SetUp() override {
test_dir_ = fs::temp_directory_path().string() + "/python_env_test_" +
std::to_string(getpid()) + "_" + std::to_string(rand());
fs::create_directories(test_dir_);
+ old_sigchld_ = signal(SIGCHLD, SIG_DFL);
}
void TearDown() override {
+ signal(SIGCHLD, old_sigchld_);
if (!test_dir_.empty() && fs::exists(test_dir_)) {
fs::remove_all(test_dir_);
}
@@ -601,4 +626,81 @@ TEST_F(PythonEnvTest,
PythonVersionManagerInitCondaSuccess) {
EXPECT_TRUE(status.ok()) << status.to_string();
}
+// ============================================================================
+// list_installed_packages tests
+// ============================================================================
+
+TEST_F(PythonEnvTest, ListInstalledPackagesInvalidPythonVersion) {
+ PythonVersion invalid_version;
+ std::vector<std::pair<std::string, std::string>> packages;
+
+ Status status = list_installed_packages(invalid_version, &packages);
+ EXPECT_FALSE(status.ok());
+ EXPECT_TRUE(status.to_string().find("Invalid python version") !=
std::string::npos);
+}
+
+TEST_F(PythonEnvTest, ListInstalledPackagesPipListExitNonZero) {
+ PythonVersion version = create_fake_python_version_for_pip_list(
+ test_dir_ + "/pip_nonzero",
+ "echo '[{\"name\":\"numpy\",\"version\":\"1.26.0\"}]'\n"
+ "exit 1\n",
+ "3.9.16");
+ std::vector<std::pair<std::string, std::string>> packages;
+
+ Status status = list_installed_packages(version, &packages);
+ EXPECT_FALSE(status.ok());
+ EXPECT_TRUE(status.to_string().find("pip list failed") !=
std::string::npos);
+}
+
+TEST_F(PythonEnvTest, ListInstalledPackagesParseError) {
+ PythonVersion version = create_fake_python_version_for_pip_list(
+ test_dir_ + "/pip_parse_error", "echo 'not-json'\nexit 0\n",
"3.9.16");
+ std::vector<std::pair<std::string, std::string>> packages;
+
+ Status status = list_installed_packages(version, &packages);
+ EXPECT_FALSE(status.ok());
+ EXPECT_TRUE(status.to_string().find("Failed to parse pip list json
output") !=
+ std::string::npos);
+}
+
+TEST_F(PythonEnvTest, ListInstalledPackagesJsonIsNotArray) {
+ PythonVersion version = create_fake_python_version_for_pip_list(
+ test_dir_ + "/pip_not_array", "echo '{\"name\":\"numpy\"}'\nexit
0\n", "3.9.16");
+ std::vector<std::pair<std::string, std::string>> packages;
+
+ Status status = list_installed_packages(version, &packages);
+ EXPECT_FALSE(status.ok());
+ EXPECT_TRUE(status.to_string().find("Failed to parse pip list json
output") !=
+ std::string::npos);
+}
+
+TEST_F(PythonEnvTest, ListInstalledPackagesInvalidJsonItemFormat) {
+ PythonVersion version = create_fake_python_version_for_pip_list(
+ test_dir_ + "/pip_invalid_item", "echo
'[{\"name\":\"numpy\"}]'\nexit 0\n", "3.9.16");
+ std::vector<std::pair<std::string, std::string>> packages;
+
+ Status status = list_installed_packages(version, &packages);
+ EXPECT_FALSE(status.ok());
+ EXPECT_TRUE(status.to_string().find("Invalid pip list json format") !=
std::string::npos);
+}
+
+TEST_F(PythonEnvTest, ListInstalledPackagesSuccess) {
+ PythonVersion version = create_fake_python_version_for_pip_list(
+ test_dir_ + "/pip_success",
+ "echo "
+
"'[{\"name\":\"numpy\",\"version\":\"1.26.0\"},{\"name\":\"pandas\",\"version\":\"2.2."
+ "0\"}]'\n"
+ "exit 0\n",
+ "3.9.16");
+ std::vector<std::pair<std::string, std::string>> packages;
+
+ Status status = list_installed_packages(version, &packages);
+ EXPECT_TRUE(status.ok()) << status.to_string();
+ ASSERT_EQ(packages.size(), 2);
+ EXPECT_EQ(packages[0].first, "numpy");
+ EXPECT_EQ(packages[0].second, "1.26.0");
+ EXPECT_EQ(packages[1].first, "pandas");
+ EXPECT_EQ(packages[1].second, "2.2.0");
+}
+
} // namespace doris
diff --git a/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisLexer.g4
b/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisLexer.g4
index 12a73beb2c5..0b2b5bf1ef7 100644
--- a/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisLexer.g4
+++ b/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisLexer.g4
@@ -414,6 +414,7 @@ PARAMETER: 'PARAMETER';
PARSED: 'PARSED';
PARTITION: 'PARTITION';
PARTITIONS: 'PARTITIONS';
+PACKAGES: 'PACKAGES';
PASSWORD: 'PASSWORD';
PASSWORD_EXPIRE: 'PASSWORD_EXPIRE';
PASSWORD_HISTORY: 'PASSWORD_HISTORY';
@@ -431,6 +432,7 @@ PLAN: 'PLAN';
PLAY: 'PLAY';
PRIVILEGES: 'PRIVILEGES';
PROCESS: 'PROCESS';
+PYTHON: 'PYTHON';
PLUGIN: 'PLUGIN';
PLUGINS: 'PLUGINS';
POLICY: 'POLICY';
@@ -603,6 +605,7 @@ VAULT: 'VAULT';
VAULTS: 'VAULTS';
VERBOSE: 'VERBOSE';
VERSION: 'VERSION';
+VERSIONS: 'VERSIONS';
VIEW: 'VIEW';
VIEWS: 'VIEWS';
WARM: 'WARM';
diff --git a/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4
b/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4
index 4276eaf55b4..535fc479716 100644
--- a/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4
+++ b/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4
@@ -475,6 +475,8 @@ supportedShowStatement
(FROM |IN) tableName=multipartIdentifier
((FROM | IN) database=multipartIdentifier)?
#showIndex
| SHOW WARM UP JOB wildWhere?
#showWarmUpJob
+ | SHOW PYTHON VERSIONS
#showPythonVersions
+ | SHOW PYTHON PACKAGES IN STRING_LITERAL
#showPythonPackages
;
supportedLoadStatement
@@ -2190,6 +2192,7 @@ nonReserved
| PASSWORD_LOCK_TIME
| PASSWORD_REUSE
| PARTITIONS
+ | PACKAGES
| PATH
| PAUSE
| PERCENT
@@ -2209,6 +2212,7 @@ nonReserved
| PROFILE
| PROPERTIES
| PROPERTY
+ | PYTHON
| QUANTILE_STATE
| QUANTILE_UNION
| QUARTER
@@ -2315,6 +2319,7 @@ nonReserved
| VAULTS
| VERBOSE
| VERSION
+ | VERSIONS
| VIEW
| VIEWS
| WARM
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
index 005bac3c01b..afc3131b109 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
@@ -836,6 +836,8 @@ import
org.apache.doris.nereids.trees.plans.commands.ShowPluginsCommand;
import org.apache.doris.nereids.trees.plans.commands.ShowPrivilegesCommand;
import org.apache.doris.nereids.trees.plans.commands.ShowProcCommand;
import org.apache.doris.nereids.trees.plans.commands.ShowProcessListCommand;
+import org.apache.doris.nereids.trees.plans.commands.ShowPythonPackagesCommand;
+import org.apache.doris.nereids.trees.plans.commands.ShowPythonVersionsCommand;
import org.apache.doris.nereids.trees.plans.commands.ShowQueryProfileCommand;
import org.apache.doris.nereids.trees.plans.commands.ShowQueryStatsCommand;
import
org.apache.doris.nereids.trees.plans.commands.ShowQueuedAnalyzeJobsCommand;
@@ -6513,6 +6515,17 @@ public class LogicalPlanBuilder extends
DorisParserBaseVisitor<Object> {
return new ShowTrashCommand();
}
+ @Override
+ public LogicalPlan
visitShowPythonVersions(DorisParser.ShowPythonVersionsContext ctx) {
+ return new ShowPythonVersionsCommand();
+ }
+
+ @Override
+ public LogicalPlan
visitShowPythonPackages(DorisParser.ShowPythonPackagesContext ctx) {
+ String version = stripQuotes(ctx.STRING_LITERAL().getText());
+ return new ShowPythonPackagesCommand(version);
+ }
+
@Override
public LogicalPlan visitAdminCleanTrash(DorisParser.AdminCleanTrashContext
ctx) {
if (ctx.ON() != null) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java
index 4d071c838dc..dcff41a3175 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java
@@ -314,6 +314,8 @@ public enum PlanType {
SHOW_PROC_COMMAND,
SHOW_PLUGINS_COMMAND,
SHOW_PRIVILEGES_COMMAND,
+ SHOW_PYTHON_PACKAGES_COMMAND,
+ SHOW_PYTHON_VERSIONS_COMMAND,
SHOW_REPLICA_DISTRIBUTION_COMMAND,
SHOW_REPLICA_STATUS_COMMAND,
SHOW_REPOSITORIES_COMMAND,
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowPythonPackagesCommand.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowPythonPackagesCommand.java
new file mode 100644
index 00000000000..f4b5ad8f99a
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowPythonPackagesCommand.java
@@ -0,0 +1,183 @@
+// 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.
+
+package org.apache.doris.nereids.trees.plans.commands;
+
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.ScalarType;
+import org.apache.doris.common.ClientPool;
+import org.apache.doris.common.ErrorCode;
+import org.apache.doris.common.ErrorReport;
+import org.apache.doris.mysql.privilege.PrivPredicate;
+import org.apache.doris.nereids.trees.plans.PlanType;
+import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.ShowResultSet;
+import org.apache.doris.qe.ShowResultSetMetaData;
+import org.apache.doris.qe.StmtExecutor;
+import org.apache.doris.system.Backend;
+import org.apache.doris.thrift.BackendService;
+import org.apache.doris.thrift.TNetworkAddress;
+import org.apache.doris.thrift.TPythonPackageInfo;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * SHOW PYTHON PACKAGES IN '<version>' command.
+ * Shows pip packages installed for the given Python version, collected from
all alive BEs.
+ */
+public class ShowPythonPackagesCommand extends ShowCommand {
+ private static final Logger LOG =
LogManager.getLogger(ShowPythonPackagesCommand.class);
+
+ private static final String[] TITLE_NAMES = {"Package", "Version"};
+ private static final String[] TITLE_NAMES_INCONSISTENT = {"Package",
"Version", "Consistent", "Backends"};
+
+ private final String pythonVersion;
+
+ public ShowPythonPackagesCommand(String pythonVersion) {
+ super(PlanType.SHOW_PYTHON_PACKAGES_COMMAND);
+ this.pythonVersion = pythonVersion;
+ }
+
+ public String getPythonVersion() {
+ return pythonVersion;
+ }
+
+ @Override
+ public ShowResultSetMetaData getMetaData() {
+ return getMetaData(true);
+ }
+
+ private ShowResultSetMetaData getMetaData(boolean consistent) {
+ ShowResultSetMetaData.Builder builder =
ShowResultSetMetaData.builder();
+ String[] titles = consistent ? TITLE_NAMES : TITLE_NAMES_INCONSISTENT;
+ for (String title : titles) {
+ builder.addColumn(new Column(title,
ScalarType.createVarchar(256)));
+ }
+ return builder.build();
+ }
+
+ @Override
+ public <R, C> R accept(PlanVisitor<R, C> visitor, C context) {
+ return visitor.visitShowPythonPackagesCommand(this, context);
+ }
+
+ @Override
+ public ShowResultSet doRun(ConnectContext ctx, StmtExecutor executor)
throws Exception {
+ if
(!Env.getCurrentEnv().getAccessManager().checkGlobalPriv(ConnectContext.get(),
+ PrivPredicate.ADMIN)) {
+
ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
"ADMIN");
+ }
+
+ ImmutableMap<Long, Backend> backendsInfo =
Env.getCurrentSystemInfo().getAllBackendsByAllCluster();
+
+ // Collect packages from each alive BE, tracking which BE each result
came from
+ List<Map<String, String>> allBePackages = new ArrayList<>();
+ List<String> beIdentifiers = new ArrayList<>();
+ for (Backend backend : backendsInfo.values()) {
+ if (!backend.isAlive()) {
+ continue;
+ }
+ BackendService.Client client = null;
+ TNetworkAddress address = null;
+ boolean ok = false;
+ try {
+ address = new TNetworkAddress(backend.getHost(),
backend.getBePort());
+ client = ClientPool.backendPool.borrowObject(address);
+ List<TPythonPackageInfo> packages =
client.getPythonPackages(pythonVersion);
+ ok = true;
+
+ Map<String, String> pkgMap = new HashMap<>();
+ for (TPythonPackageInfo pkg : packages) {
+ pkgMap.put(pkg.getPackageName(), pkg.getVersion());
+ }
+ allBePackages.add(pkgMap);
+ beIdentifiers.add(backend.getHost() + ":" +
backend.getBePort());
+ } catch (Exception e) {
+ LOG.warn("Failed to get python packages from backend[{}]",
backend.getId(), e);
+ } finally {
+ if (ok) {
+ ClientPool.backendPool.returnObject(address, client);
+ } else {
+ ClientPool.backendPool.invalidateObject(address, client);
+ }
+ }
+ }
+
+ if (allBePackages.isEmpty()) {
+ return new ShowResultSet(getMetaData(), Lists.newArrayList());
+ }
+
+ // Check consistency across BEs
+ boolean consistent = true;
+ Map<String, String> referencePackages = allBePackages.get(0);
+ for (int i = 1; i < allBePackages.size(); i++) {
+ if (!referencePackages.equals(allBePackages.get(i))) {
+ consistent = false;
+ break;
+ }
+ }
+
+ List<List<String>> rows = Lists.newArrayList();
+ if (consistent) {
+ for (Map.Entry<String, String> entry :
referencePackages.entrySet()) {
+ List<String> row = new ArrayList<>();
+ row.add(entry.getKey());
+ row.add(entry.getValue());
+ rows.add(row);
+ }
+ } else {
+ // For each package+version, collect which BEs have it
+ // key: pkgName -> (version -> list of BE identifiers)
+ Map<String, Map<String, List<String>>> pkgVersionBes = new
HashMap<>();
+ for (int i = 0; i < allBePackages.size(); i++) {
+ String beId = beIdentifiers.get(i);
+ for (Map.Entry<String, String> entry :
allBePackages.get(i).entrySet()) {
+ pkgVersionBes.computeIfAbsent(entry.getKey(), k -> new
HashMap<>())
+ .computeIfAbsent(entry.getValue(), k -> new
ArrayList<>())
+ .add(beId);
+ }
+ }
+ int totalBes = allBePackages.size();
+ for (Map.Entry<String, Map<String, List<String>>> entry :
pkgVersionBes.entrySet()) {
+ String pkgName = entry.getKey();
+ Map<String, List<String>> versionBes = entry.getValue();
+ boolean pkgConsistent = versionBes.size() == 1
+ && versionBes.values().iterator().next().size() ==
totalBes;
+ for (Map.Entry<String, List<String>> vb :
versionBes.entrySet()) {
+ List<String> row = new ArrayList<>();
+ row.add(pkgName);
+ row.add(vb.getKey());
+ row.add(pkgConsistent ? "Yes" : "No");
+ row.add(pkgConsistent ? "" : String.join(", ",
vb.getValue()));
+ rows.add(row);
+ }
+ }
+ }
+
+ return new ShowResultSet(getMetaData(consistent), rows);
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowPythonVersionsCommand.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowPythonVersionsCommand.java
new file mode 100644
index 00000000000..16fab957bce
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowPythonVersionsCommand.java
@@ -0,0 +1,140 @@
+// 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.
+
+package org.apache.doris.nereids.trees.plans.commands;
+
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.ScalarType;
+import org.apache.doris.common.ClientPool;
+import org.apache.doris.common.ErrorCode;
+import org.apache.doris.common.ErrorReport;
+import org.apache.doris.mysql.privilege.PrivPredicate;
+import org.apache.doris.nereids.trees.plans.PlanType;
+import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.ShowResultSet;
+import org.apache.doris.qe.ShowResultSetMetaData;
+import org.apache.doris.qe.StmtExecutor;
+import org.apache.doris.system.Backend;
+import org.apache.doris.thrift.BackendService;
+import org.apache.doris.thrift.TNetworkAddress;
+import org.apache.doris.thrift.TPythonEnvInfo;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * SHOW PYTHON VERSIONS command.
+ * Shows Python versions available on all alive backends (intersection).
+ */
+public class ShowPythonVersionsCommand extends ShowCommand {
+ private static final Logger LOG =
LogManager.getLogger(ShowPythonVersionsCommand.class);
+
+ private static final String[] TITLE_NAMES = {
+ "Version", "EnvName", "EnvType", "BasePath", "ExecutablePath"
+ };
+
+ public ShowPythonVersionsCommand() {
+ super(PlanType.SHOW_PYTHON_VERSIONS_COMMAND);
+ }
+
+ @Override
+ public ShowResultSetMetaData getMetaData() {
+ ShowResultSetMetaData.Builder builder =
ShowResultSetMetaData.builder();
+ for (String title : TITLE_NAMES) {
+ builder.addColumn(new Column(title,
ScalarType.createVarchar(256)));
+ }
+ return builder.build();
+ }
+
+ @Override
+ public <R, C> R accept(PlanVisitor<R, C> visitor, C context) {
+ return visitor.visitShowPythonVersionsCommand(this, context);
+ }
+
+ @Override
+ public ShowResultSet doRun(ConnectContext ctx, StmtExecutor executor)
throws Exception {
+ if
(!Env.getCurrentEnv().getAccessManager().checkGlobalPriv(ConnectContext.get(),
+ PrivPredicate.ADMIN)) {
+
ErrorReport.reportAnalysisException(ErrorCode.ERR_SPECIFIC_ACCESS_DENIED_ERROR,
"ADMIN");
+ }
+
+ ImmutableMap<Long, Backend> backendsInfo =
Env.getCurrentSystemInfo().getAllBackendsByAllCluster();
+ List<List<TPythonEnvInfo>> allBeEnvs = new ArrayList<>();
+ Set<String> commonVersions = null;
+
+ for (Backend backend : backendsInfo.values()) {
+ if (!backend.isAlive()) {
+ continue;
+ }
+ BackendService.Client client = null;
+ TNetworkAddress address = null;
+ boolean ok = false;
+ try {
+ address = new TNetworkAddress(backend.getHost(),
backend.getBePort());
+ client = ClientPool.backendPool.borrowObject(address);
+ List<TPythonEnvInfo> envs = client.getPythonEnvs();
+ ok = true;
+
+ allBeEnvs.add(envs);
+ Set<String> versions = new HashSet<>();
+ for (TPythonEnvInfo env : envs) {
+ versions.add(env.getFullVersion());
+ }
+ if (commonVersions == null) {
+ commonVersions = versions;
+ } else {
+ commonVersions.retainAll(versions);
+ }
+ } catch (Exception e) {
+ LOG.warn("Failed to get python envs from backend[{}]",
backend.getId(), e);
+ } finally {
+ if (ok) {
+ ClientPool.backendPool.returnObject(address, client);
+ } else {
+ ClientPool.backendPool.invalidateObject(address, client);
+ }
+ }
+ }
+
+ List<List<String>> rows = Lists.newArrayList();
+ if (commonVersions != null && !allBeEnvs.isEmpty()) {
+ // Use envs from the first BE as reference, filtered to common
versions
+ for (TPythonEnvInfo env : allBeEnvs.get(0)) {
+ if (commonVersions.contains(env.getFullVersion())) {
+ List<String> row = new ArrayList<>();
+ row.add(env.getFullVersion());
+ row.add(env.getEnvName());
+ row.add(env.getEnvType());
+ row.add(env.getBasePath());
+ row.add(env.getExecutablePath());
+ rows.add(row);
+ }
+ }
+ }
+
+ return new ShowResultSet(getMetaData(), rows);
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/CommandVisitor.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/CommandVisitor.java
index 743dd9dbbb5..0f1625c629b 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/CommandVisitor.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/CommandVisitor.java
@@ -231,6 +231,8 @@ import
org.apache.doris.nereids.trees.plans.commands.ShowPluginsCommand;
import org.apache.doris.nereids.trees.plans.commands.ShowPrivilegesCommand;
import org.apache.doris.nereids.trees.plans.commands.ShowProcCommand;
import org.apache.doris.nereids.trees.plans.commands.ShowProcessListCommand;
+import org.apache.doris.nereids.trees.plans.commands.ShowPythonPackagesCommand;
+import org.apache.doris.nereids.trees.plans.commands.ShowPythonVersionsCommand;
import org.apache.doris.nereids.trees.plans.commands.ShowQueryProfileCommand;
import org.apache.doris.nereids.trees.plans.commands.ShowQueryStatsCommand;
import
org.apache.doris.nereids.trees.plans.commands.ShowQueuedAnalyzeJobsCommand;
@@ -717,6 +719,14 @@ public interface CommandVisitor<R, C> {
return visitCommand(showPluginsCommand, context);
}
+ default R visitShowPythonVersionsCommand(ShowPythonVersionsCommand
showPythonVersionsCommand, C context) {
+ return visitCommand(showPythonVersionsCommand, context);
+ }
+
+ default R visitShowPythonPackagesCommand(ShowPythonPackagesCommand
showPythonPackagesCommand, C context) {
+ return visitCommand(showPythonPackagesCommand, context);
+ }
+
default R visitShowTrashCommand(ShowTrashCommand showTrashCommand, C
context) {
return visitCommand(showTrashCommand, context);
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/common/GenericPoolTest.java
b/fe/fe-core/src/test/java/org/apache/doris/common/GenericPoolTest.java
index e761f07a90e..dd5e4984e71 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/common/GenericPoolTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/common/GenericPoolTest.java
@@ -35,6 +35,8 @@ import org.apache.doris.thrift.TIngestBinlogResult;
import org.apache.doris.thrift.TNetworkAddress;
import org.apache.doris.thrift.TPublishTopicRequest;
import org.apache.doris.thrift.TPublishTopicResult;
+import org.apache.doris.thrift.TPythonEnvInfo;
+import org.apache.doris.thrift.TPythonPackageInfo;
import org.apache.doris.thrift.TQueryIngestBinlogRequest;
import org.apache.doris.thrift.TQueryIngestBinlogResult;
import org.apache.doris.thrift.TRoutineLoadTask;
@@ -246,6 +248,16 @@ public class GenericPoolTest {
org.apache.doris.thrift.TTestStorageConnectivityRequest
request) throws TException {
return null;
}
+
+ @Override
+ public List<TPythonEnvInfo> getPythonEnvs() throws TException {
+ return null;
+ }
+
+ @Override
+ public List<TPythonPackageInfo> getPythonPackages(String
pythonVersion) throws TException {
+ return null;
+ }
}
@Test
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/utframe/MockedBackendFactory.java
b/fe/fe-core/src/test/java/org/apache/doris/utframe/MockedBackendFactory.java
index 0d4a109921a..5c20a2b64f6 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/utframe/MockedBackendFactory.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/utframe/MockedBackendFactory.java
@@ -55,6 +55,8 @@ import org.apache.doris.thrift.TMasterInfo;
import org.apache.doris.thrift.TNetworkAddress;
import org.apache.doris.thrift.TPublishTopicRequest;
import org.apache.doris.thrift.TPublishTopicResult;
+import org.apache.doris.thrift.TPythonEnvInfo;
+import org.apache.doris.thrift.TPythonPackageInfo;
import org.apache.doris.thrift.TQueryIngestBinlogRequest;
import org.apache.doris.thrift.TQueryIngestBinlogResult;
import org.apache.doris.thrift.TRoutineLoadTask;
@@ -478,6 +480,16 @@ public class MockedBackendFactory {
response.setStatus(new TStatus(TStatusCode.OK));
return response;
}
+
+ @Override
+ public java.util.List<TPythonEnvInfo> getPythonEnvs() throws
TException {
+ return java.util.Collections.emptyList();
+ }
+
+ @Override
+ public java.util.List<TPythonPackageInfo> getPythonPackages(String
pythonVersion) throws TException {
+ return java.util.Collections.emptyList();
+ }
}
// The default Brpc service.
diff --git a/gensrc/thrift/BackendService.thrift
b/gensrc/thrift/BackendService.thrift
index 684f2404fcb..44c1e7cd94c 100644
--- a/gensrc/thrift/BackendService.thrift
+++ b/gensrc/thrift/BackendService.thrift
@@ -378,6 +378,19 @@ struct TTestStorageConnectivityResponse {
1: optional Status.TStatus status;
}
+struct TPythonEnvInfo {
+ 1: optional string env_name // e.g. "myenv"
+ 2: optional string full_version // e.g. "3.9.16"
+ 3: optional string env_type // "conda" or "venv"
+ 4: optional string base_path // e.g. "/opt/miniconda3/envs/myenv"
+ 5: optional string executable_path
+}
+
+struct TPythonPackageInfo {
+ 1: optional string package_name
+ 2: optional string version
+}
+
service BackendService {
AgentService.TAgentResult
submit_tasks(1:list<AgentService.TAgentTaskRequest> tasks);
@@ -431,4 +444,10 @@ service BackendService {
// Test storage connectivity (S3, HDFS, etc.)
TTestStorageConnectivityResponse
test_storage_connectivity(1:TTestStorageConnectivityRequest request);
+
+ // Get Python environments available on this BE
+ list<TPythonEnvInfo> get_python_envs();
+
+ // Get installed pip packages for a specific Python version
+ list<TPythonPackageInfo> get_python_packages(1:string python_version);
}
diff --git
a/regression-test/suites/nereids_p0/show/test_show_python_packages_command.groovy
b/regression-test/suites/nereids_p0/show/test_show_python_packages_command.groovy
new file mode 100644
index 00000000000..0dbbc2ca958
--- /dev/null
+++
b/regression-test/suites/nereids_p0/show/test_show_python_packages_command.groovy
@@ -0,0 +1,55 @@
+// 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.
+
+suite('test_show_python_packages_command') {
+ // get available Python versions so we can pick one for the packages test
+ def versions = sql 'SHOW PYTHON VERSIONS'
+ if (versions.size() == 0) {
+ return
+ }
+ def testVersion = versions[0][0]
+
+ // Execute SHOW PYTHON PACKAGES IN '<version>'
+ def result = sql "SHOW PYTHON PACKAGES IN '${testVersion}'"
+
+ // There should be at least some packages installed
+ assertTrue(result.size() > 0,
+ "Expected at least some packages for Python ${testVersion}")
+
+ // Collect all package names for later assertions
+ def packageNames = [] as Set
+ for (row in result) {
+ // Package name (column 0) should be non-empty
+ def pkgName = row[0]
+ assertNotNull(pkgName)
+ assertTrue(pkgName.length() > 0, 'Package name should not be empty')
+
+ // Package version (column 1) should be non-empty
+ def pkgVersion = row[1]
+ assertNotNull(pkgVersion)
+ assertTrue(pkgVersion.length() > 0,
+ "Package version for '${pkgName}' should not be empty")
+
+ packageNames.add(pkgName.toLowerCase())
+ }
+
+ // Verify that essential packages (pyarrow and pandas) are present
+ assertTrue(packageNames.contains('pyarrow'),
+ "pyarrow should be installed, found packages: ${packageNames}")
+ assertTrue(packageNames.contains('pandas'),
+ "pandas should be installed, found packages: ${packageNames}")
+}
diff --git
a/regression-test/suites/nereids_p0/show/test_show_python_versions_command.groovy
b/regression-test/suites/nereids_p0/show/test_show_python_versions_command.groovy
new file mode 100644
index 00000000000..dc322ea6b6b
--- /dev/null
+++
b/regression-test/suites/nereids_p0/show/test_show_python_versions_command.groovy
@@ -0,0 +1,59 @@
+// 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.
+
+suite('test_show_python_versions_command') {
+ def result = sql 'SHOW PYTHON VERSIONS'
+
+ if (result.size() > 0) {
+ // Verify column structure: [Version, EnvName, EnvType, BasePath,
ExecutablePath]
+ for (row in result) {
+ // Version (column 0) should be a non-empty version string like
"3.9.16"
+ def version = row[0]
+ assertNotNull(version)
+ assertTrue(version.length() > 0, 'Version should not be empty')
+ assertTrue(version ==~ /\d+\.\d+(\.\d+)?/,
+ "Version '${version}' should match pattern x.y or x.y.z")
+
+ // EnvName (column 1) should be non-empty
+ def envName = row[1]
+ assertNotNull(envName)
+ assertTrue(envName.length() > 0, 'EnvName should not be empty')
+
+ // EnvType (column 2) must be either "conda" or "venv"
+ def envType = row[2]
+ assertTrue(envType == 'conda' || envType == 'venv',
+ "EnvType '${envType}' should be 'conda' or 'venv'")
+
+ // BasePath (column 3) should be a non-empty path
+ def basePath = row[3]
+ assertNotNull(basePath)
+ assertTrue(basePath.length() > 0, 'BasePath should not be empty')
+ assertTrue(basePath.startsWith('/'),
+ "BasePath '${basePath}' should be an absolute path")
+
+ // ExecutablePath (column 4) should be a non-empty path
+ def execPath = row[4]
+ assertNotNull(execPath)
+ assertTrue(execPath.length() > 0, 'ExecutablePath should not be
empty')
+ assertTrue(execPath.startsWith('/'),
+ "ExecutablePath '${execPath}' should be an absolute path")
+ }
+
+ // Verify uniqueness of versions in the result
+ result.collect { it[0] } as Set
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]