feat: 支持 uv 从 pyproject/requirements 创建虚拟环境
- 工作目录探测新增 pyproject.toml / requirements*.txt 扫描、uv 可用性与 can_create - 新增 POST /api/v1/python/venv/create:已有虚拟环境优先复用;pyproject 走 uv sync,requirements 走 uv venv + uv pip install,并使用默认 PyPI 镜像 - 编辑器在缺少虚拟环境且具备依赖文件时显示「用 uv 创建环境」;没有 uv 或没有依赖文件时明确报错 - 解释器字段改为可选,留空由服务端默认 python.exe 并使用虚拟环境;Python 任务缺少虚拟环境/依赖文件时运行直接失败,不再回退系统解释器 - 补后端探测/创建测试、前端入口测试与 README
This commit is contained in:
+34
-1
@@ -18,7 +18,8 @@ use crate::{
|
||||
},
|
||||
error::{ApiError, ApiJson, ApiQuery, ApiResult, ErrorResponse},
|
||||
python_env::{
|
||||
self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource,
|
||||
self, CreatePythonVenvRequest, InterpreterKind, VenvProbeRequest, VenvProbeResponse,
|
||||
VenvProbeStatus, VenvSource,
|
||||
},
|
||||
python_mirrors::{
|
||||
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
|
||||
@@ -144,6 +145,32 @@ pub async fn probe_python_venv(
|
||||
.map_err(ApiError::internal)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/python/venv/create",
|
||||
tag = "system",
|
||||
request_body = CreatePythonVenvRequest,
|
||||
responses(
|
||||
(status = 200, description = "Created or reused project virtual environment", body = VenvProbeResponse),
|
||||
(status = 400, description = "Project cannot create a virtual environment", body = ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn create_python_venv(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<CreatePythonVenvRequest>,
|
||||
) -> ApiResult<Json<VenvProbeResponse>> {
|
||||
let mirror = crate::python_mirrors::repository::default_enabled(&state.pool)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::warn!(error = %error, "读取默认 Python 镜像失败,uv 将使用自身配置");
|
||||
None
|
||||
});
|
||||
python_env::create_project_venv(&request.working_dir, mirror.as_ref())
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|message| ApiError::bad_request("VENV_CREATE_FAILED", message))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/system/directories",
|
||||
@@ -227,6 +254,7 @@ pub async fn list_schedule(
|
||||
session,
|
||||
preview_cron,
|
||||
probe_python_venv,
|
||||
create_python_venv,
|
||||
list_python_mirrors,
|
||||
get_python_mirror,
|
||||
create_python_mirror,
|
||||
@@ -258,6 +286,7 @@ pub async fn list_schedule(
|
||||
CronPreviewResponse,
|
||||
VenvProbeRequest,
|
||||
VenvProbeResponse,
|
||||
CreatePythonVenvRequest,
|
||||
VenvProbeStatus,
|
||||
VenvSource,
|
||||
InterpreterKind,
|
||||
@@ -317,6 +346,10 @@ pub fn router(state: AppState) -> Router {
|
||||
"/api/v1/python/venv",
|
||||
axum::routing::post(probe_python_venv),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/python/venv/create",
|
||||
axum::routing::post(create_python_venv),
|
||||
)
|
||||
.route("/api/v1/system/directories", get(list_directories))
|
||||
.route("/api/v1/stats", get(task_stats))
|
||||
.route("/api/v1/schedule", get(list_schedule))
|
||||
|
||||
+313
-38
@@ -2,14 +2,20 @@ use std::{
|
||||
collections::BTreeMap,
|
||||
env,
|
||||
ffi::OsString,
|
||||
io,
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
use tokio::{
|
||||
process::Command,
|
||||
time::{Duration, timeout},
|
||||
};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::python_mirrors::model::DefaultPythonMirror;
|
||||
|
||||
const VENV_DIRECTORY_NAMES: [&str; 3] = [".venv", "venv", "env"];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -230,76 +236,152 @@ pub struct VenvProbeResponse {
|
||||
pub display_path: Option<String>,
|
||||
pub source: Option<VenvSource>,
|
||||
pub message: String,
|
||||
/// 本机 PATH 中是否能找到 uv
|
||||
pub uv_available: bool,
|
||||
/// 工作目录根部是否存在 pyproject.toml
|
||||
pub has_pyproject: bool,
|
||||
/// 工作目录根部是否存在 requirements*.txt
|
||||
pub has_requirements: bool,
|
||||
/// 探测到的 requirements 文件名(仅根目录)
|
||||
pub requirements_files: Vec<String>,
|
||||
/// 没有虚拟环境时,是否具备用 uv 创建的条件
|
||||
pub can_create: bool,
|
||||
}
|
||||
|
||||
/// 用 uv 为项目创建 `.venv`(pyproject 用 uv sync,requirements 用 uv venv + uv pip install)。
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreatePythonVenvRequest {
|
||||
pub working_dir: String,
|
||||
}
|
||||
|
||||
pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
|
||||
let working_dir = request.working_dir.trim();
|
||||
let interpreter = request.interpreter.trim();
|
||||
let interpreter = {
|
||||
let trimmed = request.interpreter.trim();
|
||||
if trimmed.is_empty() {
|
||||
"python.exe"
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
};
|
||||
let configured_venv = request
|
||||
.venv
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let interpreter_kind = classify_interpreter(interpreter);
|
||||
let uv = uv_available();
|
||||
let respond = |response: VenvProbeResponse| finalize_probe(response, working_dir, uv);
|
||||
|
||||
if working_dir.is_empty() {
|
||||
return incomplete_probe(interpreter_kind, "请先填写工作目录");
|
||||
}
|
||||
if interpreter.is_empty() {
|
||||
return incomplete_probe(interpreter_kind, "请先填写解释器或可执行程序");
|
||||
return respond(incomplete_probe(interpreter_kind, "请先填写工作目录"));
|
||||
}
|
||||
if !matches!(interpreter_kind, InterpreterKind::Python) {
|
||||
return VenvProbeResponse {
|
||||
return respond(VenvProbeResponse {
|
||||
status: VenvProbeStatus::NotApplicable,
|
||||
interpreter_kind,
|
||||
venv_path: None,
|
||||
display_path: Some(interpreter.to_owned()),
|
||||
source: None,
|
||||
message: "当前解释器不是 Python,不需要虚拟环境".to_owned(),
|
||||
};
|
||||
..base_probe()
|
||||
});
|
||||
}
|
||||
|
||||
let working_directory = match absolute_path(Path::new(working_dir)) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
return incomplete_probe(interpreter_kind, &format!("工作目录无效: {error}"));
|
||||
return respond(incomplete_probe(
|
||||
interpreter_kind,
|
||||
&format!("工作目录无效: {error}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
if !working_directory.is_dir() {
|
||||
return incomplete_probe(interpreter_kind, "工作目录不存在,暂时无法探测虚拟环境");
|
||||
return respond(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(),
|
||||
respond(
|
||||
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),
|
||||
..base_probe()
|
||||
},
|
||||
Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse {
|
||||
status: VenvProbeStatus::Missing,
|
||||
interpreter_kind,
|
||||
venv_path: None,
|
||||
display_path: Some(interpreter.to_owned()),
|
||||
source: None,
|
||||
message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(),
|
||||
..base_probe()
|
||||
},
|
||||
Err(error) => VenvProbeResponse {
|
||||
status: VenvProbeStatus::Invalid,
|
||||
interpreter_kind,
|
||||
venv_path: None,
|
||||
display_path: None,
|
||||
source: None,
|
||||
message: error.to_string(),
|
||||
..base_probe()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn base_probe() -> VenvProbeResponse {
|
||||
VenvProbeResponse {
|
||||
status: VenvProbeStatus::Incomplete,
|
||||
interpreter_kind: InterpreterKind::Other,
|
||||
venv_path: None,
|
||||
display_path: None,
|
||||
source: None,
|
||||
message: String::new(),
|
||||
uv_available: false,
|
||||
has_pyproject: false,
|
||||
has_requirements: false,
|
||||
requirements_files: Vec::new(),
|
||||
can_create: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_probe(
|
||||
mut response: VenvProbeResponse,
|
||||
working_dir: &str,
|
||||
uv_available: bool,
|
||||
) -> VenvProbeResponse {
|
||||
let files = scan_project_files(working_dir);
|
||||
response.uv_available = uv_available;
|
||||
response.has_pyproject = files.has_pyproject;
|
||||
response.has_requirements = !files.requirements.is_empty();
|
||||
response.requirements_files = files.requirements;
|
||||
response.can_create = response.status == VenvProbeStatus::Missing
|
||||
&& uv_available
|
||||
&& (response.has_pyproject || response.has_requirements);
|
||||
if response.status == VenvProbeStatus::Missing {
|
||||
response.message = if response.can_create {
|
||||
"未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv".to_owned()
|
||||
} else if !uv_available {
|
||||
"未检测到虚拟环境;本机未安装 uv,请先手动创建 .venv".to_owned()
|
||||
} else {
|
||||
"未检测到虚拟环境,也没有 pyproject.toml / requirements.txt,无法自动创建".to_owned()
|
||||
};
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvProbeResponse {
|
||||
VenvProbeResponse {
|
||||
status: VenvProbeStatus::Incomplete,
|
||||
@@ -308,9 +390,177 @@ fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvPro
|
||||
display_path: None,
|
||||
source: None,
|
||||
message: message.to_owned(),
|
||||
..base_probe()
|
||||
}
|
||||
}
|
||||
|
||||
/// 工作目录根部的 Python 依赖文件(不递归子目录)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProjectFiles {
|
||||
pub has_pyproject: bool,
|
||||
pub requirements: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn scan_project_files(working_dir: &str) -> ProjectFiles {
|
||||
let mut files = ProjectFiles::default();
|
||||
let Ok(entries) = fs::read_dir(working_dir) else {
|
||||
return files;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower == "pyproject.toml" {
|
||||
files.has_pyproject = true;
|
||||
} else if lower.starts_with("requirements") && lower.ends_with(".txt") {
|
||||
files.requirements.push(name);
|
||||
}
|
||||
}
|
||||
files.requirements.sort();
|
||||
files
|
||||
}
|
||||
|
||||
/// 本机 PATH 中是否有可用的 uv。
|
||||
pub fn uv_available() -> bool {
|
||||
let mut command = std::process::Command::new("uv");
|
||||
command
|
||||
.arg("--version")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x0800_0000);
|
||||
}
|
||||
command.status().is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
/// 用 uv 创建/同步项目虚拟环境。优先返回已有环境。
|
||||
pub async fn create_project_venv(
|
||||
working_dir: &str,
|
||||
mirror: Option<&DefaultPythonMirror>,
|
||||
) -> Result<VenvProbeResponse, String> {
|
||||
let working_directory =
|
||||
absolute_path(Path::new(working_dir)).map_err(|error| format!("工作目录无效: {error}"))?;
|
||||
if !working_directory.is_dir() {
|
||||
return Err("工作目录不存在,无法创建虚拟环境".to_owned());
|
||||
}
|
||||
|
||||
let probe_request = VenvProbeRequest {
|
||||
working_dir: working_dir.to_owned(),
|
||||
interpreter: "python.exe".to_owned(),
|
||||
venv: None,
|
||||
};
|
||||
let existing = probe_venv(&probe_request);
|
||||
if existing.status == VenvProbeStatus::Linked {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let files = scan_project_files(working_dir);
|
||||
if !files.has_pyproject && files.requirements.is_empty() {
|
||||
return Err(
|
||||
"项目没有虚拟环境,也没有 pyproject.toml / requirements.txt,无法创建".to_owned(),
|
||||
);
|
||||
}
|
||||
if !uv_available() {
|
||||
return Err("本机 PATH 中找不到 uv,无法自动创建虚拟环境;请手动创建 .venv".to_owned());
|
||||
}
|
||||
|
||||
if files.has_pyproject {
|
||||
run_uv(&working_directory, &["sync"], mirror).await?;
|
||||
} else {
|
||||
run_uv(&working_directory, &["venv", ".venv"], mirror).await?;
|
||||
let python = working_directory
|
||||
.join(".venv")
|
||||
.join("Scripts")
|
||||
.join("python.exe");
|
||||
let python = python.to_string_lossy().into_owned();
|
||||
for requirement in &files.requirements {
|
||||
run_uv(
|
||||
&working_directory,
|
||||
&["pip", "install", "--python", &python, "-r", requirement],
|
||||
mirror,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let probe = probe_venv(&probe_request);
|
||||
if probe.status != VenvProbeStatus::Linked {
|
||||
return Err(format!(
|
||||
"uv 执行完成,但没有找到有效的 .venv:{}",
|
||||
probe.message
|
||||
));
|
||||
}
|
||||
Ok(probe)
|
||||
}
|
||||
|
||||
async fn run_uv(
|
||||
working_dir: &Path,
|
||||
args: &[&str],
|
||||
mirror: Option<&DefaultPythonMirror>,
|
||||
) -> Result<String, String> {
|
||||
let mut command = Command::new("uv");
|
||||
command
|
||||
.args(args)
|
||||
.current_dir(working_dir)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(mirror) = mirror {
|
||||
command
|
||||
.env("PIP_INDEX_URL", &mirror.index_url)
|
||||
.env("UV_INDEX_URL", &mirror.index_url)
|
||||
.env("UV_DEFAULT_INDEX", &mirror.index_url);
|
||||
if let Some(host) = mirror
|
||||
.trusted_host
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
command.env("PIP_TRUSTED_HOST", host);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
command.creation_flags(0x0800_0000);
|
||||
|
||||
let output = timeout(Duration::from_secs(600), command.output())
|
||||
.await
|
||||
.map_err(|_| format!("uv {} 超时(600 秒)", args.join(" ")))?
|
||||
.map_err(|error| format!("无法启动 uv: {error}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !output.status.success() {
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout.trim()
|
||||
} else {
|
||||
stderr.trim()
|
||||
};
|
||||
return Err(format!(
|
||||
"uv {} 失败: {}",
|
||||
args.join(" "),
|
||||
truncate_output(detail)
|
||||
));
|
||||
}
|
||||
Ok(stdout.into_owned())
|
||||
}
|
||||
|
||||
fn truncate_output(value: &str) -> String {
|
||||
const MAX_CHARS: usize = 1200;
|
||||
if value.chars().count() <= MAX_CHARS {
|
||||
return value.to_owned();
|
||||
}
|
||||
let mut truncated = value.chars().take(MAX_CHARS).collect::<String>();
|
||||
truncated.push('…');
|
||||
truncated
|
||||
}
|
||||
|
||||
fn venv_source_message(source: Option<VenvSource>) -> String {
|
||||
match source {
|
||||
Some(VenvSource::Configured) => "已关联手动指定的虚拟环境".to_owned(),
|
||||
@@ -735,6 +985,31 @@ mod tests {
|
||||
assert_eq!(response.status, VenvProbeStatus::Missing);
|
||||
assert_eq!(response.display_path.as_deref(), Some("python.exe"));
|
||||
assert!(response.venv_path.is_none());
|
||||
assert!(!response.can_create);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_project_dependency_files_for_uv_creation() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
fs::write(temp.path().join("pyproject.toml"), "[project]\n").unwrap();
|
||||
fs::write(temp.path().join("requirements-dev.txt"), "").unwrap();
|
||||
|
||||
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
|
||||
|
||||
assert_eq!(response.status, VenvProbeStatus::Missing);
|
||||
assert!(response.has_pyproject);
|
||||
assert!(response.has_requirements);
|
||||
assert_eq!(response.requirements_files, vec!["requirements-dev.txt"]);
|
||||
assert_eq!(response.can_create, response.uv_available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_does_not_offer_uv_without_dependency_files() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
|
||||
|
||||
assert!(!response.can_create);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -451,6 +451,17 @@ impl TaskExecutor {
|
||||
let mut notes = Vec::new();
|
||||
if let Some(venv) = &prepared.virtual_env {
|
||||
notes.push(format!("使用 Python 虚拟环境: {}", venv.display()));
|
||||
} else if is_python_interpreter(interpreter) {
|
||||
// Python 任务强制使用项目虚拟环境:没有 .venv 时不再悄悄退回系统解释器。
|
||||
let project = crate::python_env::scan_project_files(working_dir);
|
||||
if project.has_pyproject || !project.requirements.is_empty() {
|
||||
return failed_outcome(
|
||||
"项目有 pyproject.toml / requirements.txt,但还没有 .venv;请先在编辑器中用 uv 创建虚拟环境",
|
||||
);
|
||||
}
|
||||
return failed_outcome(
|
||||
"项目没有虚拟环境,也没有 pyproject.toml / requirements.txt;请先创建 .venv 或补充依赖文件",
|
||||
);
|
||||
}
|
||||
if is_python_interpreter(interpreter) {
|
||||
match crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
|
||||
@@ -462,6 +473,9 @@ impl TaskExecutor {
|
||||
if !env_has(env, "UV_INDEX_URL") {
|
||||
command.env("UV_INDEX_URL", &mirror.index_url);
|
||||
}
|
||||
if !env_has(env, "UV_DEFAULT_INDEX") {
|
||||
command.env("UV_DEFAULT_INDEX", &mirror.index_url);
|
||||
}
|
||||
if let Some(host) = mirror
|
||||
.trusted_host
|
||||
.as_deref()
|
||||
|
||||
@@ -28,6 +28,7 @@ pub async fn create(pool: &SqlitePool, mut request: CreateTaskRequest) -> ApiRes
|
||||
request.name = request.name.trim().to_owned();
|
||||
request.cron = request.cron.trim().to_owned();
|
||||
request.timezone = request.timezone.trim().to_owned();
|
||||
normalize_action(&mut request.action);
|
||||
|
||||
validate_task_id(&request.id)?;
|
||||
validate_common(
|
||||
@@ -57,6 +58,7 @@ pub async fn update(
|
||||
request.name = request.name.trim().to_owned();
|
||||
request.cron = request.cron.trim().to_owned();
|
||||
request.timezone = request.timezone.trim().to_owned();
|
||||
normalize_action(&mut request.action);
|
||||
|
||||
validate_common(
|
||||
&request.name,
|
||||
@@ -180,6 +182,20 @@ fn validate_common(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_action(action: &mut TaskAction) {
|
||||
if let TaskAction::Script {
|
||||
venv, interpreter, ..
|
||||
} = action
|
||||
{
|
||||
if interpreter.trim().is_empty() {
|
||||
*interpreter = "python.exe".to_owned();
|
||||
}
|
||||
if venv.as_deref().is_some_and(|value| value.trim().is_empty()) {
|
||||
*venv = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_action(action: &TaskAction) -> ApiResult<()> {
|
||||
match action {
|
||||
TaskAction::Script {
|
||||
|
||||
@@ -47,9 +47,22 @@ async fn fetch_token(app: &Router) -> String {
|
||||
}
|
||||
|
||||
async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
|
||||
post_json(app, "/api/v1/python/venv", token, payload).await
|
||||
}
|
||||
|
||||
async fn post_create(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
|
||||
post_json(app, "/api/v1/python/venv/create", token, payload).await
|
||||
}
|
||||
|
||||
async fn post_json(
|
||||
app: &Router,
|
||||
uri: &str,
|
||||
token: Option<&str>,
|
||||
payload: Value,
|
||||
) -> (StatusCode, Value) {
|
||||
let mut builder = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/python/venv")
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("x-taskpilot-token", token);
|
||||
@@ -68,7 +81,7 @@ async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (Statu
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("probe body should be readable")
|
||||
.expect("response body should be readable")
|
||||
.to_bytes();
|
||||
|
||||
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
|
||||
@@ -179,3 +192,77 @@ async fn venv_probe_reports_missing_working_directory() {
|
||||
.contains("工作目录")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn venv_probe_reports_project_dependency_files() {
|
||||
let (app, temp_dir) = test_app().await;
|
||||
let token = fetch_token(&app).await;
|
||||
let project = temp_dir.path().join("deps-project");
|
||||
fs::create_dir_all(&project).expect("project directory should be created");
|
||||
fs::write(
|
||||
project.join("pyproject.toml"),
|
||||
"[project]\nname = \"demo\"\n",
|
||||
)
|
||||
.expect("pyproject.toml should be written");
|
||||
fs::write(project.join("requirements.txt"), "").expect("requirements.txt should be written");
|
||||
|
||||
let (status, body) = post_probe(
|
||||
&app,
|
||||
Some(&token),
|
||||
json!({
|
||||
"working_dir": project.to_str().expect("project path should be UTF-8"),
|
||||
"interpreter": "python.exe"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["status"], "missing");
|
||||
assert_eq!(body["has_pyproject"], true);
|
||||
assert_eq!(body["has_requirements"], true);
|
||||
assert_eq!(body["requirements_files"], json!(["requirements.txt"]));
|
||||
assert_eq!(body["can_create"], body["uv_available"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn venv_create_rejects_project_without_dependency_files() {
|
||||
let (app, temp_dir) = test_app().await;
|
||||
let token = fetch_token(&app).await;
|
||||
let project = temp_dir.path().join("empty-project");
|
||||
fs::create_dir_all(&project).expect("project directory should be created");
|
||||
|
||||
let (status, body) = post_create(
|
||||
&app,
|
||||
Some(&token),
|
||||
json!({
|
||||
"working_dir": project.to_str().expect("project path should be UTF-8")
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "VENV_CREATE_FAILED");
|
||||
assert!(
|
||||
body["message"]
|
||||
.as_str()
|
||||
.expect("message should be a string")
|
||||
.contains("pyproject.toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn venv_create_requires_management_token() {
|
||||
let (app, temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = post_create(
|
||||
&app,
|
||||
None,
|
||||
json!({
|
||||
"working_dir": temp_dir.path().to_str().expect("temp path should be UTF-8")
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["code"], "FORBIDDEN");
|
||||
}
|
||||
|
||||
@@ -113,6 +113,19 @@ fn script_task(id: &str, name: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_interpreter_defaults_to_python() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let mut payload = script_task("default_interpreter", "Default interpreter");
|
||||
payload["action"]["interpreter"] = json!("");
|
||||
|
||||
let response = send_json(&app, Method::POST, "/api/v1/tasks", Some(payload)).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let created = response_json(response).await;
|
||||
assert_eq!(created["action"]["interpreter"], "python.exe");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_crud_flow_works_end_to_end() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
Reference in New Issue
Block a user