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.
Цей коміт міститься в:
arookieofc authored and GitHub committed 2026-11-07 06:42:41 +08:00
1 джерело d018771a8d
коміт 88ecaf9ccd
14 files changed
+338 -38

No files matched your search

+126
Переглянути файл
@@ -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");
});
});
+38 -13
Переглянути файл
@@ -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>
</>
) : (
<>
+10 -2
Переглянути файл
@@ -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[]> {