дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(sdk): create Python environments with uv and Poetry in Add Interpreter (#1888)
* feat(sdk): create Python environments with uv and Poetry in Add Interpreter Add a VenvTool parameter to sdk_python_create_venv so the Add Python Interpreter dialog can build a new environment with virtualenv, uv or Poetry, matching what the environment discovery already finds. Poetry keeps envs in its own cache, so the dialog disables the location field for it and resolves the interpreter via 'poetry env info --path'. Fixes l0ng-ai/openidea#1776 * fix(i18n): update text catalogs for the new env-tool strings The Add Interpreter dialog gained uv/Poetry labels and per-tool notes, and its spinner text changed from 'Creating virtual environment' to 'Creating environment'. Add the new zh entries and rename the stale key in every catalog so the text-catalog test passes.
Цей коміт міститься в:
1 джерело
d018771a8d
коміт
88ecaf9ccd
14 files changed
+338
-38
No files matched your search
@@ -0,0 +1,126 @@
|
||||
import { act, fireEvent, render, screen, waitFor, cleanup } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Sdk } from "@/bindings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
detect: vi.fn(),
|
||||
createVenv: vi.fn(),
|
||||
choosePath: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
isTauri: () => true,
|
||||
commands: {
|
||||
...real.commands,
|
||||
sdkDetect: mocks.detect,
|
||||
sdkPythonCreateVenv: mocks.createVenv,
|
||||
cancelRequest: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.choosePath }));
|
||||
|
||||
const { AddInterpreterDialog } = await import("../languages/AddInterpreterDialog");
|
||||
const { useSdks } = await import("../languages/api");
|
||||
|
||||
const SYSTEM: Sdk = {
|
||||
path: "/usr/bin/python3",
|
||||
name: "Python 3.12",
|
||||
version: "3.12.0",
|
||||
source: "system",
|
||||
home: "/usr",
|
||||
extra: null,
|
||||
};
|
||||
const VENV: Sdk = {
|
||||
path: "/project/.venv/bin/python",
|
||||
name: "Python 3.12 (.venv)",
|
||||
version: "3.12.0",
|
||||
source: "venv",
|
||||
home: "/project/.venv",
|
||||
extra: null,
|
||||
};
|
||||
|
||||
const ok = <T,>(data: T) => ({ status: "ok" as const, data });
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(window, { __TAURI_INTERNALS__: { transformCallback: () => 1 } });
|
||||
mocks.detect.mockResolvedValue(ok([VENV, SYSTEM]));
|
||||
mocks.createVenv.mockResolvedValue(new Promise(() => {}));
|
||||
useSdks.setState({ byKind: {}, loading: {}, errors: {} });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const open = (onAdded = vi.fn()) => {
|
||||
render(<AddInterpreterDialog root="/project" onClose={() => {}} onAdded={onAdded} />);
|
||||
return onAdded;
|
||||
};
|
||||
|
||||
describe("Add Python Interpreter", () => {
|
||||
it("lists the environment types PyCharm offers", async () => {
|
||||
open();
|
||||
await waitFor(() => expect(mocks.detect).toHaveBeenCalled());
|
||||
for (const label of ["Virtualenv Environment", "uv Environment", "Poetry Environment"]) {
|
||||
expect(screen.getByRole("button", { name: label })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a virtualenv with the base interpreter by default", async () => {
|
||||
let finish: (sdk: Sdk) => void = () => {};
|
||||
mocks.createVenv.mockReturnValue(new Promise((r) => (finish = (s) => r(ok(s)))));
|
||||
const onAdded = open();
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Base interpreter" })).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: "OK" }));
|
||||
expect(mocks.createVenv).toHaveBeenCalledWith(
|
||||
"venv",
|
||||
"/usr/bin/python3",
|
||||
"/project/.venv",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
await act(async () => finish(VENV));
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledWith("/project/.venv/bin/python"));
|
||||
});
|
||||
|
||||
it("passes uv as the tool and Poetry hides the location field", async () => {
|
||||
let finish: (v: unknown) => void = () => {};
|
||||
mocks.createVenv.mockImplementation(() => new Promise((r) => (finish = r)));
|
||||
open();
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Base interpreter" })).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: "uv Environment" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "OK" }));
|
||||
expect(mocks.createVenv).toHaveBeenCalledWith(
|
||||
"uv",
|
||||
"/usr/bin/python3",
|
||||
"/project/.venv",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
await act(async () => finish(ok(VENV)));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Poetry Environment" }));
|
||||
await waitFor(() => expect(screen.getByLabelText("Location:")).toBeDisabled());
|
||||
fireEvent.click(screen.getByRole("button", { name: "OK" }));
|
||||
expect(mocks.createVenv).toHaveBeenLastCalledWith(
|
||||
"poetry",
|
||||
"/usr/bin/python3",
|
||||
"/project/.venv",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds an existing interpreter without creating anything", async () => {
|
||||
const onAdded = open();
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Base interpreter" })).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Existing environment" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Interpreter" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: /Python 3\.12 \(\.venv\)/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "OK" }));
|
||||
expect(mocks.createVenv).not.toHaveBeenCalled();
|
||||
expect(onAdded).toHaveBeenCalledWith("/project/.venv/bin/python");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,24 @@
|
||||
/**
|
||||
* PyCharm's "Add Python Interpreter" (local): generate a new virtualenv from a
|
||||
* base interpreter (`python -m venv`) or select an existing interpreter.
|
||||
* PyCharm's "Add Python Interpreter" (local): generate a new environment with a
|
||||
* tool (virtualenv / uv / Poetry) or select an existing interpreter.
|
||||
*/
|
||||
import { isCancelled } from "@/platform/ipc";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { Dialog, IconButton, Input, Select, Spinner, type SelectOption, T } from "@/ui";
|
||||
import { choosePath, createVenv, rememberSdk } from "./api";
|
||||
import { choosePath, createVenv, rememberSdk, type VenvTool } from "./api";
|
||||
import { tildify, useDetected } from "./components";
|
||||
import styles from "./Sdk.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
type Mode = "new" | "existing";
|
||||
|
||||
/** Environment types of the left column, in PyCharm's order. */
|
||||
const KINDS: { tool: VenvTool; label: string; missing: string }[] = [
|
||||
{ tool: "venv", label: "Virtualenv Environment", missing: "" },
|
||||
{ tool: "uv", label: "uv Environment", missing: "uv is not installed" },
|
||||
{ tool: "poetry", label: "Poetry Environment", missing: "Poetry is not installed" },
|
||||
];
|
||||
|
||||
export function AddInterpreterDialog({
|
||||
root,
|
||||
onClose,
|
||||
@@ -22,6 +29,7 @@ export function AddInterpreterDialog({
|
||||
onAdded: (python: string) => void;
|
||||
}) {
|
||||
const { list, loading } = useDetected("python");
|
||||
const [kind, setKind] = useState<VenvTool>("venv");
|
||||
const [mode, setMode] = useState<Mode>("new");
|
||||
const bases = useMemo(() => list.filter((s) => s.source !== "venv"), [list]);
|
||||
const [base, setBase] = useState<string | null>(null);
|
||||
@@ -54,7 +62,7 @@ export function AddInterpreterDialog({
|
||||
}
|
||||
if (!baseValue || !location.trim()) return;
|
||||
setLog([]);
|
||||
const job = createVenv(baseValue, location.trim(), (line) => setLog((l) => [...l.slice(-200), line]));
|
||||
const job = createVenv(kind, baseValue, location.trim(), (line) => setLog((l) => [...l.slice(-200), line]));
|
||||
setBusy({ cancel: job.cancel });
|
||||
job.promise
|
||||
.then((sdk) => {
|
||||
@@ -66,6 +74,13 @@ export function AddInterpreterDialog({
|
||||
};
|
||||
|
||||
const canOk = mode === "new" ? !!baseValue && !!location.trim() && !busy : !!existing;
|
||||
const note = {
|
||||
venv: tx("Runs “<base> -m venv <location>”; the new interpreter becomes the project interpreter."),
|
||||
uv: tx("Runs “uv venv <location> --python <base>”; the new interpreter becomes the project interpreter."),
|
||||
poetry: tx(
|
||||
"Runs “poetry env use <base>” in the project; poetry keeps the environment under its own cache directory.",
|
||||
),
|
||||
}[kind];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -74,7 +89,7 @@ export function AddInterpreterDialog({
|
||||
title="Add Python Interpreter"
|
||||
width={680}
|
||||
aria-label="Add Python Interpreter"
|
||||
footerStart={busy && <Spinner size={14} label="Creating virtual environment" />}
|
||||
footerStart={busy && <Spinner size={14} label="Creating environment" />}
|
||||
buttons={[
|
||||
{ label: "OK", role: "default", disabled: !canOk, onClick: ok },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
@@ -82,11 +97,21 @@ export function AddInterpreterDialog({
|
||||
>
|
||||
<div className={styles.dialogBody}>
|
||||
<ul className={styles.kinds} aria-label={tx("Interpreter type")}>
|
||||
<li>
|
||||
<button type="button" className={`${styles.kind} ${styles.kindOn}`} aria-current="true">
|
||||
<T>Virtualenv Environment</T>
|
||||
</button>
|
||||
</li>
|
||||
{KINDS.map((k) => (
|
||||
<li key={k.tool}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.kind} ${kind === k.tool ? styles.kindOn : ""}`}
|
||||
aria-current={kind === k.tool || undefined}
|
||||
onClick={() => {
|
||||
setKind(k.tool);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{k.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.radios} role="radiogroup" aria-label={tx("Environment")}>
|
||||
@@ -110,11 +135,13 @@ export function AddInterpreterDialog({
|
||||
className={styles.pathInput}
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
disabled={kind === "poetry"}
|
||||
/>
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
size="small"
|
||||
title="Browse…"
|
||||
disabled={kind === "poetry"}
|
||||
onClick={async () => {
|
||||
const p = await choosePath({ directory: true, title: "Virtualenv Location", defaultPath: root });
|
||||
if (p) setLocation(p);
|
||||
@@ -143,9 +170,7 @@ export function AddInterpreterDialog({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.note}>
|
||||
{tx("Runs “<base> -m venv <location>”; the new interpreter becomes the project interpreter.")}
|
||||
</div>
|
||||
<div className={styles.note}>{note}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -81,8 +81,16 @@ function streamed<T>(
|
||||
return { promise: run(request, channel), cancel: () => void commands.cancelRequest(request) };
|
||||
}
|
||||
|
||||
export function createVenv(base: string, location: string, onLine: (line: string) => void): Streamed<Sdk> {
|
||||
return streamed((req, ch) => call(commands.sdkPythonCreateVenv(base, location, req, ch)), onLine);
|
||||
/** Tool that creates a new Python environment (`VenvTool` in `ide-sdk`). */
|
||||
export type VenvTool = "venv" | "uv" | "poetry";
|
||||
|
||||
export function createVenv(
|
||||
tool: VenvTool,
|
||||
base: string,
|
||||
location: string,
|
||||
onLine: (line: string) => void,
|
||||
): Streamed<Sdk> {
|
||||
return streamed((req, ch) => call(commands.sdkPythonCreateVenv(tool, base, location, req, ch)), onLine);
|
||||
}
|
||||
|
||||
export function listPackages(python: string): Promise<PythonPackage[]> {
|
||||
|
||||
Reference in new issue
Заблокувати користувача