- 应用外壳换成 stash-ui Layout:左侧导航(任务调度 / Python 镜像)+ 页面标题区,窄屏折叠为顶部导航 - 新增 python_mirrors 表与 CRUD/设默认接口,内置清华、阿里云、中科大、PyPI 官方 - Python 脚本任务运行时注入默认镜像的 PIP_INDEX_URL / UV_INDEX_URL,任务自身环境变量优先 - 新增镜像管理页与表单,支持新增、编辑、启用/停用、设默认、删除 - 重新生成 OpenAPI 与 TS 类型;补齐后端镜像测试、前端单测与 e2e,修复窄屏页面被宽表格撑开
798 lines
25 KiB
Rust
798 lines
25 KiB
Rust
use std::{
|
|
collections::BTreeMap,
|
|
env,
|
|
ffi::OsString,
|
|
io,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::process::Command;
|
|
use utoipa::ToSchema;
|
|
|
|
const VENV_DIRECTORY_NAMES: [&str; 3] = [".venv", "venv", "env"];
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct PreparedPython {
|
|
pub interpreter: PathBuf,
|
|
pub virtual_env: Option<PathBuf>,
|
|
environment: BTreeMap<String, String>,
|
|
remove_environment: Vec<&'static str>,
|
|
}
|
|
|
|
impl PreparedPython {
|
|
pub fn apply(&self, command: &mut Command) {
|
|
for key in &self.remove_environment {
|
|
command.env_remove(key);
|
|
}
|
|
command.envs(&self.environment);
|
|
}
|
|
}
|
|
|
|
pub fn prepare_script_environment(
|
|
working_dir: &str,
|
|
interpreter: &str,
|
|
configured_venv: Option<&str>,
|
|
user_env: &BTreeMap<String, String>,
|
|
) -> io::Result<PreparedPython> {
|
|
if !is_python_interpreter(interpreter) {
|
|
return Ok(PreparedPython {
|
|
interpreter: resolve_interpreter(working_dir, interpreter),
|
|
virtual_env: None,
|
|
environment: user_env.clone(),
|
|
remove_environment: Vec::new(),
|
|
});
|
|
}
|
|
|
|
let interpreter_path = resolve_interpreter(working_dir, interpreter);
|
|
let detected_venv = resolve_venv(working_dir, interpreter, configured_venv)?.venv;
|
|
|
|
let mut environment = BTreeMap::new();
|
|
for (key, value) in user_env {
|
|
if key.eq_ignore_ascii_case("PATH")
|
|
|| key.eq_ignore_ascii_case("VIRTUAL_ENV")
|
|
|| key.eq_ignore_ascii_case("VIRTUAL_ENV_PROMPT")
|
|
|| (detected_venv.is_some() && key.eq_ignore_ascii_case("PYTHONHOME"))
|
|
{
|
|
continue;
|
|
}
|
|
environment.insert(key.clone(), value.clone());
|
|
}
|
|
|
|
if let Some(venv) = &detected_venv {
|
|
let scripts_dir = venv.join("Scripts");
|
|
let python = scripts_dir.join("python.exe");
|
|
environment.insert(
|
|
"VIRTUAL_ENV".to_owned(),
|
|
venv.to_string_lossy().into_owned(),
|
|
);
|
|
environment.insert(
|
|
"VIRTUAL_ENV_PROMPT".to_owned(),
|
|
venv.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.into_owned(),
|
|
);
|
|
environment.insert(
|
|
"PATH".to_owned(),
|
|
build_venv_path(&scripts_dir, user_env)?
|
|
.to_string_lossy()
|
|
.into_owned(),
|
|
);
|
|
apply_stdio_defaults(&mut environment);
|
|
return Ok(PreparedPython {
|
|
interpreter: python,
|
|
virtual_env: Some(venv.clone()),
|
|
environment,
|
|
remove_environment: vec![
|
|
"PYTHONHOME",
|
|
"PYTHONPATH",
|
|
"VIRTUAL_ENV",
|
|
"VIRTUAL_ENV_PROMPT",
|
|
],
|
|
});
|
|
}
|
|
|
|
apply_stdio_defaults(&mut environment);
|
|
Ok(PreparedPython {
|
|
interpreter: interpreter_path,
|
|
virtual_env: None,
|
|
environment,
|
|
remove_environment: vec!["VIRTUAL_ENV", "VIRTUAL_ENV_PROMPT"],
|
|
})
|
|
}
|
|
|
|
/// Python 的标准流默认跟随系统代码页(简体中文为 GBK),一旦打印 emoji 或生僻字符
|
|
/// 就会 `UnicodeEncodeError` 直接失败。这里默认改用 UTF-8,日志侧本来就优先按 UTF-8 解码。
|
|
/// 用户在任务里显式设置的 `PYTHONIOENCODING` 仍然优先。
|
|
fn apply_stdio_defaults(environment: &mut BTreeMap<String, String>) {
|
|
// 实时日志依赖标准流不缓冲,这一项始终强制。
|
|
environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
|
|
environment
|
|
.entry("PYTHONIOENCODING".to_owned())
|
|
.or_insert_with(|| "utf-8".to_owned());
|
|
}
|
|
|
|
/// 虚拟环境的来源,用于在界面上说明"为什么"关联到了这个环境。
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum VenvSource {
|
|
/// 手动填写的 `action.venv`
|
|
Configured,
|
|
/// 由解释器路径(`...\.venv\Scripts\python.exe`)识别
|
|
Interpreter,
|
|
/// 从工作目录或上级目录探测到的 `.venv`、`venv` 或 `env`
|
|
WorkingDirectory,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ResolvedVenv {
|
|
pub venv: Option<PathBuf>,
|
|
pub source: Option<VenvSource>,
|
|
}
|
|
|
|
/// 解析任务实际会使用的虚拟环境,顺序与执行期保持一致:
|
|
/// 手动指定 → 解释器路径推断 → 工作目录向上探测。
|
|
pub fn resolve_venv(
|
|
working_dir: &str,
|
|
interpreter: &str,
|
|
configured_venv: Option<&str>,
|
|
) -> io::Result<ResolvedVenv> {
|
|
if !is_python_interpreter(interpreter) {
|
|
return Ok(ResolvedVenv {
|
|
venv: None,
|
|
source: None,
|
|
});
|
|
}
|
|
|
|
let working_directory = absolute_path(Path::new(working_dir))?;
|
|
let interpreter_path = resolve_interpreter(working_dir, interpreter);
|
|
let explicit_venv = configured_venv
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(|value| resolve_against(&working_directory, value))
|
|
.transpose()?;
|
|
|
|
if let Some(venv) = explicit_venv
|
|
.as_deref()
|
|
.map(validate_venv)
|
|
.transpose()?
|
|
.flatten()
|
|
{
|
|
return Ok(ResolvedVenv {
|
|
venv: Some(venv),
|
|
source: Some(VenvSource::Configured),
|
|
});
|
|
}
|
|
if let Some(venv) = detect_venv_from_interpreter(&interpreter_path) {
|
|
return Ok(ResolvedVenv {
|
|
venv: Some(venv),
|
|
source: Some(VenvSource::Interpreter),
|
|
});
|
|
}
|
|
if is_generic_python(interpreter)
|
|
&& let Some(venv) = detect_venv_from_ancestors(&working_directory)
|
|
{
|
|
return Ok(ResolvedVenv {
|
|
venv: Some(venv),
|
|
source: Some(VenvSource::WorkingDirectory),
|
|
});
|
|
}
|
|
|
|
Ok(ResolvedVenv {
|
|
venv: None,
|
|
source: None,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InterpreterKind {
|
|
Python,
|
|
Node,
|
|
Other,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum VenvProbeStatus {
|
|
/// 已关联到有效的虚拟环境
|
|
Linked,
|
|
/// 手动指定的虚拟环境无效
|
|
Invalid,
|
|
/// 没有虚拟环境,将直接使用系统解释器
|
|
Missing,
|
|
/// 解释器不是 Python,虚拟环境不适用
|
|
NotApplicable,
|
|
/// 输入不完整,暂时无法探测
|
|
Incomplete,
|
|
}
|
|
|
|
/// 编辑器在用户填写工作目录时发起的探测请求。
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct VenvProbeRequest {
|
|
/// 任务工作目录,对应 `action.working_dir`
|
|
pub working_dir: String,
|
|
/// 解释器或可执行程序,对应 `action.interpreter`
|
|
pub interpreter: String,
|
|
/// 手动填写的虚拟环境目录,对应 `action.venv`
|
|
#[serde(default)]
|
|
pub venv: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct VenvProbeResponse {
|
|
pub status: VenvProbeStatus,
|
|
pub interpreter_kind: InterpreterKind,
|
|
/// 虚拟环境根目录的绝对路径
|
|
pub venv_path: Option<String>,
|
|
/// 供界面展示的解释器路径;位于工作目录内时使用相对路径
|
|
pub display_path: Option<String>,
|
|
pub source: Option<VenvSource>,
|
|
pub message: String,
|
|
}
|
|
|
|
pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
|
|
let working_dir = request.working_dir.trim();
|
|
let interpreter = request.interpreter.trim();
|
|
let configured_venv = request
|
|
.venv
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty());
|
|
let interpreter_kind = classify_interpreter(interpreter);
|
|
|
|
if working_dir.is_empty() {
|
|
return incomplete_probe(interpreter_kind, "请先填写工作目录");
|
|
}
|
|
if interpreter.is_empty() {
|
|
return incomplete_probe(interpreter_kind, "请先填写解释器或可执行程序");
|
|
}
|
|
if !matches!(interpreter_kind, InterpreterKind::Python) {
|
|
return VenvProbeResponse {
|
|
status: VenvProbeStatus::NotApplicable,
|
|
interpreter_kind,
|
|
venv_path: None,
|
|
display_path: Some(interpreter.to_owned()),
|
|
source: None,
|
|
message: "当前解释器不是 Python,不需要虚拟环境".to_owned(),
|
|
};
|
|
}
|
|
|
|
let working_directory = match absolute_path(Path::new(working_dir)) {
|
|
Ok(path) => path,
|
|
Err(error) => {
|
|
return incomplete_probe(interpreter_kind, &format!("工作目录无效: {error}"));
|
|
}
|
|
};
|
|
if !working_directory.is_dir() {
|
|
return incomplete_probe(interpreter_kind, "工作目录不存在,暂时无法探测虚拟环境");
|
|
}
|
|
|
|
match resolve_venv(working_dir, interpreter, configured_venv) {
|
|
Ok(ResolvedVenv {
|
|
venv: Some(venv),
|
|
source,
|
|
}) => VenvProbeResponse {
|
|
status: VenvProbeStatus::Linked,
|
|
interpreter_kind,
|
|
display_path: Some(python_display_path(&venv, working_dir)),
|
|
venv_path: Some(venv.to_string_lossy().into_owned()),
|
|
source,
|
|
message: venv_source_message(source),
|
|
},
|
|
Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse {
|
|
status: VenvProbeStatus::Missing,
|
|
interpreter_kind,
|
|
venv_path: None,
|
|
display_path: Some(interpreter.to_owned()),
|
|
source: None,
|
|
message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(),
|
|
},
|
|
Err(error) => VenvProbeResponse {
|
|
status: VenvProbeStatus::Invalid,
|
|
interpreter_kind,
|
|
venv_path: None,
|
|
display_path: None,
|
|
source: None,
|
|
message: error.to_string(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvProbeResponse {
|
|
VenvProbeResponse {
|
|
status: VenvProbeStatus::Incomplete,
|
|
interpreter_kind,
|
|
venv_path: None,
|
|
display_path: None,
|
|
source: None,
|
|
message: message.to_owned(),
|
|
}
|
|
}
|
|
|
|
fn venv_source_message(source: Option<VenvSource>) -> String {
|
|
match source {
|
|
Some(VenvSource::Configured) => "已关联手动指定的虚拟环境".to_owned(),
|
|
Some(VenvSource::Interpreter) => "已从解释器路径识别虚拟环境".to_owned(),
|
|
Some(VenvSource::WorkingDirectory) => "已自动关联工作目录中的虚拟环境".to_owned(),
|
|
None => "已关联虚拟环境".to_owned(),
|
|
}
|
|
}
|
|
|
|
fn python_display_path(venv: &Path, working_dir: &str) -> String {
|
|
let python = venv.join("Scripts").join("python.exe");
|
|
absolute_path(Path::new(working_dir))
|
|
.ok()
|
|
.and_then(|base| relative_display(&base, &python))
|
|
.unwrap_or_else(|| python.to_string_lossy().into_owned())
|
|
}
|
|
|
|
fn relative_display(base: &Path, target: &Path) -> Option<String> {
|
|
let base_components = base.components().collect::<Vec<_>>();
|
|
let target_components = target.components().collect::<Vec<_>>();
|
|
if target_components.len() <= base_components.len() {
|
|
return None;
|
|
}
|
|
let shares_prefix = base_components
|
|
.iter()
|
|
.zip(&target_components)
|
|
.all(|(base, target)| {
|
|
base.as_os_str()
|
|
.to_string_lossy()
|
|
.eq_ignore_ascii_case(&target.as_os_str().to_string_lossy())
|
|
});
|
|
if !shares_prefix {
|
|
return None;
|
|
}
|
|
|
|
let relative = target_components[base_components.len()..]
|
|
.iter()
|
|
.map(|component| component.as_os_str())
|
|
.collect::<PathBuf>();
|
|
Some(relative.to_string_lossy().into_owned())
|
|
}
|
|
|
|
fn classify_interpreter(interpreter: &str) -> InterpreterKind {
|
|
if is_python_interpreter(interpreter) {
|
|
return InterpreterKind::Python;
|
|
}
|
|
|
|
let name = Path::new(interpreter)
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or(interpreter)
|
|
.to_ascii_lowercase();
|
|
let stem = name.strip_suffix(".exe").unwrap_or(&name);
|
|
|
|
if matches!(stem, "node" | "nodejs") {
|
|
InterpreterKind::Node
|
|
} else {
|
|
InterpreterKind::Other
|
|
}
|
|
}
|
|
|
|
pub fn validate_configured_venv(working_dir: &str, configured_venv: &str) -> io::Result<PathBuf> {
|
|
let working_directory = absolute_path(Path::new(working_dir))?;
|
|
let venv = resolve_against(&working_directory, configured_venv)?;
|
|
validate_venv(&venv)?.ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!("无效的 Python 虚拟环境: {}", venv.display()),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn validate_venv(path: &Path) -> io::Result<Option<PathBuf>> {
|
|
let root = absolute_path(path)?;
|
|
let python = root.join("Scripts").join("python.exe");
|
|
if !root.join("pyvenv.cfg").is_file() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!("虚拟环境缺少 pyvenv.cfg: {}", root.display()),
|
|
));
|
|
}
|
|
if !python.is_file() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!("虚拟环境缺少 Scripts\\python.exe: {}", root.display()),
|
|
));
|
|
}
|
|
Ok(Some(root))
|
|
}
|
|
|
|
fn detect_venv_from_ancestors(start: &Path) -> Option<PathBuf> {
|
|
for directory in start.ancestors() {
|
|
for name in VENV_DIRECTORY_NAMES {
|
|
let candidate = directory.join(name);
|
|
if candidate.join("pyvenv.cfg").is_file()
|
|
&& candidate.join("Scripts").join("python.exe").is_file()
|
|
{
|
|
return Some(candidate);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn detect_venv_from_interpreter(interpreter: &Path) -> Option<PathBuf> {
|
|
let scripts = interpreter.parent()?;
|
|
if !scripts
|
|
.file_name()
|
|
.is_some_and(|name| name.eq_ignore_ascii_case("Scripts"))
|
|
{
|
|
return None;
|
|
}
|
|
let root = scripts.parent()?;
|
|
(root.join("pyvenv.cfg").is_file() && scripts.join("python.exe").is_file())
|
|
.then(|| root.to_path_buf())
|
|
}
|
|
|
|
fn build_venv_path(
|
|
scripts_dir: &Path,
|
|
user_env: &BTreeMap<String, String>,
|
|
) -> io::Result<OsString> {
|
|
let base = user_env
|
|
.iter()
|
|
.find(|(key, _)| key.eq_ignore_ascii_case("PATH"))
|
|
.map(|(_, value)| OsString::from(value))
|
|
.or_else(|| env::var_os("PATH"))
|
|
.unwrap_or_default();
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut paths = Vec::new();
|
|
for path in std::iter::once(scripts_dir.to_path_buf()).chain(env::split_paths(&base)) {
|
|
let key = path.to_string_lossy().to_ascii_lowercase();
|
|
if seen.insert(key) {
|
|
paths.push(path);
|
|
}
|
|
}
|
|
env::join_paths(paths).map_err(io::Error::other)
|
|
}
|
|
|
|
fn resolve_interpreter(working_dir: &str, interpreter: &str) -> PathBuf {
|
|
let path = Path::new(interpreter);
|
|
if path.is_absolute() || path.components().count() == 1 {
|
|
path.to_path_buf()
|
|
} else {
|
|
Path::new(working_dir).join(path)
|
|
}
|
|
}
|
|
|
|
fn resolve_against(base: &Path, value: &str) -> io::Result<PathBuf> {
|
|
let path = Path::new(value);
|
|
let resolved = if path.is_absolute() {
|
|
path.to_path_buf()
|
|
} else {
|
|
base.join(path)
|
|
};
|
|
absolute_path(&resolved)
|
|
}
|
|
|
|
fn absolute_path(path: &Path) -> io::Result<PathBuf> {
|
|
std::path::absolute(path)
|
|
}
|
|
|
|
pub fn is_python_interpreter(interpreter: &str) -> bool {
|
|
let name = Path::new(interpreter)
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or(interpreter)
|
|
.to_ascii_lowercase();
|
|
name == "py.exe" || name == "py" || name.starts_with("python")
|
|
}
|
|
|
|
fn is_generic_python(interpreter: &str) -> bool {
|
|
Path::new(interpreter).components().count() == 1 && is_python_interpreter(interpreter)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
use tempfile::TempDir;
|
|
|
|
fn create_fake_venv(root: &Path) -> PathBuf {
|
|
let venv = root.join(".venv");
|
|
fs::create_dir_all(venv.join("Scripts")).unwrap();
|
|
fs::write(venv.join("pyvenv.cfg"), "home = C:\\Python").unwrap();
|
|
fs::write(venv.join("Scripts").join("python.exe"), "").unwrap();
|
|
venv
|
|
}
|
|
|
|
#[test]
|
|
fn detects_venv_from_working_directory_and_builds_path() {
|
|
let temp = TempDir::new().unwrap();
|
|
let venv = create_fake_venv(temp.path());
|
|
let working_dir = temp.path().join("project").join("src");
|
|
fs::create_dir_all(&working_dir).unwrap();
|
|
let mut user_env = BTreeMap::new();
|
|
user_env.insert("CUSTOM_VALUE".to_owned(), "yes".to_owned());
|
|
user_env.insert("PYTHONHOME".to_owned(), "C:\\wrong".to_owned());
|
|
|
|
let prepared = prepare_script_environment(
|
|
working_dir.to_str().unwrap(),
|
|
"python.exe",
|
|
None,
|
|
&user_env,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(prepared.virtual_env, Some(venv.clone()));
|
|
assert_eq!(
|
|
prepared.interpreter,
|
|
venv.join("Scripts").join("python.exe")
|
|
);
|
|
assert_eq!(
|
|
prepared.environment.get("VIRTUAL_ENV"),
|
|
Some(&venv.to_string_lossy().into_owned())
|
|
);
|
|
assert_eq!(
|
|
prepared.environment.get("PYTHONUNBUFFERED"),
|
|
Some(&"1".to_owned())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_venv_is_validated() {
|
|
let temp = TempDir::new().unwrap();
|
|
let venv = create_fake_venv(temp.path());
|
|
let prepared = prepare_script_environment(
|
|
temp.path().to_str().unwrap(),
|
|
"python.exe",
|
|
Some(venv.to_str().unwrap()),
|
|
&BTreeMap::new(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(prepared.virtual_env, Some(venv));
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_explicit_venv_returns_error() {
|
|
let temp = TempDir::new().unwrap();
|
|
let missing = temp.path().join("missing-venv");
|
|
let error = prepare_script_environment(
|
|
temp.path().to_str().unwrap(),
|
|
"python.exe",
|
|
Some(missing.to_str().unwrap()),
|
|
&BTreeMap::new(),
|
|
)
|
|
.unwrap_err();
|
|
|
|
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
|
}
|
|
|
|
#[test]
|
|
fn non_python_interpreter_is_not_modified() {
|
|
let temp = TempDir::new().unwrap();
|
|
create_fake_venv(temp.path());
|
|
let prepared = prepare_script_environment(
|
|
temp.path().to_str().unwrap(),
|
|
"node.exe",
|
|
None,
|
|
&BTreeMap::new(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(prepared.virtual_env, None);
|
|
assert!(prepared.environment.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn venv_environment_overrides_conflicting_python_variables() {
|
|
let temp = TempDir::new().unwrap();
|
|
let venv = create_fake_venv(temp.path());
|
|
let mut user_env = BTreeMap::new();
|
|
user_env.insert(
|
|
"Path".to_owned(),
|
|
temp.path()
|
|
.join("custom-bin")
|
|
.to_string_lossy()
|
|
.into_owned(),
|
|
);
|
|
user_env.insert("PYTHONHOME".to_owned(), "C:\\wrong".to_owned());
|
|
user_env.insert("PYTHONUNBUFFERED".to_owned(), "0".to_owned());
|
|
user_env.insert(
|
|
"PYTHONPATH".to_owned(),
|
|
"C:\\explicit-python-path".to_owned(),
|
|
);
|
|
|
|
let prepared = prepare_script_environment(
|
|
temp.path().to_str().unwrap(),
|
|
"python.exe",
|
|
None,
|
|
&user_env,
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(!prepared.environment.contains_key("PYTHONHOME"));
|
|
assert_eq!(
|
|
prepared.environment.get("PYTHONUNBUFFERED"),
|
|
Some(&"1".to_owned())
|
|
);
|
|
assert_eq!(
|
|
prepared.environment.get("PYTHONPATH"),
|
|
Some(&"C:\\explicit-python-path".to_owned())
|
|
);
|
|
let paths = env::split_paths(
|
|
prepared
|
|
.environment
|
|
.get("PATH")
|
|
.expect("venv PATH should be present"),
|
|
)
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(paths.first(), Some(&venv.join("Scripts")));
|
|
assert!(paths.contains(&temp.path().join("custom-bin")));
|
|
}
|
|
|
|
#[test]
|
|
fn global_python_removes_stale_virtual_env_markers() {
|
|
let temp = TempDir::new().unwrap();
|
|
let prepared = prepare_script_environment(
|
|
temp.path().to_str().unwrap(),
|
|
"python.exe",
|
|
None,
|
|
&BTreeMap::new(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(prepared.virtual_env, None);
|
|
assert!(prepared.remove_environment.contains(&"VIRTUAL_ENV"));
|
|
assert!(prepared.remove_environment.contains(&"VIRTUAL_ENV_PROMPT"));
|
|
}
|
|
|
|
#[test]
|
|
fn injects_utf8_stdio_for_python_tasks() {
|
|
let temp = TempDir::new().unwrap();
|
|
|
|
let prepared = prepare_script_environment(
|
|
temp.path().to_str().unwrap(),
|
|
"python.exe",
|
|
None,
|
|
&BTreeMap::new(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
prepared.environment.get("PYTHONIOENCODING"),
|
|
Some(&"utf-8".to_owned())
|
|
);
|
|
assert_eq!(
|
|
prepared.environment.get("PYTHONUNBUFFERED"),
|
|
Some(&"1".to_owned())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_user_pythonioencoding_override() {
|
|
let temp = TempDir::new().unwrap();
|
|
let mut user_env = BTreeMap::new();
|
|
user_env.insert("PYTHONIOENCODING".to_owned(), "gbk".to_owned());
|
|
|
|
let prepared = prepare_script_environment(
|
|
temp.path().to_str().unwrap(),
|
|
"python.exe",
|
|
None,
|
|
&user_env,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
prepared.environment.get("PYTHONIOENCODING"),
|
|
Some(&"gbk".to_owned())
|
|
);
|
|
}
|
|
|
|
fn probe(working_dir: &str, interpreter: &str, venv: Option<&str>) -> VenvProbeResponse {
|
|
probe_venv(&VenvProbeRequest {
|
|
working_dir: working_dir.to_owned(),
|
|
interpreter: interpreter.to_owned(),
|
|
venv: venv.map(str::to_owned),
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn probe_links_venv_detected_in_working_directory() {
|
|
let temp = TempDir::new().unwrap();
|
|
create_fake_venv(temp.path());
|
|
|
|
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
|
|
|
|
assert_eq!(response.status, VenvProbeStatus::Linked);
|
|
assert_eq!(response.source, Some(VenvSource::WorkingDirectory));
|
|
assert_eq!(response.interpreter_kind, InterpreterKind::Python);
|
|
let expected = Path::new(".venv")
|
|
.join("Scripts")
|
|
.join("python.exe")
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
assert_eq!(response.display_path, Some(expected));
|
|
}
|
|
|
|
#[test]
|
|
fn probe_prefers_configured_venv_over_detection() {
|
|
let temp = TempDir::new().unwrap();
|
|
let project = temp.path().join("project");
|
|
fs::create_dir_all(&project).unwrap();
|
|
let venv = create_fake_venv(&project);
|
|
|
|
let response = probe(
|
|
project.to_str().unwrap(),
|
|
"python.exe",
|
|
Some(venv.to_str().unwrap()),
|
|
);
|
|
|
|
assert_eq!(response.status, VenvProbeStatus::Linked);
|
|
assert_eq!(response.source, Some(VenvSource::Configured));
|
|
}
|
|
|
|
#[test]
|
|
fn probe_reports_system_interpreter_when_no_venv_exists() {
|
|
let temp = TempDir::new().unwrap();
|
|
|
|
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
|
|
|
|
assert_eq!(response.status, VenvProbeStatus::Missing);
|
|
assert_eq!(response.display_path.as_deref(), Some("python.exe"));
|
|
assert!(response.venv_path.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn probe_rejects_invalid_configured_venv() {
|
|
let temp = TempDir::new().unwrap();
|
|
let missing = temp.path().join("missing-venv");
|
|
|
|
let response = probe(
|
|
temp.path().to_str().unwrap(),
|
|
"python.exe",
|
|
Some(missing.to_str().unwrap()),
|
|
);
|
|
|
|
assert_eq!(response.status, VenvProbeStatus::Invalid);
|
|
assert!(response.message.contains("pyvenv.cfg"));
|
|
}
|
|
|
|
#[test]
|
|
fn probe_marks_non_python_interpreters_as_not_applicable() {
|
|
let temp = TempDir::new().unwrap();
|
|
let working_dir = temp.path().to_str().unwrap();
|
|
|
|
let node = probe(working_dir, "node.exe", None);
|
|
assert_eq!(node.status, VenvProbeStatus::NotApplicable);
|
|
assert_eq!(node.interpreter_kind, InterpreterKind::Node);
|
|
|
|
let shell = probe(working_dir, "cmd.exe", None);
|
|
assert_eq!(shell.status, VenvProbeStatus::NotApplicable);
|
|
assert_eq!(shell.interpreter_kind, InterpreterKind::Other);
|
|
}
|
|
|
|
#[test]
|
|
fn probe_requires_existing_working_directory() {
|
|
let temp = TempDir::new().unwrap();
|
|
|
|
let empty = probe(" ", "python.exe", None);
|
|
assert_eq!(empty.status, VenvProbeStatus::Incomplete);
|
|
assert!(empty.message.contains("工作目录"));
|
|
|
|
let missing = temp.path().join("absent");
|
|
let response = probe(missing.to_str().unwrap(), "python.exe", None);
|
|
assert_eq!(response.status, VenvProbeStatus::Incomplete);
|
|
assert!(response.message.contains("工作目录"));
|
|
}
|
|
|
|
#[test]
|
|
fn probe_matches_execution_environment_for_same_input() {
|
|
let temp = TempDir::new().unwrap();
|
|
let venv = create_fake_venv(temp.path());
|
|
let working_dir = temp.path().to_str().unwrap();
|
|
|
|
let prepared =
|
|
prepare_script_environment(working_dir, "python.exe", None, &BTreeMap::new()).unwrap();
|
|
let response = probe(working_dir, "python.exe", None);
|
|
|
|
assert_eq!(prepared.virtual_env.as_deref(), Some(venv.as_path()));
|
|
assert_eq!(response.venv_path.as_deref(), Some(venv.to_str().unwrap()));
|
|
assert_eq!(response.status, VenvProbeStatus::Linked);
|
|
}
|
|
}
|