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:
2026-11-03 22:57:17 +08:00
parent 29fc579275
commit 8e6b3b92b9
14 changed files with 759 additions and 70 deletions
+11
View File
@@ -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);
+30 -4
View File
@@ -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 () => {
+12
View File
@@ -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<VenvProbeResponse> {
return requestJson<VenvProbeResponse>('/api/v1/python/venv/create', {
method: 'POST',
body: JSON.stringify(request),
})
}
/**
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
+63
View File
@@ -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;
+56 -8
View File
@@ -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<string | null>(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({
/>
</FormField>
<FormField
label="解释器"
label="解释器(可选)"
htmlFor="task-interpreter"
hint="Python 项目留空即可,自动使用虚拟环境里的 Python;Node / Shell 等再填"
error={errors.script?.interpreter?.message}
>
<Input
id="task-interpreter"
{...register('script.interpreter')}
className="mono-input"
placeholder="python.exe"
placeholder="留空自动使用虚拟环境 Python"
/>
</FormField>
</div>
@@ -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())
}
/>
<FormField
@@ -865,6 +890,8 @@ interface VenvDetectorBarProps {
probe: VenvProbeResponse | undefined
pending: boolean
failed: boolean
creating: boolean
onCreate: () => 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 (
<div className="env-detector-bar" role="status">
<div className="env-detector-message">
<span className="env-status-badge warn">● {probe.message}</span>
<span className="env-detector-hint">
{probe.has_pyproject
? 'pyproject.toml'
: probe.requirements_files.join(' · ')}
</span>
</div>
<Button
size="sm"
variant="secondary"
loading={creating}
onClick={onCreate}
>
用 uv 创建环境
</Button>
</div>
)
}
return (
<div className="env-detector-bar" role="status">
<span className="env-status-badge ok">
● 使用系统解释器: <code>{probe.display_path}</code>
</span>
<span className="env-detector-hint">{probe.message}</span>
<span className="env-status-badge danger">● {probe.message}</span>
</div>
)
}
+1 -8
View File
@@ -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: [],