feat: 解决python虚拟环境问题
This commit is contained in:
@@ -30,6 +30,8 @@ React 页面沿用该原型作为视觉基线:1040px 主容器、紧凑指标
|
||||
- `/api/v1/events` 使用 SSE 推送运行状态和日志增量,前端断线时自动回退到轮询。
|
||||
- 运行历史支持按状态和触发方式筛选,并支持下载完整日志。
|
||||
- 敏感环境变量和敏感 Header 的值不会写入系统日志,输出中的匹配值会替换为 `[REDACTED]`。
|
||||
- Python 脚本支持显式虚拟环境配置,并会自动向上探测 `.venv`、`venv`、`env`。
|
||||
- 执行前会校验 `pyvenv.cfg` 和 `Scripts/python.exe`,优先使用虚拟环境解释器,并注入 PATH、`VIRTUAL_ENV` 和无缓冲输出设置。
|
||||
- 日志支持保留期限和总容量清理,单次日志仍有 5 MiB 上限。
|
||||
- React 页面已接入真实任务列表、搜索筛选、分页、CRUD、启停、手动运行、运行历史和日志轮询。
|
||||
- 三类 Action 使用不同动态表单,并以类型化载荷提交,不再固定为脚本任务。
|
||||
@@ -345,12 +347,14 @@ task_pilot/
|
||||
|
||||
- [ADR-0001:Cron 解析与任务调度](docs/decisions/0001-cron-engine.md)
|
||||
- [ADR-0002:前后端开发与生产托管](docs/decisions/0002-static-hosting.md)
|
||||
- [ADR-0003:Python 虚拟环境启动环境](docs/decisions/0003-python-environment.md)
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 重试策略字段已持久化,但自动重试尚未实现。
|
||||
- HTTP 任务执行器尚未提供响应 Header 转换等高级断言,只校验状态码。
|
||||
- 敏感值脱敏基于变量名关键词和值替换,不是系统凭据保险箱。
|
||||
- Conda 环境不会按 venv 自动激活,目前需要显式填写其 Python 解释器路径。
|
||||
- 当前版本是本地单用户工具,不支持多用户账户和远程部署。
|
||||
- 尚未安装为 Windows 系统服务,关闭启动脚本后调度器也会停止。
|
||||
- 旧 `task_pilot.html` 暂时保留在根目录作为设计参考,正式入口为 React 应用。
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod cron;
|
||||
pub mod database;
|
||||
pub mod error;
|
||||
pub mod process_job;
|
||||
pub mod python_env;
|
||||
pub mod runs;
|
||||
pub mod scheduler;
|
||||
pub mod security;
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env,
|
||||
ffi::OsString,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
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 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()?;
|
||||
let detected_venv = explicit_venv
|
||||
.as_deref()
|
||||
.map(validate_venv)
|
||||
.transpose()?
|
||||
.flatten()
|
||||
.or_else(|| detect_venv_from_interpreter(&interpreter_path))
|
||||
.or_else(|| {
|
||||
is_generic_python(interpreter)
|
||||
.then(|| detect_venv_from_ancestors(&working_directory))
|
||||
.flatten()
|
||||
});
|
||||
|
||||
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(),
|
||||
);
|
||||
environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
|
||||
return Ok(PreparedPython {
|
||||
interpreter: python,
|
||||
virtual_env: Some(venv.clone()),
|
||||
environment,
|
||||
remove_environment: vec![
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
"VIRTUAL_ENV",
|
||||
"VIRTUAL_ENV_PROMPT",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
|
||||
Ok(PreparedPython {
|
||||
interpreter: interpreter_path,
|
||||
virtual_env: None,
|
||||
environment,
|
||||
remove_environment: vec!["VIRTUAL_ENV", "VIRTUAL_ENV_PROMPT"],
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ use tokio_util::sync::CancellationToken;
|
||||
use crate::{
|
||||
error::ApiResult,
|
||||
process_job::ProcessJob,
|
||||
python_env::prepare_script_environment,
|
||||
runs::{
|
||||
model::{RunStatus, RunTrigger, TaskRunResponse},
|
||||
repository,
|
||||
@@ -291,18 +292,32 @@ impl TaskExecutor {
|
||||
match &task.action {
|
||||
TaskAction::Script {
|
||||
working_dir,
|
||||
venv,
|
||||
interpreter,
|
||||
script,
|
||||
args,
|
||||
env,
|
||||
} => {
|
||||
let mut command = Command::new(interpreter);
|
||||
command
|
||||
.arg(script)
|
||||
.args(args)
|
||||
.envs(env)
|
||||
.current_dir(working_dir);
|
||||
self.run_command(task, run_id, cancellation, command).await
|
||||
let prepared = match prepare_script_environment(
|
||||
working_dir,
|
||||
interpreter,
|
||||
venv.as_deref(),
|
||||
env,
|
||||
) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
return failed_outcome(format!("准备 Python 运行环境失败: {error}"));
|
||||
}
|
||||
};
|
||||
let mut command = Command::new(&prepared.interpreter);
|
||||
prepared.apply(&mut command);
|
||||
command.arg(script).args(args).current_dir(working_dir);
|
||||
let startup_note = prepared
|
||||
.virtual_env
|
||||
.as_ref()
|
||||
.map(|venv| format!("使用 Python 虚拟环境: {}", venv.display()));
|
||||
self.run_command(task, run_id, cancellation, command, startup_note)
|
||||
.await
|
||||
}
|
||||
TaskAction::Process {
|
||||
executable,
|
||||
@@ -315,7 +330,8 @@ impl TaskExecutor {
|
||||
if let Some(working_dir) = working_dir {
|
||||
command.current_dir(working_dir);
|
||||
}
|
||||
self.run_command(task, run_id, cancellation, command).await
|
||||
self.run_command(task, run_id, cancellation, command, None)
|
||||
.await
|
||||
}
|
||||
TaskAction::Http { .. } => self.run_http(task, run_id, cancellation).await,
|
||||
}
|
||||
@@ -327,6 +343,7 @@ impl TaskExecutor {
|
||||
run_id: i64,
|
||||
cancellation: &CancellationToken,
|
||||
mut command: Command,
|
||||
startup_note: Option<String>,
|
||||
) -> ScriptOutcome {
|
||||
let log_path = self.log_path(&task.id, run_id);
|
||||
let secrets = sensitive_values(&task.action);
|
||||
@@ -342,6 +359,9 @@ impl TaskExecutor {
|
||||
Ok(sink) => sink,
|
||||
Err(error) => return failed_outcome(format!("无法创建运行日志: {error}")),
|
||||
};
|
||||
if let Some(note) = startup_note {
|
||||
sink.system(¬e).await;
|
||||
}
|
||||
|
||||
let job = match ProcessJob::new() {
|
||||
Ok(job) => job,
|
||||
|
||||
@@ -176,6 +176,7 @@ mod tests {
|
||||
retry_policy: RetryPolicy::default(),
|
||||
action: TaskAction::Script {
|
||||
working_dir: ".".to_owned(),
|
||||
venv: None,
|
||||
interpreter: "cmd.exe".to_owned(),
|
||||
script: "/C".to_owned(),
|
||||
args: vec!["exit".to_owned(), "0".to_owned()],
|
||||
|
||||
@@ -55,6 +55,8 @@ pub struct RetryPolicy {
|
||||
pub enum TaskAction {
|
||||
Script {
|
||||
working_dir: String,
|
||||
#[serde(default)]
|
||||
venv: Option<String>,
|
||||
interpreter: String,
|
||||
script: String,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -7,6 +7,7 @@ use croner::Cron;
|
||||
use sqlx::SqlitePool;
|
||||
use url::Url;
|
||||
|
||||
use crate::python_env::validate_configured_venv;
|
||||
use crate::{error::ApiResult, tasks::model::*};
|
||||
|
||||
use super::repository::{self, ListFilters};
|
||||
@@ -182,12 +183,22 @@ fn validate_action(action: &TaskAction) -> ApiResult<()> {
|
||||
match action {
|
||||
TaskAction::Script {
|
||||
working_dir,
|
||||
venv,
|
||||
interpreter,
|
||||
script,
|
||||
args,
|
||||
env,
|
||||
} => {
|
||||
validate_required("action.working_dir", working_dir)?;
|
||||
if let Some(venv) = venv {
|
||||
validate_required("action.venv", venv)?;
|
||||
validate_configured_venv(working_dir, venv).map_err(|error| {
|
||||
crate::error::ApiError::validation(
|
||||
"action.venv",
|
||||
format!("虚拟环境无效: {error}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
validate_required("action.interpreter", interpreter)?;
|
||||
validate_required("action.script", script)?;
|
||||
validate_args(args)?;
|
||||
@@ -319,6 +330,7 @@ mod tests {
|
||||
fn script_action() -> TaskAction {
|
||||
TaskAction::Script {
|
||||
working_dir: "D:\\Projects\\sync".to_owned(),
|
||||
venv: None,
|
||||
interpreter: "python.exe".to_owned(),
|
||||
script: "main.py".to_owned(),
|
||||
args: vec!["--mode".to_owned(), "full".to_owned()],
|
||||
@@ -358,6 +370,7 @@ mod tests {
|
||||
fn rejects_empty_script_path() {
|
||||
let action = TaskAction::Script {
|
||||
working_dir: "D:\\Projects\\sync".to_owned(),
|
||||
venv: None,
|
||||
interpreter: "python.exe".to_owned(),
|
||||
script: " ".to_owned(),
|
||||
args: Vec::new(),
|
||||
|
||||
@@ -275,6 +275,77 @@ async fn manual_script_run_captures_exit_code_and_logs() {
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_detected_venv_injects_activation_environment() {
|
||||
let app = test_app().await;
|
||||
let project = app._temp_dir.path().join("project");
|
||||
let working_dir = project.join("src");
|
||||
let venv = project.join(".venv");
|
||||
std::fs::create_dir_all(&working_dir).unwrap();
|
||||
std::fs::create_dir_all(venv.join("Scripts")).unwrap();
|
||||
std::fs::write(venv.join("pyvenv.cfg"), "home = C:\\Python").unwrap();
|
||||
let command_shell = std::env::var_os("COMSPEC")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("C:\\Windows\\System32\\cmd.exe"));
|
||||
std::fs::copy(command_shell, venv.join("Scripts").join("python.exe")).unwrap();
|
||||
|
||||
let task = json!({
|
||||
"id": "venv_task",
|
||||
"name": "Venv task",
|
||||
"cron": "0 0 2 * * *",
|
||||
"timeout_secs": 5,
|
||||
"action": {
|
||||
"type": "script",
|
||||
"working_dir": working_dir,
|
||||
"interpreter": "python.exe",
|
||||
"script": "/C",
|
||||
"args": [
|
||||
"echo",
|
||||
"VIRTUAL=%VIRTUAL_ENV%;UNBUFFERED=%PYTHONUNBUFFERED%;HOME=%PYTHONHOME%;CUSTOM=%CUSTOM%"
|
||||
],
|
||||
"env": {
|
||||
"PYTHONHOME": "C:\\wrong-python-home",
|
||||
"PYTHONUNBUFFERED": "0",
|
||||
"CUSTOM": "yes"
|
||||
}
|
||||
}
|
||||
});
|
||||
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
let run = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/venv_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let run_id = response_json(run).await["id"].as_i64().unwrap();
|
||||
assert_eq!(
|
||||
wait_for_run(&app.router, run_id).await["status"],
|
||||
"succeeded"
|
||||
);
|
||||
let log = response_json(
|
||||
send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
&format!("/api/v1/runs/{run_id}/logs"),
|
||||
None,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
let content = log["content"].as_str().unwrap();
|
||||
assert!(
|
||||
content.contains(&format!(
|
||||
"VIRTUAL={};UNBUFFERED=1;HOME=%PYTHONHOME%;CUSTOM=yes",
|
||||
venv.display()
|
||||
)),
|
||||
"unexpected venv log output: {content}"
|
||||
);
|
||||
assert!(!content.contains("wrong-python-home"));
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_and_process_executors_run_through_the_api() {
|
||||
let app = test_app().await;
|
||||
|
||||
@@ -999,6 +999,12 @@
|
||||
"script"
|
||||
]
|
||||
},
|
||||
"venv": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"working_dir": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# ADR-0003:Python 虚拟环境启动环境
|
||||
|
||||
- 状态:已接受
|
||||
- 日期:2026-10-19
|
||||
|
||||
## 背景
|
||||
|
||||
直接执行虚拟环境中的 `python.exe` 只解决了当前进程使用哪个解释器,并不会自动完成命令行激活脚本所做的环境准备。脚本派生的子进程可能因此找不到虚拟环境内的工具,或受到宿主机全局 Python 环境变量污染。
|
||||
|
||||
## 决策
|
||||
|
||||
所有脚本任务在启动前统一经过后端 `python_env` 模块:
|
||||
|
||||
1. 只有解释器确认为 Python 时才应用 Python 专属环境。
|
||||
2. 支持显式配置虚拟环境目录;未配置时从工作目录向上搜索 `.venv`、`venv`、`env`。
|
||||
3. 虚拟环境必须同时存在 `pyvenv.cfg` 和 `Scripts/python.exe`,否则显式配置在任务保存时直接拒绝,自动探测则跳过无效目录。
|
||||
4. 优先使用虚拟环境自己的 `Scripts/python.exe`。
|
||||
5. 将 `Scripts` 目录置于 PATH 最前方,同时保留用户提供的 PATH 或宿主机 PATH,并按 Windows 大小写不敏感规则去重。
|
||||
6. 注入 `VIRTUAL_ENV` 和 `VIRTUAL_ENV_PROMPT`。
|
||||
7. 清理继承的 `PYTHONHOME`、`PYTHONPATH` 和旧的 `VIRTUAL_ENV` 标记。
|
||||
8. 虚拟环境模式禁止用户环境重新注入 `PYTHONHOME`;显式配置的 `PYTHONPATH` 仍可在清理继承污染后重新加入。
|
||||
9. 强制设置 `PYTHONUNBUFFERED=1`。
|
||||
10. 未能检测到虚拟环境时,平滑回退到配置的 Python 解释器。
|
||||
|
||||
## 原因
|
||||
|
||||
- PATH 是隔离子进程工具解析的关键,不只是当前 Python 解释器路径。
|
||||
- `VIRTUAL_ENV` 是第三方框架判断隔离环境的重要标识。
|
||||
- 继承的 Python 全局变量可能破坏 venv 的标准库解析。
|
||||
- 实时日志依赖 Python 标准流不被块缓冲。
|
||||
- 仅按目录名判断不可靠,必须验证 venv 结构。
|
||||
- Windows 环境变量大小写不敏感,必须主动处理 `PATH`/`Path` 重复。
|
||||
|
||||
## 边界
|
||||
|
||||
- Conda 环境不会被识别为标准 venv;当前可通过显式解释器路径运行,但不会自动设置 `CONDA_PREFIX`。
|
||||
- `PYTHONUNBUFFERED` 只影响 Python 标准流。第三方日志 Handler 或非 Python 子进程仍可能自行缓冲。
|
||||
- 虚拟环境路径必须是后端运行时可以访问的有效 Windows 目录。
|
||||
- 使用系统 Python 时不会伪造虚拟环境变量。
|
||||
@@ -67,11 +67,17 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
|
||||
await newTaskDialog
|
||||
.getByPlaceholder('参数值 (可选,无值可留空)')
|
||||
.fill('full')
|
||||
await newTaskDialog
|
||||
.getByLabel(/虚拟环境目录/)
|
||||
.fill('.venv')
|
||||
await newTaskDialog
|
||||
.getByText('调用预览 (Runtime Preview)')
|
||||
.scrollIntoViewIfNeeded()
|
||||
await expect(
|
||||
newTaskDialog.getByText('--mode full', { exact: false }),
|
||||
newTaskDialog.getByText(
|
||||
'.venv\\Scripts\\python.exe main.py --mode full',
|
||||
{ exact: false },
|
||||
),
|
||||
).toBeVisible()
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-task-editor-runtime.png',
|
||||
|
||||
@@ -302,6 +302,7 @@ export interface components {
|
||||
script: string;
|
||||
/** @enum {string} */
|
||||
type: "script";
|
||||
venv?: string | null;
|
||||
working_dir: string;
|
||||
} | {
|
||||
body?: string | null;
|
||||
|
||||
@@ -328,6 +328,16 @@ export function TaskEditorModal({
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field
|
||||
label="虚拟环境目录 (可选)"
|
||||
hint="留空时自动从工作目录及上级目录探测 .venv、venv、env"
|
||||
>
|
||||
<input
|
||||
{...register('script.venv')}
|
||||
className="field-input mono"
|
||||
placeholder="例如:.venv"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="脚本文件或相对路径"
|
||||
error={errors.script?.script?.message}
|
||||
@@ -402,6 +412,11 @@ export function TaskEditorModal({
|
||||
<span>Windows 原生环境</span>
|
||||
</div>
|
||||
<div className="cli-preview-body">
|
||||
{scriptPreview.venv.trim() ? (
|
||||
<div className="cli-line-env">
|
||||
set VIRTUAL_ENV={scriptPreview.venv.trim()}
|
||||
</div>
|
||||
) : null}
|
||||
{scriptPreview.env
|
||||
.filter((item) => item.key.trim())
|
||||
.map((item, index) => (
|
||||
@@ -416,7 +431,7 @@ export function TaskEditorModal({
|
||||
</div>
|
||||
))}
|
||||
<div className="cli-line-cmd">
|
||||
{scriptPreview.interpreter.trim() || 'python.exe'}{' '}
|
||||
{formatInterpreterPreview(scriptPreview)}{' '}
|
||||
{scriptPreview.script.trim() || 'main.py'}
|
||||
{formatArgumentPreview(scriptPreview.args)}
|
||||
</div>
|
||||
@@ -598,6 +613,15 @@ function formatArgumentPreview(argumentsList: KeyValueFormValue[]): string {
|
||||
return values.length ? ` ${values.join(' ')}` : ''
|
||||
}
|
||||
|
||||
function formatInterpreterPreview(
|
||||
script: TaskFormValues['script'],
|
||||
): string {
|
||||
if (script.venv.trim()) {
|
||||
return `${script.venv.trim().replace(/[\\/]+$/, '')}\\Scripts\\python.exe`
|
||||
}
|
||||
return script.interpreter.trim() || 'python.exe'
|
||||
}
|
||||
|
||||
function isSensitiveName(name: string): boolean {
|
||||
const normalized = name.toUpperCase()
|
||||
return [
|
||||
|
||||
@@ -6,6 +6,28 @@ import {
|
||||
} from './taskForm'
|
||||
|
||||
describe('task form payload', () => {
|
||||
it('submits an explicit Python virtual environment', () => {
|
||||
const values = createEmptyTaskFormValues()
|
||||
values.id = 'venv_task'
|
||||
values.name = 'Venv task'
|
||||
values.script.working_dir = 'D:\\Projects\\venv-task'
|
||||
values.script.venv = '.venv'
|
||||
values.script.interpreter = 'python.exe'
|
||||
values.script.script = 'main.py'
|
||||
values.script.args = [{ key: '--mode', value: 'full' }]
|
||||
|
||||
const payload = valuesToCreateRequest(values)
|
||||
|
||||
expect(payload.action).toMatchObject({
|
||||
type: 'script',
|
||||
working_dir: 'D:\\Projects\\venv-task',
|
||||
venv: '.venv',
|
||||
interpreter: 'python.exe',
|
||||
script: 'main.py',
|
||||
args: ['--mode', 'full'],
|
||||
})
|
||||
})
|
||||
|
||||
it('creates an HTTP action instead of forcing script type', () => {
|
||||
const values = createEmptyTaskFormValues()
|
||||
values.id = 'health_check'
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface TaskFormValues {
|
||||
actionType: TaskActionType
|
||||
script: {
|
||||
working_dir: string
|
||||
venv: string
|
||||
interpreter: string
|
||||
script: string
|
||||
args: KeyValueFormValue[]
|
||||
@@ -86,6 +87,7 @@ export const taskFormSchema = z
|
||||
actionType: z.enum(['script', 'http', 'process']),
|
||||
script: z.object({
|
||||
working_dir: z.string(),
|
||||
venv: z.string(),
|
||||
interpreter: z.string(),
|
||||
script: z.string(),
|
||||
args: z.array(keyValueSchema),
|
||||
@@ -168,6 +170,7 @@ export function createEmptyTaskFormValues(): TaskFormValues {
|
||||
actionType: 'script',
|
||||
script: {
|
||||
working_dir: '',
|
||||
venv: '',
|
||||
interpreter: 'python.exe',
|
||||
script: 'main.py',
|
||||
args: [],
|
||||
@@ -202,6 +205,7 @@ export function taskToFormValues(task: Task): TaskFormValues {
|
||||
if (task.action.type === 'script') {
|
||||
values.script = {
|
||||
working_dir: task.action.working_dir,
|
||||
venv: task.action.venv ?? '',
|
||||
interpreter: task.action.interpreter,
|
||||
script: task.action.script,
|
||||
args: argsToRows(task.action.args ?? []),
|
||||
@@ -240,6 +244,7 @@ export function valuesToAction(values: TaskFormValues): TaskAction {
|
||||
return {
|
||||
type: 'script',
|
||||
working_dir: values.script.working_dir.trim(),
|
||||
venv: values.script.venv.trim() || null,
|
||||
interpreter: values.script.interpreter.trim(),
|
||||
script: values.script.script.trim(),
|
||||
args: argumentsToArray(values.script.args),
|
||||
|
||||
Reference in New Issue
Block a user