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:
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 时返回盘符列表。
|
||||
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user