1439 lines
55 KiB
Python
1439 lines
55 KiB
Python
# coding: utf-8
|
||
# -------------------------------------------------------------------
|
||
# YakPanel
|
||
# -------------------------------------------------------------------
|
||
# Copyright (c) 2014-2099 YakPanel(www.yakpanel.com) All rights reserved.
|
||
# -------------------------------------------------------------------
|
||
# Author: yakpanel
|
||
# -------------------------------------------------------------------
|
||
# ------------------------------
|
||
# python virtual environment manager
|
||
# ------------------------------
|
||
import pwd
|
||
import re
|
||
import os
|
||
import json
|
||
import subprocess
|
||
import time
|
||
import sys
|
||
import traceback
|
||
import shutil
|
||
from collections import OrderedDict
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional, Tuple, Callable, Union
|
||
|
||
if "/www/server/panel/class" not in sys.path:
|
||
sys.path.append("/www/server/panel/class")
|
||
|
||
import public
|
||
|
||
_SYS_BIN_PATH = ("/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin")
|
||
|
||
|
||
# 1. set_python_version 设置环境 2. uninstall_py_version 移除或卸载环境
|
||
|
||
|
||
def _run_command_with_call_log(
|
||
cmd: Union[List[str], str], call_log: Optional[Callable[[str], None]] = None, user=''
|
||
) -> Optional[str]:
|
||
if not callable(call_log):
|
||
call_log = lambda x: print(x)
|
||
|
||
if user and user != "root":
|
||
res = pwd.getpwnam(user)
|
||
uid = res.pw_uid
|
||
gid = res.pw_gid
|
||
|
||
def pre_exec_fn():
|
||
os.setgid(gid)
|
||
os.setuid(uid)
|
||
else:
|
||
pre_exec_fn = None
|
||
|
||
# 执行命令
|
||
try:
|
||
popen_cmd: Union[List[str], str]
|
||
if isinstance(cmd, list):
|
||
# list 可执行 + 参数,不需要shell
|
||
popen_cmd = cmd
|
||
use_shell = False
|
||
else:
|
||
# str 可能包含换行
|
||
popen_cmd = ["/bin/bash", "-lc", cmd]
|
||
use_shell = False
|
||
|
||
process = subprocess.Popen(
|
||
popen_cmd,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
shell=use_shell,
|
||
preexec_fn=pre_exec_fn
|
||
)
|
||
|
||
# 实时读取输出
|
||
while True:
|
||
output = process.stdout.readline() # type: ignore
|
||
if output == '' and process.poll() is not None:
|
||
break
|
||
if output:
|
||
call_log(output.strip())
|
||
|
||
# 检查返回码
|
||
if process.returncode != 0:
|
||
error_msg = "excute error: {}".format(process.returncode)
|
||
call_log(error_msg)
|
||
return error_msg
|
||
|
||
return None
|
||
|
||
except Exception as e:
|
||
error_msg = str(e)
|
||
call_log(error_msg)
|
||
return error_msg
|
||
|
||
|
||
def python_manager_path() -> str:
|
||
_p = "/www/server/python_manager"
|
||
if os.path.exists(_p):
|
||
return os.path.realpath(_p)
|
||
return _p
|
||
|
||
|
||
def pyenv_path() -> str:
|
||
_p = "/www/server/pyporject_evn"
|
||
if os.path.exists(_p):
|
||
return os.path.realpath(_p)
|
||
return _p
|
||
|
||
|
||
class PythonEnvironment:
|
||
"""Python环境元数据载体类"""
|
||
_BT_PROJECT_ENV_SHELL = "/www/server/panel/script/btpyprojectenv.sh"
|
||
_bt_etc_pyenv = "/www/server/panel/data/bt_etc_pyenv.sh"
|
||
project_to_pyenv_map_file = "/www/server/panel/data/python_project_name2env.txt"
|
||
default_pip_source = "https://pypi.org/simple/"
|
||
_ONLY_BINARY_NAMES = {
|
||
"numpy", "scipy", "pandas", "cryptography", "pyOpenSSL", "Pillow",
|
||
"opencv-python", "psycopg2-binary", "opencv", "mysqlclient", "lxml", "pyarrow"
|
||
}
|
||
_PYENV_FORMAT_DATA = """
|
||
# Start-Python-Env Command Environment Settings
|
||
if [ -f '/www/server/panel/data/bt_etc_pyenv.sh' ]; then source /www/server/panel/data/bt_etc_pyenv.sh; fi
|
||
# End-Python-Env
|
||
"""
|
||
|
||
@staticmethod
|
||
def return_profile():
|
||
if os.path.exists('/root/.bash_profile'):
|
||
return '/root/.bash_profile'
|
||
if os.path.exists('/root/.bashrc'):
|
||
return '/root/.bashrc'
|
||
fd = open('/root/.bashrc', mode="w", encoding="utf-8")
|
||
fd.close()
|
||
return '/root/.bashrc'
|
||
|
||
# ptah python安装路径
|
||
def __init__(self, bin_path: str, version: str, env_type: str, **kwargs):
|
||
self.bin_path = bin_path
|
||
self.version = version
|
||
self.env_type = env_type # conda venv virtualenv system
|
||
self.conda_path = kwargs.get("conda_path", "")
|
||
self.venv_name = kwargs.get("venv_name", "")
|
||
self.activate_sh = kwargs.get("activate_sh", "")
|
||
self.system_path = kwargs.get("system_path", "")
|
||
self.ps = kwargs.get("ps", "")
|
||
self.site_packages = kwargs.get("site_packages", "") # 应当支持site-packages 为空的情况
|
||
self._pip_source = ""
|
||
if not os.path.isfile(self.project_to_pyenv_map_file):
|
||
public.writeFile(self.project_to_pyenv_map_file, "")
|
||
|
||
def set_pip_source(self, pip_source: str):
|
||
self._pip_source = pip_source
|
||
|
||
def to_dict(self, **kwargs) -> Dict:
|
||
data = {
|
||
"bin_path": self.bin_path,
|
||
"version": self.version,
|
||
"type": self.env_type,
|
||
"conda_path": self.conda_path,
|
||
"venv_name": self.venv_name,
|
||
"activate_sh": self.activate_sh,
|
||
"system_path": self.system_path,
|
||
"ps": self.ps,
|
||
"site_packages": self.site_packages
|
||
}
|
||
for k, v in kwargs.items():
|
||
if k in data:
|
||
continue
|
||
data[k] = v
|
||
return data
|
||
|
||
@classmethod
|
||
def from_dict(cls, data: Dict) -> Optional["PythonEnvironment"]:
|
||
if not isinstance(data, dict):
|
||
return None
|
||
try:
|
||
return cls(
|
||
data["bin_path"],
|
||
data["version"],
|
||
data["type"],
|
||
conda_path=data.get("conda_path"),
|
||
venv_name=data.get("venv_name"),
|
||
activate_sh=data.get("activate_sh"),
|
||
system_path=data.get("system_path"),
|
||
ps=data.get("ps"),
|
||
site_packages=data.get("site_packages"),
|
||
)
|
||
except Exception:
|
||
import traceback
|
||
public.print_log(traceback.format_exc())
|
||
return None
|
||
|
||
def create_venv(self, env_path: str, ps: str = "") -> Optional[str]:
|
||
if self.env_type != "system":
|
||
return public.lang("Please select a system environment to create the virtual environment")
|
||
|
||
# 创建目录
|
||
parent_dir = os.path.dirname(env_path)
|
||
if not os.path.exists(parent_dir):
|
||
try:
|
||
os.makedirs(parent_dir)
|
||
except Exception as e:
|
||
return "Failed to create directory: {}".format(str(e))
|
||
|
||
try:
|
||
subprocess.run(
|
||
[self.bin_path, "-m", "venv", env_path],
|
||
capture_output=True, text=True, check=True
|
||
)
|
||
except Exception as e:
|
||
print("Failed to create virtual environment: {}".format(str(e)))
|
||
return str(e)
|
||
|
||
site_packages = _EnvironmentDetector.get_site_packages(env_path + "/bin/python")
|
||
_ep = EnvironmentReporter()
|
||
_ep.update_report({
|
||
"bin_path": env_path + "/bin/python",
|
||
"version": self.version,
|
||
"type": "venv",
|
||
"conda_path": self.conda_path,
|
||
"venv_name": os.path.basename(env_path),
|
||
"activate_sh": "source {}/bin/activate".format(env_path),
|
||
"system_path": self.bin_path,
|
||
"site_packages": site_packages,
|
||
"ps": ps or os.path.basename(env_path),
|
||
})
|
||
return None
|
||
|
||
def create_venv_sync(self, env_path: str, ps: str = "", call_log: Optional[Callable[[str], None]] = None):
|
||
"""通过call_log函数实时返回创建日志, 应用于websocket场景"""
|
||
if call_log is None:
|
||
call_log = lambda x: print(x)
|
||
|
||
# 执行命令
|
||
res = _run_command_with_call_log([self.bin_path, "-u", "-m", "venv", env_path], call_log)
|
||
if res is not None:
|
||
return res
|
||
|
||
try:
|
||
site_packages = _EnvironmentDetector.get_site_packages(env_path + "/bin/python")
|
||
_ep = EnvironmentReporter()
|
||
_ep.update_report({
|
||
"bin_path": env_path + "/bin/python",
|
||
"version": self.version,
|
||
"type": "venv",
|
||
"conda_path": self.conda_path,
|
||
"venv_name": os.path.basename(env_path),
|
||
"activate_sh": "source {}/bin/activate".format(env_path),
|
||
"system_path": self.bin_path,
|
||
"site_packages": site_packages,
|
||
"ps": ps or os.path.basename(env_path),
|
||
})
|
||
call_log(public.lang("Virtual environment created successfully."))
|
||
return None
|
||
except Exception as e:
|
||
error_msg = f"Failed to update environment configuration: {str(e)}"
|
||
call_log(error_msg)
|
||
return error_msg
|
||
|
||
def _site_pkg_name2bin(self, pkg_name: str) -> Optional[str]:
|
||
"""通过包名查询在site-packages的*-info/RECORD可执行文件的位置 (可用于 pip, gunicorn, uwsgi)"""
|
||
record_file = ""
|
||
name_lower = pkg_name.lower()
|
||
if not self.site_packages or not os.path.exists(self.site_packages):
|
||
self.site_packages = _EnvironmentDetector.get_site_packages(self.bin_path)
|
||
if not self.site_packages or not os.path.exists(self.site_packages):
|
||
return None
|
||
for i in os.listdir(self.site_packages):
|
||
i_lower = i.lower()
|
||
if i.endswith(".dist-info") and i_lower.startswith(name_lower + "-"):
|
||
record_file = os.path.join(self.site_packages, i, "RECORD")
|
||
break
|
||
if not record_file:
|
||
return None
|
||
with open(record_file, "r") as f:
|
||
for line in f:
|
||
tmp = line.split(",")
|
||
if len(tmp) != 3:
|
||
continue
|
||
# 可执行文件的位置不会site-packages在文件夹下
|
||
if name_lower in os.path.basename(tmp[0].lower()) and tmp[0].startswith("../"):
|
||
bin_p = Path(self.site_packages) / tmp[0]
|
||
if bin_p.exists():
|
||
return str(bin_p.resolve())
|
||
return None
|
||
|
||
def uwsgi_bin(self) -> Optional[str]:
|
||
return self._site_pkg_name2bin("uwsgi")
|
||
|
||
def pip_bin(self) -> Optional[str]:
|
||
return self._site_pkg_name2bin("pip")
|
||
|
||
def gunicorn_bin(self) -> Optional[str]:
|
||
return self._site_pkg_name2bin("gunicorn")
|
||
|
||
def activate_shell(self) -> Optional[str]:
|
||
"""获取激活环境的shell命令, venv和conda执行对应的脚本即可,"""
|
||
if self.env_type == "venv":
|
||
res_sh = self.activate_sh
|
||
elif self.env_type == "conda":
|
||
init_sh = "{}/etc/profile.d/conda.sh".format(os.path.dirname(os.path.dirname(self.conda_path)))
|
||
remove_panel_py = (
|
||
"bt_env_deactivate > /dev/null 2>&1 \n"
|
||
"export PATH=$(echo $PATH | tr ':' '\\n' | grep -v '{}' | tr '\\n' ':') \n"
|
||
"export PATH=$(echo $PATH | tr ':' '\\n' | grep -v '{}' | tr '\\n' ':') \n".format(
|
||
python_manager_path(),
|
||
pyenv_path()
|
||
)
|
||
)
|
||
return "{}{} init >/dev/null 2>&1\nsource {}\n{}".format(
|
||
remove_panel_py, self.conda_path, init_sh, self.activate_sh
|
||
)
|
||
elif self.env_type == "system":
|
||
if any((self.bin_path.startswith(i) for i in _SYS_BIN_PATH)):
|
||
res_sh = 'export PATH="{}":"$PATH"'.format(os.path.dirname(self.bin_path))
|
||
else:
|
||
res_sh = "export _BT_PROJECT_ENV={} && source {} NULL \n".format(
|
||
os.path.dirname(os.path.dirname(self.bin_path)),
|
||
self._BT_PROJECT_ENV_SHELL
|
||
)
|
||
else:
|
||
return ""
|
||
|
||
if self.env_type != "conda" and len(CondaDetector().find_conda_executable()) > 0:
|
||
res_sh = """# stop Conda env and clean Conda config
|
||
conda deactivate
|
||
unset CONDA_PREFIX
|
||
unset CONDA_EXE
|
||
unset _CE_CONDA
|
||
unset _CE_M
|
||
export PATH=$(echo $PATH | tr ':' '\\n' | grep -v 'conda' | tr '\\n' ':')
|
||
export LD_LIBRARY_PATH=$(echo $LD_LIBRARY_PATH | tr ':' '\\n' | grep -v 'conda' | tr '\\n' ':')
|
||
""" + res_sh
|
||
|
||
return res_sh + "\n"
|
||
|
||
def _install_pip(self, call_log: Optional[Callable[[str], None]] = None) -> Optional[str]:
|
||
if not callable(call_log):
|
||
call_log = lambda x: print(x)
|
||
sh = self.activate_shell() + "\n" + " ".join([self.bin_path, "-u", "-m", "ensurepip"])
|
||
return _run_command_with_call_log(sh, call_log)
|
||
|
||
def pip_install(self, pkg: str, version: str = "", no_cache: bool = False,
|
||
call_log: Optional[Callable[[str], None]] = None) -> Optional[str]:
|
||
if not self.pip_bin():
|
||
self._install_pip(call_log)
|
||
if not callable(call_log):
|
||
call_log = lambda x: print(x)
|
||
cmd = [self.pip_bin(), "install"]
|
||
if version:
|
||
cmd.append("{}=={}".format(pkg, version))
|
||
else:
|
||
cmd.append(pkg)
|
||
|
||
cmd.append("-i")
|
||
if self._pip_source:
|
||
cmd.append(self._pip_source)
|
||
else:
|
||
cmd.append(self.default_pip_source)
|
||
if no_cache:
|
||
cmd.append("--no-cache-dir")
|
||
|
||
cmd_pip = " ".join(cmd)
|
||
if pkg.lower().find("mysqlclient") != -1:
|
||
mysql_flag = self.build_mysql_env()
|
||
cmd_pip = mysql_flag + cmd_pip
|
||
|
||
cmd = self.activate_shell() + "\n" + cmd_pip
|
||
error_list = set()
|
||
err_msg = "ERROR: Failed building wheel for"
|
||
|
||
def check_error_with_log(output: str) -> None:
|
||
idx = output.find(err_msg)
|
||
if idx != -1:
|
||
pkg_name = output[idx + len(err_msg):].strip().split()[0].strip()
|
||
if pkg_name in self._ONLY_BINARY_NAMES:
|
||
error_list.add(pkg_name)
|
||
return call_log(output)
|
||
public.print_log(f"pip install cmd ==== {cmd}")
|
||
res = _run_command_with_call_log(cmd, check_error_with_log)
|
||
if error_list:
|
||
call_log("Found packages with compilation errors: [{}], trying to install in binary format".format(", ".join(error_list)))
|
||
cmd = cmd + " --only-binary=" + ",".join(error_list)
|
||
return _run_command_with_call_log(cmd, call_log)
|
||
return res
|
||
|
||
@staticmethod
|
||
def build_mysql_env() -> str:
|
||
if os.path.exists("/www/server/mysql"):
|
||
return (
|
||
"export LD_LIBRARY_PATH=\"/www/server/mysql/lib:$LD_LIBRARY_PATH\""
|
||
"export MYSQLCLIENT_CFLAGS='-I/www/server/mysql/include'\n"
|
||
"export MYSQLCLIENT_LDFLAGS='-L/www/server/mysql/lib -lmysqlclient'\n"
|
||
)
|
||
elif os.path.exists("/usr/local/mysql"):
|
||
return (
|
||
"export MYSQLCLIENT_CFLAGS='-I/usr/local/mysql/include'\n"
|
||
"export MYSQLCLIENT_LDFLAGS='-L/usr/local/mysql/lib -lmysqlclient'\n"
|
||
)
|
||
else:
|
||
return ""
|
||
|
||
def pip_uninstall(self, pkg: str, call_log: Optional[Callable[[str], None]] = None) -> Optional[str]:
|
||
if not self.pip_bin():
|
||
return "pip is not installed"
|
||
cmd = self.activate_shell() + "\n" + " ".join([self.pip_bin(), "uninstall", "-y", pkg])
|
||
return _run_command_with_call_log(cmd, call_log)
|
||
|
||
def pip_list(self, force: bool = False) -> List[Tuple[str, str]]:
|
||
if not self.pip_bin():
|
||
return []
|
||
prefix_path = os.path.dirname(os.path.dirname(self.bin_path))
|
||
if not force and not any(self.bin_path.startswith(i) for i in _SYS_BIN_PATH):
|
||
try:
|
||
old_cache = json.loads(public.readFile(os.path.join(prefix_path, "pip_list.cache")))
|
||
mtime = int(os.path.getmtime(self.site_packages))
|
||
if old_cache and int(old_cache.get("mtime", 0)) == mtime:
|
||
return old_cache["data"]
|
||
except:
|
||
pass
|
||
|
||
cmd = self.activate_shell() + "\n" + " ".join([self.pip_bin(), "list"])
|
||
res_out = subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL, shell=True)
|
||
pip_list = []
|
||
for line in res_out.split("\n"):
|
||
try:
|
||
tmp = line.strip().split()
|
||
if len(tmp) >= 2:
|
||
if tmp[0] == "Package":
|
||
continue
|
||
if tmp[0].startswith("-----"):
|
||
continue
|
||
if tmp[1].startswith("("):
|
||
tmp[1] = tmp[1].strip("()")
|
||
pip_list.append((tmp[0], tmp[1]))
|
||
except:
|
||
pass
|
||
mtime = int(os.path.getmtime(self.site_packages))
|
||
public.writeFile(os.path.join(prefix_path, "pip_list.cache"), json.dumps({"mtime": mtime, "data": pip_list}))
|
||
return pip_list
|
||
|
||
def pkg_file_exits(self, pkg_name: str) -> bool:
|
||
pkg_name_lower = pkg_name.lower()
|
||
if not self.site_packages or not os.path.exists(self.site_packages):
|
||
return False
|
||
for name in os.listdir(self.site_packages):
|
||
name_lower = name.lower()
|
||
if name_lower.startswith(pkg_name_lower) and name.endswith(".dist-info"):
|
||
return True
|
||
|
||
return False
|
||
|
||
def init_site_server_pkg(self, call_log: Optional[Callable[[str], None]] = None) -> bool:
|
||
pkg_list = ("uwsgi", "gunicorn", "uvicorn", "hypercorn", "daphne")
|
||
status = True
|
||
for pkg in pkg_list:
|
||
if self.pkg_file_exits(pkg):
|
||
call_log("{} is already installed".format(pkg))
|
||
continue
|
||
call_log(f"Project Using [{pkg}]\n"
|
||
f"Installing [{pkg}] ....")
|
||
if pkg not in [
|
||
i[0] for i in self.pip_list()
|
||
]:
|
||
res = self.pip_install(pkg, call_log=call_log)
|
||
if res:
|
||
status = False
|
||
return status
|
||
|
||
def exec_shell(self, cmd: str, call_log: Optional[Callable[[str], None]] = None, user=""):
|
||
cmd = self.activate_shell() + "\n" + cmd
|
||
public.print_log(f"cmd ==== {cmd}")
|
||
return _run_command_with_call_log(cmd, call_log=call_log, user=user)
|
||
|
||
@classmethod
|
||
def profile_check_use(cls):
|
||
out, _ = public.ExecShell("lsattr {}".format(cls.return_profile()))
|
||
return out.find("--i") == -1
|
||
|
||
@staticmethod
|
||
def _remove_old_python_env():
|
||
try:
|
||
data = public.readFile("/etc/profile")
|
||
rep = re.compile(r'# +Start-Python-Env[^\n]*\n(export +PATH=".*")\n# +End-Python-Env')
|
||
rep2 = re.compile(r'# +Start-Python-Env[^\n]*\nif\s+\[ +-f[^\n]*\n# +End-Python-Env')
|
||
data = rep.sub("", data)
|
||
data = rep2.sub("", data)
|
||
public.writeFile("/etc/profile", data)
|
||
except:
|
||
pass
|
||
|
||
@classmethod
|
||
def set_profile_env(cls, pyenv: Optional["PythonEnvironment"]) -> Optional[str]:
|
||
if pyenv and pyenv.env_type == "conda":
|
||
return ("Conda environment does not support direct setting, "
|
||
"please execute conda activate {} in the command line").format(pyenv.venv_name)
|
||
if not cls.profile_check_use():
|
||
return public.lang(
|
||
"It seems that system hardening is enabled, "
|
||
"and environment variable related settings cannot be operated"
|
||
)
|
||
_profile = cls.return_profile()
|
||
if not os.path.isfile("/etc/profile"):
|
||
return public.lang("File /etc/profile does not exist")
|
||
cls._remove_old_python_env()
|
||
data = public.readFile(_profile)
|
||
data = re.sub(r'# +Start-Python-Env[^\n]*\nif\s+\[ +-f[^\n]*\n# +End-Python-Env', "", data)
|
||
data += cls._PYENV_FORMAT_DATA
|
||
public.writeFile(_profile, data)
|
||
if not pyenv and os.path.isfile(cls._bt_etc_pyenv):
|
||
os.remove(cls._bt_etc_pyenv)
|
||
return None
|
||
if pyenv:
|
||
cmd = "#{}\n{}".format(pyenv.bin_path, pyenv.activate_shell())
|
||
public.writeFile(cls._bt_etc_pyenv, cmd)
|
||
return None
|
||
|
||
def remove(self) -> Optional[str]:
|
||
if self.env_type == "conda":
|
||
return ("Conda environment does not support this operation,"
|
||
" please execute conda remove -n {} in the command line").format(self.venv_name)
|
||
elif not self.bin_path.startswith(python_manager_path()) and \
|
||
not self.bin_path.startswith(pyenv_path()):
|
||
return public.lang("Python environments not installed or created by the YakPanel are not supported,"
|
||
" please handle them manually")
|
||
try:
|
||
home = os.path.dirname(os.path.dirname(self.bin_path))
|
||
public.print_log(
|
||
os.path.isdir(home) and (home.startswith(python_manager_path()) or home.startswith(pyenv_path())), home)
|
||
if os.path.isdir(home) and (home.startswith(python_manager_path()) or home.startswith(pyenv_path())):
|
||
shutil.rmtree(home)
|
||
except:
|
||
return public.lang("Delete failed")
|
||
|
||
@property
|
||
def can_remove(self):
|
||
if self.env_type == "conda":
|
||
return False
|
||
if not self.bin_path.startswith(python_manager_path()) and \
|
||
not self.bin_path.startswith(pyenv_path()):
|
||
return False
|
||
if self.bin_path.startswith("/www/server/panel/pyenv"):
|
||
return False
|
||
return True
|
||
|
||
@property
|
||
def can_create(self):
|
||
if self.env_type == "system":
|
||
return True
|
||
return False
|
||
|
||
@property
|
||
def can_set_default(self):
|
||
if self.env_type == "conda":
|
||
return False
|
||
return True
|
||
|
||
@property
|
||
def path_name(self):
|
||
if any(self.bin_path.startswith(i) for i in _SYS_BIN_PATH):
|
||
return ""
|
||
|
||
return Path(self.bin_path).parent.parent.name
|
||
|
||
@classmethod
|
||
def profile_env_bin(cls) -> Optional[str]:
|
||
if not os.path.isfile("/etc/profile"):
|
||
return None
|
||
data = public.readFile("/etc/profile")
|
||
for line in data.split("\n"):
|
||
if line.find("source /www/server/panel/data/bt_etc_pyenv.sh"):
|
||
break
|
||
else:
|
||
return None
|
||
|
||
active_data: str = public.readFile(cls._bt_etc_pyenv)
|
||
if not active_data:
|
||
return None
|
||
|
||
bin_path = active_data.split("\n", 1)[0].strip("#")
|
||
return bin_path
|
||
|
||
def use2project(self, project_name: str):
|
||
"""项目:环境绝对路径 映射"""
|
||
if not project_name:
|
||
return
|
||
with open(self.project_to_pyenv_map_file, "r+") as f:
|
||
# home = /www/server/pyporject_evn/djenv
|
||
home = os.path.dirname(os.path.dirname(self.bin_path))
|
||
for i in _SYS_BIN_PATH:
|
||
if self.bin_path.startswith(i):
|
||
# 使用系统环境
|
||
home = i
|
||
break
|
||
data = f.read() or ""
|
||
lines = [
|
||
ln for ln in data.split("\n") if ln.strip()
|
||
] # 去空行
|
||
for i, line in enumerate(lines):
|
||
if line.startswith(project_name + ":"):
|
||
lines[i] = "{}:{}".format(project_name, home)
|
||
break
|
||
else:
|
||
lines.append("{}:{}".format(project_name, home))
|
||
f.seek(0)
|
||
f.truncate()
|
||
f.write("\n".join(lines))
|
||
|
||
|
||
class _EnvironmentDetector:
|
||
"""环境检测基类"""
|
||
_ver_regexp = re.compile(r"Python\s+(\d+\.\d+(\.\d+)?)")
|
||
_pypy_regexp = re.compile(r"PyPy\s+(\d+\.\d+(\.\d+)?)")
|
||
_site_packages_regexp = re.compile(r"pip\s+[\d.]+\s+from\s+(?P<path>(/[^/]+)+)/pip\s+\(python")
|
||
_site_packages_regexp2 = re.compile(r"pip\s+[\d.]+\s+from\s+(?P<path>(/[^/]+)+)\s+\(python")
|
||
|
||
def detect(self) -> List[PythonEnvironment]:
|
||
raise NotImplementedError
|
||
|
||
@staticmethod
|
||
def now_env_path_list():
|
||
"""使用子进程获取 PATH """
|
||
try:
|
||
result = subprocess.run(
|
||
["bash", "-c", "echo $PATH"],
|
||
capture_output=True, text=True, check=True
|
||
)
|
||
return result.stdout.strip().split(":")
|
||
except Exception as e:
|
||
print("Failed to get PATH: {}".format(str(e)))
|
||
return os.getenv("PATH", "").split(":")
|
||
|
||
@classmethod
|
||
def _parse_version_data(cls, data: str) -> Optional[str]:
|
||
ver_res = cls._ver_regexp.search(data)
|
||
if ver_res:
|
||
ver = ver_res.group()
|
||
else:
|
||
return None
|
||
pypy_ver_res = cls._pypy_regexp.search(data)
|
||
if pypy_ver_res:
|
||
pypy_ver = pypy_ver_res.group()
|
||
return "{} ({})".format(ver, pypy_ver)
|
||
return ver
|
||
|
||
@classmethod
|
||
def _parse_site_packages(cls, data: str) -> Optional[str]:
|
||
path_res = cls._site_packages_regexp.search(data)
|
||
if path_res:
|
||
return path_res.group("path")
|
||
path_res = cls._site_packages_regexp2.search(data)
|
||
if path_res:
|
||
return path_res.group("path")
|
||
return None
|
||
|
||
@classmethod
|
||
def site_pkgs_by_python_bin(cls, python_bin: str) -> Optional[str]:
|
||
lib_path = Path(python_bin).parent.parent / "lib"
|
||
if not lib_path.is_dir():
|
||
return None
|
||
|
||
for p in lib_path.iterdir():
|
||
p: Path
|
||
if p.is_dir() and (p.name.startswith("python") or p.name.startswith("pypy")):
|
||
site_packages_path: Path = p / "site-packages"
|
||
if site_packages_path.is_dir():
|
||
return str(site_packages_path.resolve())
|
||
return None
|
||
|
||
@classmethod
|
||
def site_pkgs_by_cmd(cls, python_path: str) -> Optional[str]:
|
||
try:
|
||
result = subprocess.run(
|
||
[python_path, "-m", "pip", "-V"],
|
||
capture_output=True, text=True, check=True
|
||
)
|
||
return cls._parse_site_packages(result.stdout.strip() + "\n" + result.stderr.strip())
|
||
except Exception as e:
|
||
print("Failed to get site-packages: {}".format(str(e)))
|
||
|
||
@classmethod
|
||
def get_site_packages(cls, python_path: str) -> Optional[str]:
|
||
if not any(python_path.startswith(p) for p in _SYS_BIN_PATH):
|
||
site_packages = cls.site_pkgs_by_python_bin(python_path)
|
||
if site_packages:
|
||
return site_packages
|
||
site_packages = cls.site_pkgs_by_cmd(python_path)
|
||
if site_packages and os.path.isdir(site_packages):
|
||
return site_packages
|
||
return None
|
||
|
||
|
||
class CondaDetector(_EnvironmentDetector):
|
||
"""Conda环境检测器"""
|
||
|
||
def __init__(self):
|
||
self.conda_paths = []
|
||
|
||
def find_conda_executable(self) -> List[str]:
|
||
# 预定义候选路径(保持原有逻辑)
|
||
conda_paths = [
|
||
Path(os.path.expanduser("~/miniconda3")),
|
||
Path(os.path.expanduser("~/anaconda3")),
|
||
Path("/opt/conda"),
|
||
Path(os.getenv("CONDA_EXE", "")).resolve().parent.parent, # 从CONDA_EXE推导安装目录
|
||
Path(os.getenv("CONDA_PREFIX", "")).resolve() # 从CONDA_EXE推导安装目录
|
||
]
|
||
|
||
# 新增:从系统PATH查找
|
||
for dir_path in self.now_env_path_list():
|
||
p = Path(dir_path)
|
||
if p.is_dir():
|
||
# 查找conda可执行文件(兼容符号链接)
|
||
conda_candidate = p / "conda"
|
||
if conda_candidate.exists():
|
||
conda_paths.insert(0, conda_candidate.resolve().parent.parent) # 定位到conda安装根目录
|
||
|
||
res_list = []
|
||
# 验证候选路径
|
||
for path in conda_paths:
|
||
conda_bin = path / "condabin" / "conda"
|
||
if str(conda_bin) not in res_list and conda_bin.exists() and os.access(conda_bin, os.X_OK):
|
||
res_list.append(str(conda_bin))
|
||
return res_list
|
||
|
||
def detect(self) -> List[PythonEnvironment]:
|
||
if not self.conda_paths:
|
||
self.conda_paths = self.find_conda_executable()
|
||
|
||
res_list = []
|
||
for conda_path in self.conda_paths:
|
||
res_list.extend(self.detect_by_conda_bin(conda_path))
|
||
return res_list
|
||
|
||
@classmethod
|
||
def detect_by_conda_bin(cls, conda_path: str) -> List[PythonEnvironment]:
|
||
res_list = []
|
||
try:
|
||
result = subprocess.run(
|
||
[conda_path, "env", "list", "--json"],
|
||
capture_output=True, text=True, check=True
|
||
)
|
||
env_data = json.loads(result.stdout)
|
||
if not env_data.get("root_prefix", None):
|
||
root_prefix = Path(conda_path.split("/bin")[0]).resolve()
|
||
else:
|
||
root_prefix = Path(env_data["root_prefix"]).resolve()
|
||
# 解析环境名称和路径
|
||
for env_spec in env_data["envs"]:
|
||
tmp_p = Path(env_spec).resolve()
|
||
env_name = "base" if tmp_p == root_prefix else tmp_p.name
|
||
|
||
bin_path = "{}/bin/python".format(str(tmp_p))
|
||
if not os.path.exists(bin_path) or not os.access(bin_path, os.X_OK):
|
||
bin_path = "{}/bin/python3".format(str(tmp_p))
|
||
if not os.path.exists(bin_path) or not os.access(bin_path, os.X_OK):
|
||
continue
|
||
version = cls.get_py_version(conda_path, env_name)
|
||
if not version:
|
||
continue
|
||
|
||
site_packages = cls.get_site_packages(bin_path)
|
||
if not site_packages:
|
||
continue
|
||
|
||
pe = PythonEnvironment(bin_path, version, "conda")
|
||
pe.conda_path = conda_path
|
||
pe.venv_name = env_name
|
||
pe.activate_sh = "conda activate {}".format(env_name)
|
||
pe.site_packages = site_packages
|
||
res_list.append(pe)
|
||
|
||
except Exception as e:
|
||
print(f"Conda detection failed: {str(e)}")
|
||
pass
|
||
return res_list
|
||
|
||
@classmethod
|
||
def get_py_version(cls, conda_path: str, name: str) -> Optional[str]:
|
||
"""conda run -n base python --version"""
|
||
try:
|
||
result = subprocess.run(
|
||
[conda_path, "run", "-n", name, "python", "--version"],
|
||
capture_output=True, text=True, check=True
|
||
)
|
||
return cls._parse_version_data(result.stdout.strip() + "\n" + result.stderr.strip())
|
||
except Exception as e:
|
||
print(f"Conda environment detection failed: {str(e)}")
|
||
return None
|
||
|
||
@classmethod
|
||
def find_conda_python(cls, path: str) -> List[PythonEnvironment]:
|
||
p = Path(path).expanduser().resolve()
|
||
if p.is_file() and os.access(p, os.X_OK):
|
||
return cls.detect_by_conda_bin(str(p))
|
||
return []
|
||
|
||
|
||
class VirtualEnvDetector(_EnvironmentDetector):
|
||
"""虚拟环境检测器(支持venv/virtualenv)"""
|
||
ENV_MARKERS = ["pyvenv.cfg", "bin/activate"]
|
||
NAME_REGEXPS = [
|
||
re.compile(r'''!=\s*x\s*]\s*;\s*then\s*VIRTUAL_ENV_PROMPT=['"]?(?P<name>[^'"\s]+)['"]?\n'''),
|
||
re.compile(r'''_OLD_VIRTUAL_PS1="\$\{PS1:-}"\s+PS1=['"\s]*\(['"\s]*(?P<name>.*)['"\s]*\)['"\s]*\$\{PS1:-}"'''),
|
||
re.compile(r'''VIRTUAL_ENV=["']?(/.*)/(?P<name>\S*)["']?\n'''),
|
||
]
|
||
|
||
def __init__(self, search_dirs: List[str]):
|
||
self.search_dirs = search_dirs
|
||
|
||
def detect(self) -> List[PythonEnvironment]:
|
||
envs = []
|
||
max_depth = 2
|
||
find_bin_path = set()
|
||
for base_dir in self.search_dirs:
|
||
expanded_dir = os.path.expanduser(base_dir)
|
||
if not os.path.exists(expanded_dir):
|
||
continue
|
||
|
||
for root, dirs, _ in os.walk(expanded_dir, topdown=True):
|
||
# 避免过多搜索子目录
|
||
if root.replace(expanded_dir, "").count(os.sep) >= max_depth:
|
||
dirs.clear()
|
||
continue
|
||
|
||
for tmp_dir in dirs:
|
||
if all(Path(root) / tmp_dir / marker for marker in self.ENV_MARKERS):
|
||
bin_path = "{}/bin/python".format(root)
|
||
if not os.path.exists(bin_path) or not os.access(bin_path, os.X_OK):
|
||
bin_path = "{}/bin/python3".format(root)
|
||
if not os.path.exists(bin_path) or not os.access(bin_path, os.X_OK):
|
||
continue
|
||
if bin_path in find_bin_path:
|
||
continue
|
||
data = self.get_env_info(root)
|
||
if not data:
|
||
continue
|
||
site_packages = self.get_site_packages(bin_path)
|
||
if not site_packages:
|
||
continue
|
||
find_bin_path.add(bin_path)
|
||
pe = PythonEnvironment(bin_path, data[1], "venv")
|
||
pe.activate_sh = "source {}/bin/activate".format(root)
|
||
pe.system_path = data[0]
|
||
pe.venv_name = data[2]
|
||
pe.site_packages = site_packages
|
||
envs.append(pe)
|
||
return envs
|
||
|
||
@classmethod
|
||
def get_env_info(cls, path: str) -> Optional[Tuple[str, str, str]]:
|
||
cfg_file = Path(path) / "pyvenv.cfg"
|
||
atv_file = Path(path) / "bin" / "activate"
|
||
if not cfg_file.exists():
|
||
return None
|
||
|
||
if not atv_file.exists():
|
||
return None
|
||
|
||
version_list = []
|
||
name = ""
|
||
try:
|
||
with open(cfg_file, "r") as fp:
|
||
for line in fp:
|
||
if line.startswith("home ="):
|
||
# home_dir = line.split("=")[1].strip() # 改为从虚拟环境的bin目录中获取
|
||
continue
|
||
elif line.startswith("implementation ="):
|
||
version_list.insert(0, line.split("=")[1].strip())
|
||
elif line.startswith("version_info ="):
|
||
v = line.split("=")[1].strip()
|
||
if v.count(".") > 2:
|
||
v = ".".join(v.split(".")[:3])
|
||
version_list.append(v)
|
||
elif line.startswith("version ="):
|
||
version_list.append(line.split("=")[1].strip())
|
||
with open(atv_file, "r") as fp:
|
||
atv_data = fp.read()
|
||
for regexp in cls.NAME_REGEXPS:
|
||
res = regexp.search(atv_data)
|
||
if res:
|
||
name = res.group("name").strip().strip('"\'')
|
||
break
|
||
except Exception as e:
|
||
print("Failed to get virtual environment info: {}".format(str(e)))
|
||
return None
|
||
|
||
if not version_list:
|
||
return None
|
||
|
||
bin_path = "{}/bin/python".format(path)
|
||
if not os.path.exists(bin_path) or not os.access(bin_path, os.X_OK):
|
||
bin_path = "{}/bin/python3".format(path)
|
||
if not os.path.exists(bin_path) or not os.access(bin_path, os.X_OK):
|
||
return None
|
||
|
||
real_bin = os.path.realpath(bin_path)
|
||
if len(version_list) == 1:
|
||
if os.path.basename(real_bin).startswith("pypy"):
|
||
version_list.insert(0, "Python")
|
||
version_list.append("(PyPy)")
|
||
else:
|
||
version_list.insert(0, "Python")
|
||
|
||
version = " ".join(version_list)
|
||
if not name:
|
||
name = os.path.basename(path)
|
||
|
||
return real_bin, version, name
|
||
|
||
@classmethod
|
||
def find_venv_python(cls, path: str) -> Optional[PythonEnvironment]:
|
||
p = Path(path).resolve()
|
||
while str(p) != "/":
|
||
if not p.exists() or not p.is_dir():
|
||
break
|
||
bin_path = "{}/bin/python".format(str(p))
|
||
if not os.path.exists(bin_path) or not os.access(bin_path, os.X_OK):
|
||
bin_path = "{}/bin/python3".format(str(p))
|
||
if not os.path.exists(bin_path) or not os.access(bin_path, os.X_OK):
|
||
p = p.parent
|
||
continue
|
||
|
||
data = cls.get_env_info(str(p))
|
||
if not data:
|
||
p = p.parent
|
||
continue
|
||
|
||
site_packages = cls.get_site_packages(bin_path)
|
||
if not site_packages:
|
||
p = p.parent
|
||
continue
|
||
|
||
pe = PythonEnvironment(bin_path, data[1], "venv")
|
||
pe.activate_sh = "source {}/bin/activate".format(str(p))
|
||
pe.system_path = data[0]
|
||
pe.venv_name = data[2]
|
||
pe.site_packages = site_packages
|
||
return pe
|
||
|
||
return None
|
||
|
||
|
||
class SystemPythonDetector(_EnvironmentDetector):
|
||
"""系统Python检测器"""
|
||
|
||
def __init__(self, search_dirs: List[str]):
|
||
self.search_dirs = search_dirs
|
||
self.python_bin_map = {}
|
||
self.regexp_name = re.compile(r"^(python|pypy)([23](\.\d+)?)?$")
|
||
|
||
@staticmethod
|
||
def get_conda_path() -> List[str]:
|
||
conda_path = []
|
||
for i in CondaDetector().find_conda_executable():
|
||
base_path = i.split("/bin/")[0]
|
||
envs_path = base_path + "/envs"
|
||
conda_path.extend([envs_path, base_path])
|
||
return conda_path
|
||
|
||
def is_conda_python(self, python_bin: str) -> bool:
|
||
if hasattr(self, "_conda_path"):
|
||
_conda_path = self._conda_path
|
||
else:
|
||
_conda_path = self.get_conda_path()
|
||
setattr(self, "_conda_path", _conda_path)
|
||
for i in _conda_path:
|
||
if python_bin.startswith(i):
|
||
return True
|
||
return False
|
||
|
||
def detect(self) -> List[PythonEnvironment]:
|
||
# 新增:从系统PATH查找
|
||
self.python_bin_map = {}
|
||
for dir_path in self.now_env_path_list():
|
||
p = Path(dir_path)
|
||
if not p.is_dir():
|
||
continue
|
||
|
||
self.detect_by_path(p)
|
||
|
||
max_depth = 2
|
||
for base_dir in self.search_dirs:
|
||
expanded_dir = os.path.expanduser(base_dir)
|
||
if not os.path.exists(expanded_dir):
|
||
continue
|
||
|
||
for root, dirs, _ in os.walk(expanded_dir, topdown=True):
|
||
# 避免过多搜索子目录
|
||
if root.replace(expanded_dir, "").count(os.sep) >= max_depth:
|
||
dirs.clear()
|
||
continue
|
||
|
||
for tmp_dir in dirs:
|
||
self.detect_by_path(Path(root) / tmp_dir)
|
||
|
||
res_list = []
|
||
for python_bin in self.python_bin_map.values():
|
||
if self.is_conda_python(str(python_bin)):
|
||
continue
|
||
version = self.get_py_version(str(python_bin))
|
||
if not version:
|
||
continue
|
||
|
||
site_packages = self.get_site_packages(str(python_bin))
|
||
if not site_packages:
|
||
site_packages = ""
|
||
|
||
pe = PythonEnvironment(str(python_bin), version, "system")
|
||
pe.site_packages = site_packages
|
||
res_list.append(pe)
|
||
|
||
return res_list
|
||
|
||
def detect_by_path(self, test_path: Path):
|
||
for python_bin_name in os.listdir(test_path):
|
||
if not self.regexp_name.match(python_bin_name):
|
||
continue
|
||
python_bin = Path(test_path) / python_bin_name
|
||
if python_bin.exists() and python_bin.is_file() and not python_bin.is_symlink() and os.access(python_bin,
|
||
os.X_OK):
|
||
fs_inode = os.stat(python_bin).st_ino
|
||
if fs_inode in self.python_bin_map and len(str(python_bin)) > len(self.python_bin_map[fs_inode]):
|
||
continue
|
||
self.python_bin_map[fs_inode] = str(python_bin.resolve())
|
||
# 如果查询目录不是系统目录,此时应该为Python安装目录, 则只检查一个有效的Python环境
|
||
if str(test_path) not in _SYS_BIN_PATH:
|
||
return
|
||
|
||
@classmethod
|
||
def get_py_version(cls, python_bin: str) -> Optional[str]:
|
||
try:
|
||
res = subprocess.run([python_bin, "--version"],
|
||
capture_output=True, text=True, check=True
|
||
)
|
||
return cls._parse_version_data(res.stdout.strip() + "\n" + res.stderr.strip())
|
||
except Exception as e:
|
||
print("Failed to get Python version: {}".format(str(e)))
|
||
return None
|
||
|
||
@classmethod
|
||
def find_system_python(cls, path: str) -> Optional[PythonEnvironment]:
|
||
p = Path(path).resolve()
|
||
if p.is_file() and os.access(p, os.X_OK):
|
||
if cls.is_conda_python(cls([]), str(p)):
|
||
return None
|
||
version = cls.get_py_version(str(p))
|
||
if not version:
|
||
return None
|
||
site_packages = cls.get_site_packages(str(p))
|
||
if not site_packages:
|
||
site_packages = ""
|
||
ep = PythonEnvironment(str(p), version, "system")
|
||
ep.site_packages = site_packages
|
||
return ep
|
||
return None
|
||
|
||
|
||
class EnvironmentReporter:
|
||
"""环境报告生成器"""
|
||
REPORT_FILE = "/www/server/panel/data/python_project_env.json"
|
||
|
||
def __init__(self):
|
||
_python_manager_path = python_manager_path()
|
||
_pyenv_path = pyenv_path()
|
||
_panel_pyenv = "/www/server/panel/pyenv"
|
||
self.detectors = [
|
||
CondaDetector(),
|
||
VirtualEnvDetector([
|
||
_panel_pyenv,
|
||
_pyenv_path,
|
||
_python_manager_path,
|
||
]),
|
||
SystemPythonDetector([
|
||
_panel_pyenv,
|
||
_pyenv_path,
|
||
"{}/versions".format(_pyenv_path),
|
||
"{}/pypy_versions".format(_pyenv_path),
|
||
"{}/versions".format(_python_manager_path),
|
||
])
|
||
]
|
||
|
||
def generate_report(self) -> Dict:
|
||
report = {"environments": [], "update_time": int(time.time())}
|
||
for detector in self.detectors:
|
||
try:
|
||
envs = detector.detect()
|
||
report["environments"].extend([e.to_dict() for e in envs])
|
||
except Exception as e:
|
||
print("Environment detection failed: {}".format(str(e)))
|
||
traceback.print_exc()
|
||
continue
|
||
return report
|
||
|
||
@classmethod
|
||
def update_report(cls, *now_report: Dict) -> Optional[str]:
|
||
try:
|
||
with open(cls.REPORT_FILE, "r") as fs:
|
||
old_data = json.loads(fs.read())
|
||
except:
|
||
old_data = {"environments": [], "update_time": int(time.time())}
|
||
|
||
path_map = OrderedDict()
|
||
for e in old_data["environments"]:
|
||
path_map[e["bin_path"]] = e
|
||
|
||
add_list = []
|
||
for e in now_report:
|
||
if e["bin_path"] in path_map:
|
||
tmp_ps = path_map[e["bin_path"]]["ps"]
|
||
path_map[e["bin_path"]].update(e)
|
||
if tmp_ps and not path_map[e["bin_path"]]["ps"]:
|
||
path_map[e["bin_path"]]["ps"] = tmp_ps
|
||
else:
|
||
path_map[e["bin_path"]] = e
|
||
add_list.append(e)
|
||
|
||
now_data = {"environments": add_list + old_data["environments"], "update_time": int(time.time())}
|
||
try:
|
||
with open(cls.REPORT_FILE, "w") as fs:
|
||
fs.write(json.dumps(now_data, indent=4))
|
||
return None
|
||
except Exception as e:
|
||
return "Failed to save record: {}".format(e)
|
||
|
||
def init_report(self):
|
||
if os.path.exists(self.REPORT_FILE):
|
||
r = self.generate_report()
|
||
self.update_report(*r["environments"])
|
||
else:
|
||
with open(self.REPORT_FILE, "w") as fs:
|
||
fs.write(json.dumps(self.generate_report(), indent=4))
|
||
em = EnvironmentManager()
|
||
all_py_project = public.M("sites").where("project_type=?", ("Python",)).field('id,project_config').select()
|
||
for project in all_py_project:
|
||
project_config: dict = json.loads(project['project_config'])
|
||
if "python_bin" in project_config:
|
||
continue
|
||
p = em.get_env_py_path(project_config.get("vpath"))
|
||
if p:
|
||
project_config["python_bin"] = p.bin_path
|
||
|
||
public.M("sites").where("id=?", (project['id'],)).update({"project_config": json.dumps(project_config)})
|
||
|
||
class EnvironmentManager:
|
||
_REPORT_FILE = "/www/server/panel/data/python_project_env.json"
|
||
|
||
def __init__(self):
|
||
self._all_env = None
|
||
|
||
@property
|
||
def all_env(self) -> List[PythonEnvironment]:
|
||
venv_src_map = {}
|
||
if self._all_env is None:
|
||
self._all_env = []
|
||
for e in self.load_report()["environments"]:
|
||
if not os.path.isfile(e["bin_path"]): # 文件已不存在的,就不在再展示
|
||
continue
|
||
tmp_p = PythonEnvironment.from_dict(e)
|
||
self._all_env.append(tmp_p)
|
||
if tmp_p.env_type == "venv":
|
||
venv_src_map[tmp_p.system_path] = tmp_p
|
||
|
||
for tmp_p in self._all_env:
|
||
if tmp_p.env_type == "system" and tmp_p.bin_path in venv_src_map:
|
||
venv_src_map.pop(tmp_p.bin_path)
|
||
|
||
for tmp_p in venv_src_map.values():
|
||
self._all_env.remove(tmp_p)
|
||
|
||
return self._all_env
|
||
|
||
def get_env_py_path(self, python_path: str) -> Optional[PythonEnvironment]:
|
||
python_path = python_path.rstrip("/")
|
||
for tmp_p in self.all_env:
|
||
if tmp_p.bin_path == python_path:
|
||
return tmp_p
|
||
# 兼容旧版本
|
||
if python_path.startswith("/www/server/pyporject_evn/"):
|
||
for tmp_p in self.all_env:
|
||
if tmp_p.bin_path.startswith(python_path) and tmp_p.env_type == "system":
|
||
return tmp_p
|
||
return None
|
||
|
||
@staticmethod
|
||
def load_report() -> Dict:
|
||
with open(EnvironmentReporter.REPORT_FILE, "r") as fs:
|
||
return json.loads(fs.read())
|
||
|
||
@staticmethod
|
||
def add_python_env(add_type: str, path: str) -> Optional[str]:
|
||
p = Path(path)
|
||
if not p.exists():
|
||
return "Python environment does not exist"
|
||
if path.find("conda") and add_type == "system":
|
||
tmp_path = path.rsplit("/bin")[0]
|
||
if os.path.isfile(tmp_path + "/condabin/conda"):
|
||
add_type = "conda"
|
||
path = tmp_path + "/condabin/conda"
|
||
elif os.path.isfile(tmp_path + "/../../condabin/conda"):
|
||
add_type = "conda"
|
||
path = os.path.realpath(tmp_path + "/../../condabin/conda")
|
||
|
||
if add_type == "system":
|
||
tmp_p = SystemPythonDetector.find_system_python(path)
|
||
if not tmp_p:
|
||
return "Python environment does not exist"
|
||
p_list = [tmp_p]
|
||
elif add_type == "venv":
|
||
tmp_p = VirtualEnvDetector.find_venv_python(path)
|
||
if not tmp_p:
|
||
return "Python environment does not exist"
|
||
else:
|
||
p_list = [tmp_p]
|
||
system_p = SystemPythonDetector.find_system_python(tmp_p.system_path)
|
||
if system_p:
|
||
p_list.append(system_p)
|
||
elif add_type == "conda":
|
||
p_list = CondaDetector.find_conda_python(path)
|
||
if not p_list:
|
||
return "Python environment does not exist"
|
||
else:
|
||
return "Invalid specified type"
|
||
|
||
_ep = EnvironmentReporter()
|
||
_ep.update_report(*[p.to_dict() for p in p_list])
|
||
return None
|
||
|
||
def multi_remove_env(self, *more_path: str) -> List[Dict]:
|
||
"""批量移除Python虚拟环境(从管理列表中移除)
|
||
|
||
:param more_path: 其他要移除的路径(可变参数)
|
||
:return: 错误信息或None(成功)
|
||
可以移除的条件,
|
||
1.是python_manager/pyporject_evn下的环境,
|
||
2.非conda环境,
|
||
3.无项目使用中,
|
||
4.system环境,需要无以此环境为home的vnev环境
|
||
"""
|
||
# 合并所有待删除路径并进行标准化处理
|
||
paths_to_remove = {os.path.normpath(p) for p in more_path}
|
||
|
||
to_remove = []
|
||
saved = []
|
||
|
||
for p in self.all_env:
|
||
if p.bin_path in paths_to_remove:
|
||
to_remove.append(p)
|
||
paths_to_remove.remove(p.bin_path)
|
||
else:
|
||
saved.append(p)
|
||
|
||
if paths_to_remove:
|
||
msg = "Python environment does not exist: %s"
|
||
return [{"path": p, "status": False, "msg": msg % p} for p in paths_to_remove]
|
||
|
||
bin_path2project = self.all_python_project_map()
|
||
# 优先处理顺序 有项目使用的、conda、venv、system(非系统)、系统环境
|
||
to_remove.sort(key=lambda x: (
|
||
x.bin_path not in bin_path2project,
|
||
("conda", "venv", "system").index(x.env_type),
|
||
any(x.bin_path.startswith(syp) for syp in _SYS_BIN_PATH)
|
||
))
|
||
|
||
res_map = {p.bin_path: {
|
||
"path": p.bin_path, "status": True, "msg": "Python environment deleted successfully"
|
||
} for p in to_remove}
|
||
|
||
real_remove = []
|
||
for p in to_remove:
|
||
if p.env_type == "conda":
|
||
res_map[p.bin_path].update(
|
||
status=False,
|
||
msg="Conda environments aren't supported here. Run: conda remove -n {}".format(p.venv_name)
|
||
)
|
||
saved.append(p)
|
||
continue
|
||
elif p.bin_path in bin_path2project:
|
||
res_map[p.bin_path].update(
|
||
status=False,
|
||
msg="Python environment is in use by project [{}]. Delete the project first.".format(
|
||
bin_path2project[p.bin_path][0]
|
||
)
|
||
)
|
||
saved.append(p)
|
||
continue
|
||
elif not p.bin_path.startswith(python_manager_path()) and \
|
||
not p.bin_path.startswith(pyenv_path()):
|
||
res_map[p.bin_path].update(
|
||
status=False,
|
||
msg="This Python environment wasn't installed/created by the YakPanel and can't be managed here. "
|
||
"Please handle it manually."
|
||
)
|
||
saved.append(p)
|
||
continue
|
||
elif p.env_type == "system":
|
||
for i in saved:
|
||
if i.system_path == p.bin_path:
|
||
res_map[p.bin_path].update(
|
||
status=False,
|
||
msg="This Python environment is being used by virtual environment [{}]. "
|
||
"Delete the virtual environment first.".format(
|
||
i.venv_name or i.ps or i.version
|
||
))
|
||
saved.append(p)
|
||
break
|
||
else:
|
||
real_remove.append(p)
|
||
else:
|
||
real_remove.append(p)
|
||
|
||
if real_remove:
|
||
for p in real_remove:
|
||
msg = p.remove()
|
||
if msg:
|
||
res_map[p.bin_path].update(
|
||
status=False,
|
||
msg=msg
|
||
)
|
||
# saved.append(p) 执行过删除的,即使删除失败,也要不保留记录
|
||
else:
|
||
res_map[p.bin_path].update(
|
||
status=True,
|
||
msg="Python environment removed successfully"
|
||
)
|
||
|
||
saved_bin = {p.bin_path for p in saved}
|
||
report = self.load_report()
|
||
report["update_time"] = int(time.time())
|
||
report["environments"] = [i for i in report["environments"] if i["bin_path"] in saved_bin]
|
||
public.writeFile(self._REPORT_FILE, json.dumps(report, indent=4))
|
||
return list(res_map.values())
|
||
|
||
# 查询数据库中所有项目对环境的使用情况,返回映射关系
|
||
def all_python_project_map(self):
|
||
res_map = {}
|
||
all_py_project = public.M("sites").where("project_type=?", ("Python",)).field('name,project_config').select()
|
||
for project in all_py_project:
|
||
project_config: dict = json.loads(project['project_config'])
|
||
p = self.get_env_py_path(project_config.get('python_bin', project_config.get("vpath")))
|
||
if p:
|
||
res_map.setdefault(p.bin_path, []).append(project["name"])
|
||
|
||
return res_map
|
||
|
||
def set_python2env(self, path: str) -> Optional[str]:
|
||
"""设置python环境到命令行"""
|
||
if path == "":
|
||
pyenv = None
|
||
else:
|
||
pyenv = self.get_env_py_path(path)
|
||
if not pyenv:
|
||
return "Python environment does not exist"
|
||
PythonEnvironment.set_profile_env(pyenv)
|
||
return None
|
||
|
||
def get_default_python_env(self) -> Optional[PythonEnvironment]:
|
||
"""获取默认python环境"""
|
||
p_bin = PythonEnvironment.profile_env_bin()
|
||
if not p_bin:
|
||
return None
|
||
pyenv = self.get_env_py_path(p_bin)
|
||
if pyenv:
|
||
return pyenv
|
||
return None
|
||
|
||
def set_python_env_ps(self, path: str, ps: str) -> Optional[str]:
|
||
"""设置python环境"""
|
||
pyenv = self.get_env_py_path(path)
|
||
if not pyenv:
|
||
return "Python environment does not exist"
|
||
pyenv.ps = ps
|
||
EnvironmentReporter().update_report(pyenv.to_dict())
|
||
return None
|
||
|
||
@staticmethod
|
||
def _is_valid_env_name(name: str) -> bool:
|
||
"""校验虚拟环境名称合法性"""
|
||
# Linux文件系统命名规则
|
||
forbidden_chars = {'/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0'}
|
||
max_length = 255 # 最大文件名长度
|
||
|
||
# 基本规则检查
|
||
if not name:
|
||
return False
|
||
if any(char in forbidden_chars for char in name):
|
||
return False
|
||
if len(name) > max_length:
|
||
return False
|
||
if name in ('.', '..'): # 保留名称
|
||
return False
|
||
|
||
# 增强校验
|
||
if not re.match(r'^[a-zA-Z0-9_.-]+$', name):
|
||
return False
|
||
|
||
return True
|
||
|
||
def create_python_env(self,
|
||
venv_name: str,
|
||
src_python_bin: str,
|
||
ps: str,
|
||
call_log: Callable[[str], None] = None) -> Optional[str]:
|
||
|
||
if call_log and callable(call_log):
|
||
real_call = call_log
|
||
else:
|
||
real_call = lambda x: None
|
||
|
||
if not self._is_valid_env_name(venv_name):
|
||
err_msg3 = "Virtual environment name is invalid"
|
||
real_call(err_msg3)
|
||
return err_msg3
|
||
|
||
err_msg = "The source Python environment does not exist"
|
||
if not os.path.exists(src_python_bin):
|
||
real_call(err_msg)
|
||
return err_msg
|
||
src_p = self.get_env_py_path(src_python_bin)
|
||
if not src_p:
|
||
real_call(err_msg)
|
||
return err_msg
|
||
|
||
if src_p.env_type != "system":
|
||
err_msg2 = "Need to use system Python environment to create a virtual environment"
|
||
real_call(err_msg2)
|
||
return err_msg2
|
||
|
||
venv_path = "/www/server/pyporject_evn/{}".format(venv_name)
|
||
if call_log and callable(call_log):
|
||
call_log("Starting to create virtual environment")
|
||
return src_p.create_venv_sync(venv_path, ps, call_log)
|
||
else:
|
||
return src_p.create_venv(venv_path, ps)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
EnvironmentReporter().init_report()
|