diff --git a/README.md b/README.md index 1fd7047..7b49953 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000 | 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 | | 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 | | 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 | -| Python | 自动关联 `.venv`/`venv`/`env`、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、虚拟环境校验、默认 PyPI 镜像注入 | +| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、默认 PyPI 镜像注入 | | 界面 | 左侧导航(任务调度 / Python 镜像)、三类动态表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 | | 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 | | 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 | @@ -206,7 +206,7 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled ## Python 与虚拟环境 -解析顺序(执行期与编辑器探测共用同一份逻辑): +编辑器和执行器共用同一份解析逻辑: 1. 手动指定的 `action.venv` 2. 从解释器路径推断(`...\.venv\Scripts\python.exe`) @@ -214,6 +214,19 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled 虚拟环境必须同时存在 `pyvenv.cfg` 与 `Scripts\python.exe`,否则显式配置在保存时被拒绝。 +**没有虚拟环境时**: + +- 工作目录根部有 `pyproject.toml` 或 `requirements*.txt`,且 PATH 中能找到 `uv`: + 编辑器会显示「用 uv 创建环境」按钮,点击后 + `pyproject.toml` 走 `uv sync`,仅 requirements 走 `uv venv .venv` + `uv pip install -r`。 + 已有 `.venv`/`venv`/`env` 会优先复用,不会重复创建。创建时会使用当前默认 PyPI 镜像 + (`PIP_INDEX_URL` / `UV_DEFAULT_INDEX`),不会绕过镜像设置。 +- 没装 uv:必须手动准备虚拟环境;Python 任务缺少 `.venv` 时运行失败并给出提示。 +- 既没有虚拟环境、也没有 pyproject/requirements:编辑器显示错误,Python 任务运行失败。 + Python 任务不再静默回退到系统解释器。 +- 解释器字段是可选的:留空表示 Python,运行时会自动使用虚拟环境里的 `python.exe`; + Node / Shell 等非 Python 脚本再填写具体可执行程序。 + 命中虚拟环境后注入: | 变量 | 行为 | @@ -234,7 +247,8 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled - **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。 - **任务表**:名称/ID + 类型标签、时区、调度周期(Cron + **下次触发时间**)、状态、超时、操作。 - **编辑器**:三类动态表单、Cron 预览(实时计算未来 3/5/8/10 次)、时区下拉、虚拟环境状态条、调用预览; - 探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改。 + 探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改;缺少虚拟环境但项目有依赖文件时可直接用 uv 创建。 + 解释器字段可选,留空自动使用虚拟环境 Python。 - **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。 - **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。 - **Python 镜像**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。 @@ -293,7 +307,8 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled | `GET` | `/api/v1/session` | 获取当前进程的管理令牌 | | `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 | | `POST` | `/api/v1/cron/preview` | 计算未来若干次触发时间 | -| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境 | +| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境、uv 可用性与依赖文件 | +| `POST` | `/api/v1/python/venv/create` | 用 uv 创建/同步项目 `.venv`(优先复用已有环境) | | `GET` | `/api/v1/system/directories?path=` | 列盘符或子文件夹(目录浏览器) | | `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) | | `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 | @@ -391,7 +406,8 @@ Content-Type: application/json | `timezone` | 有效 IANA 名称 | | `timeout_secs` | 1–86400 | | `retry_policy` | `max_attempts ≤ 10`,`delay_secs ≤ 3600` | -| `action.working_dir` / `interpreter` / `script` | 非空 | +| `action.working_dir` / `script` | 非空 | +| `action.interpreter` | 可空;留空时服务端默认成 `python.exe`,Python 任务会用虚拟环境解释器 | | `action.venv` | 若填写,必须存在 `pyvenv.cfg` 与 `Scripts\python.exe` | | `action.args` | ≤ 256 个,单个 ≤ 2048 字符 | | `action.env` | ≤ 128 条;key ≤ 128、不含 `=`/`\0`;value ≤ 8192、不含 `\0` | @@ -524,9 +540,9 @@ uv run --with fonttools --with brotli tools/build-fonts.py | 层 | 覆盖 | | --- | --- | -| Rust 单元(57) | Cron、虚拟环境解析与探测、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 | -| Rust 集成(30) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账 | -| 前端 Vitest(29) | 仪表盘渲染、侧栏导航到镜像页、表单载荷、虚拟环境状态、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 | +| Rust 单元(59) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 可用性、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 | +| Rust 集成(38) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、venv 探测/创建 | +| 前端 Vitest(30) | 仪表盘渲染、侧栏导航到镜像页、表单载荷、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 | | Playwright(6) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 | | Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 | @@ -555,6 +571,7 @@ uv run --with fonttools --with brotli tools/build-fonts.py - HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。 - 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。 - Conda 环境不会自动激活,需要显式填写解释器路径。 +- 用 uv 创建环境是编辑器里的显式操作;运行中的任务不会在后台自动安装依赖。首次创建需要网络与可写的 uv 缓存目录。 - 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。 - 未安装为 Windows 服务;关闭启动脚本后调度停止。 - 错过的调度不会补跑。 diff --git a/backend/src/api.rs b/backend/src/api.rs index 519621b..7ec2982 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -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, + ApiJson(request): ApiJson, +) -> ApiResult> { + 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)) diff --git a/backend/src/python_env.rs b/backend/src/python_env.rs index 059c798..ed3ab2a 100644 --- a/backend/src/python_env.rs +++ b/backend/src/python_env.rs @@ -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, pub source: Option, 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, + /// 没有虚拟环境时,是否具备用 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, +} + +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 { + 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 { + 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::(); + truncated.push('…'); + truncated +} + fn venv_source_message(source: Option) -> 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] diff --git a/backend/src/runs/executor.rs b/backend/src/runs/executor.rs index 7fbde86..b9a7352 100644 --- a/backend/src/runs/executor.rs +++ b/backend/src/runs/executor.rs @@ -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() diff --git a/backend/src/tasks/service.rs b/backend/src/tasks/service.rs index 9eba333..870219d 100644 --- a/backend/src/tasks/service.rs +++ b/backend/src/tasks/service.rs @@ -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 { diff --git a/backend/tests/python_env_probe.rs b/backend/tests/python_env_probe.rs index e9f6b91..d93552b 100644 --- a/backend/tests/python_env_probe.rs +++ b/backend/tests/python_env_probe.rs @@ -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"); +} diff --git a/backend/tests/tasks.rs b/backend/tests/tasks.rs index 6937886..ecfd009 100644 --- a/backend/tests/tasks.rs +++ b/backend/tests/tasks.rs @@ -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; diff --git a/contracts/openapi.json b/contracts/openapi.json index 71f1809..421bf57 100644 --- a/contracts/openapi.json +++ b/contracts/openapi.json @@ -383,6 +383,46 @@ } } }, + "/api/v1/python/venv/create": { + "post": { + "tags": [ + "system" + ], + "operationId": "create_python_venv", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePythonVenvRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Created or reused project virtual environment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VenvProbeResponse" + } + } + } + }, + "400": { + "description": "Project cannot create a virtual environment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/runs/{run_id}": { "get": { "tags": [ @@ -1155,6 +1195,19 @@ }, "additionalProperties": false }, + "CreatePythonVenvRequest": { + "type": "object", + "description": "用 uv 为项目创建 `.venv`(pyproject 用 uv sync,requirements 用 uv venv + uv pip install)。", + "required": [ + "working_dir" + ], + "properties": { + "working_dir": { + "type": "string" + } + }, + "additionalProperties": false + }, "CreateTaskRequest": { "type": "object", "required": [ @@ -1959,9 +2012,18 @@ "required": [ "status", "interpreter_kind", - "message" + "message", + "uv_available", + "has_pyproject", + "has_requirements", + "requirements_files", + "can_create" ], "properties": { + "can_create": { + "type": "boolean", + "description": "没有虚拟环境时,是否具备用 uv 创建的条件" + }, "display_path": { "type": [ "string", @@ -1969,12 +2031,27 @@ ], "description": "供界面展示的解释器路径;位于工作目录内时使用相对路径" }, + "has_pyproject": { + "type": "boolean", + "description": "工作目录根部是否存在 pyproject.toml" + }, + "has_requirements": { + "type": "boolean", + "description": "工作目录根部是否存在 requirements*.txt" + }, "interpreter_kind": { "$ref": "#/components/schemas/InterpreterKind" }, "message": { "type": "string" }, + "requirements_files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "探测到的 requirements 文件名(仅根目录)" + }, "source": { "oneOf": [ { @@ -1988,6 +2065,10 @@ "status": { "$ref": "#/components/schemas/VenvProbeStatus" }, + "uv_available": { + "type": "boolean", + "description": "本机 PATH 中是否能找到 uv" + }, "venv_path": { "type": [ "string", diff --git a/frontend/src/App.css b/frontend/src/App.css index 3877738..0fa6aa4 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -444,6 +444,17 @@ color: var(--su-text-muted); } +.env-status-badge.danger { + color: var(--su-color-danger); +} + +.env-detector-message { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + .env-status-badge code { overflow: hidden; border-radius: var(--su-radius-xs); diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index acb2e2e..c47ef53 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -111,6 +111,7 @@ function createFetchMock(options: FetchMockOptions = {}) { working_dir: string } const linked = probe.working_dir.toLowerCase().includes('sync') + const creatable = probe.working_dir.toLowerCase().includes('uvproject') return jsonResponse({ status: linked ? 'linked' : 'missing', interpreter_kind: 'python', @@ -119,7 +120,14 @@ function createFetchMock(options: FetchMockOptions = {}) { source: linked ? 'working_directory' : null, message: linked ? '已自动关联工作目录中的虚拟环境' - : '未检测到虚拟环境,将直接使用系统解释器', + : creatable + ? '未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv' + : '未检测到虚拟环境;本机未安装 uv,请先手动创建 .venv', + uv_available: creatable, + has_pyproject: creatable, + has_requirements: false, + requirements_files: [], + can_create: creatable, }) } if (url.includes('/api/v1/system/directories')) { @@ -264,7 +272,7 @@ describe('TaskDashboard', () => { ).toBeInTheDocument() }) - it('falls back to the system interpreter when no venv is detected', async () => { + it('shows an error when no venv or dependency files exist', async () => { vi.stubGlobal('fetch', createFetchMock()) const user = userEvent.setup() renderApp() @@ -276,11 +284,29 @@ describe('TaskDashboard', () => { ) expect( - await screen.findByText(/使用系统解释器:/, {}, { timeout: 3000 }), + await screen.findByText(/未检测到虚拟环境;本机未安装 uv/, {}, { + timeout: 3000, + }), ).toBeInTheDocument() + }) + + it('offers uv environment creation when pyproject.toml exists', async () => { + vi.stubGlobal('fetch', createFetchMock()) + const user = userEvent.setup() + renderApp() + + await user.click(screen.getByRole('button', { name: '+ 新建任务' })) + await user.type( + screen.getByLabelText(/项目工作目录/), + 'D:\\Projects\\uvproject', + ) + expect( - screen.getByText('未检测到虚拟环境,将直接使用系统解释器'), + await screen.findByRole('button', { name: '用 uv 创建环境' }, { + timeout: 3000, + }), ).toBeInTheDocument() + expect(screen.getByText('pyproject.toml')).toBeInTheDocument() }) it('browses folders in the page and fills the working directory', async () => { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cf66c5e..3b721ce 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -18,6 +18,8 @@ export type CronPreviewRequest = components['schemas']['CronPreviewRequest'] export type CronPreviewResponse = components['schemas']['CronPreviewResponse'] export type VenvProbeRequest = components['schemas']['VenvProbeRequest'] export type VenvProbeResponse = components['schemas']['VenvProbeResponse'] +export type CreatePythonVenvRequest = + components['schemas']['CreatePythonVenvRequest'] export type VenvProbeStatus = components['schemas']['VenvProbeStatus'] export type VenvSource = components['schemas']['VenvSource'] export type InterpreterKind = components['schemas']['InterpreterKind'] @@ -285,6 +287,16 @@ export function probePythonVenv( }) } +/** 用 uv 为项目创建/同步 .venv;优先复用已有环境。 */ +export function createPythonVenv( + request: CreatePythonVenvRequest, +): Promise { + return requestJson('/api/v1/python/venv/create', { + method: 'POST', + body: JSON.stringify(request), + }) +} + /** * 列出目录下的子文件夹;不传 path 时返回盘符列表。 * 用于页面内的目录浏览器,选中后把绝对路径回填到表单。 diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index 195ae70..2350a84 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -116,6 +116,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/python/venv/create": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["create_python_venv"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/runs/{run_id}": { parameters: { query?: never; @@ -352,6 +368,10 @@ export interface components { name: string; trusted_host?: string | null; }; + /** @description 用 uv 为项目创建 `.venv`(pyproject 用 uv sync,requirements 用 uv venv + uv pip install)。 */ + CreatePythonVenvRequest: { + working_dir: string; + }; CreateTaskRequest: { action: components["schemas"]["TaskAction"]; concurrency_policy?: components["schemas"]["ConcurrencyPolicy"]; @@ -598,12 +618,22 @@ export interface components { working_dir: string; }; VenvProbeResponse: { + /** @description 没有虚拟环境时,是否具备用 uv 创建的条件 */ + can_create: boolean; /** @description 供界面展示的解释器路径;位于工作目录内时使用相对路径 */ display_path?: string | null; + /** @description 工作目录根部是否存在 pyproject.toml */ + has_pyproject: boolean; + /** @description 工作目录根部是否存在 requirements*.txt */ + has_requirements: boolean; interpreter_kind: components["schemas"]["InterpreterKind"]; message: string; + /** @description 探测到的 requirements 文件名(仅根目录) */ + requirements_files: string[]; source?: components["schemas"]["VenvSource"] | null; status: components["schemas"]["VenvProbeStatus"]; + /** @description 本机 PATH 中是否能找到 uv */ + uv_available: boolean; /** @description 虚拟环境根目录的绝对路径 */ venv_path?: string | null; }; @@ -939,6 +969,39 @@ export interface operations { }; }; }; + create_python_venv: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreatePythonVenvRequest"]; + }; + }; + responses: { + /** @description Created or reused project virtual environment */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VenvProbeResponse"]; + }; + }; + /** @description Project cannot create a virtual environment */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; get_run: { parameters: { query?: never; diff --git a/frontend/src/components/TaskEditorModal.tsx b/frontend/src/components/TaskEditorModal.tsx index 8197e85..144d6f4 100644 --- a/frontend/src/components/TaskEditorModal.tsx +++ b/frontend/src/components/TaskEditorModal.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Button, Checkbox, @@ -14,6 +14,7 @@ import { Text, Textarea, TextButton, + toast, } from '@stash/ui' import { useEffect, useRef, useState, type ReactNode } from 'react' import { @@ -25,6 +26,7 @@ import { } from 'react-hook-form' import { ApiRequestError, + createPythonVenv, previewCron, probePythonVenv, type Task, @@ -81,6 +83,7 @@ export function TaskEditorModal({ const [submitError, setSubmitError] = useState(null) const [cronPreviewCount, setCronPreviewCount] = useState(5) const [pickerOpen, setPickerOpen] = useState(false) + const queryClient = useQueryClient() // 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖; // 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。 const autoVenvRef = useRef('') @@ -155,7 +158,7 @@ export function TaskEditorModal({ probePythonVenv( { working_dir: debouncedWorkingDir, - interpreter: debouncedInterpreter, + interpreter: debouncedInterpreter.trim() || 'python.exe', venv: debouncedVenv.trim() || null, }, signal, @@ -165,6 +168,23 @@ export function TaskEditorModal({ retry: false, staleTime: 15_000, }) + const createVenvMutation = useMutation({ + mutationFn: (workingDir: string) => + createPythonVenv({ working_dir: workingDir }), + onSuccess: (probe, workingDir) => { + toast.success('虚拟环境已创建') + if (probe.venv_path) { + const value = venvFieldValue(workingDir, probe.venv_path) + autoVenvRef.current = value + setValue('script.venv', value, { + shouldDirty: true, + shouldValidate: true, + }) + } + void queryClient.invalidateQueries({ queryKey: ['python-venv'] }) + }, + onError: (error) => toast.error(error.message), + }) const linkedVenv = venvProbe.data?.status === 'linked' ? venvProbe.data : null const previewVirtualEnv = scriptPreview.venv.trim() || linkedVenv?.venv_path || '' @@ -497,15 +517,16 @@ export function TaskEditorModal({ /> @@ -515,6 +536,10 @@ export function TaskEditorModal({ probe={venvProbe.data} pending={venvProbe.isPending} failed={venvProbe.isError} + creating={createVenvMutation.isPending} + onCreate={() => + createVenvMutation.mutate(scriptPreview.working_dir.trim()) + } /> void } function VenvDetectorBar({ @@ -872,6 +899,8 @@ function VenvDetectorBar({ probe, pending, failed, + creating, + onCreate, }: VenvDetectorBarProps) { if (!workingDir.trim()) { return ( @@ -926,12 +955,31 @@ function VenvDetectorBar({ } if (probe.status === 'missing') { + if (probe.can_create) { + return ( +
+
+ ● {probe.message} + + {probe.has_pyproject + ? 'pyproject.toml' + : probe.requirements_files.join(' · ')} + +
+ +
+ ) + } return (
- - ● 使用系统解释器: {probe.display_path} - - {probe.message} + ● {probe.message}
) } diff --git a/frontend/src/lib/taskForm.ts b/frontend/src/lib/taskForm.ts index 8368524..077bdaf 100644 --- a/frontend/src/lib/taskForm.ts +++ b/frontend/src/lib/taskForm.ts @@ -116,13 +116,6 @@ export const taskFormSchema = z message: '请输入工作目录', }) } - if (!values.script.interpreter.trim()) { - context.addIssue({ - code: 'custom', - path: ['script', 'interpreter'], - message: '请输入解释器或可执行程序', - }) - } if (!values.script.script.trim()) { context.addIssue({ code: 'custom', @@ -171,7 +164,7 @@ export function createEmptyTaskFormValues(): TaskFormValues { script: { working_dir: '', venv: '', - interpreter: 'python.exe', + interpreter: '', script: 'main.py', args: [], env: [],