Skip to content

Commit ed85cef

Browse files
authored
feat: Use uv to install and create virtual python environment (#8)
1 parent 725ada7 commit ed85cef

File tree

4 files changed

+159
-69
lines changed

4 files changed

+159
-69
lines changed

.changeset/curly-clouds-sip.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"finagent": patch
3+
---
4+
5+
Use uv to install and create virtual python environment

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ Interactive financial analyst generating Jupyter Notebooks using Claude Code. Br
55
## Prerequisites
66

77
- Node.js 20+ (get it from https://nodejs.org/en/download)
8-
- Python 3.12+
98
- Anthropic API key (get it from https://console.anthropic.com/)
109

1110
## Run

src/components/VenvSetupGate.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,19 @@ const VenvSetupGate: React.FC<VenvSetupGateProps> = ({ onReady, children }) => {
103103
);
104104
}
105105

106-
// checking or prompt
106+
// checking phase
107+
if (phase === "checking") {
108+
return (
109+
<>
110+
<Header />
111+
<Box flexDirection="column">
112+
<Text>Checking your environment...</Text>
113+
</Box>
114+
</>
115+
);
116+
}
117+
118+
// prompt phase
107119
return (
108120
<>
109121
<Header />

src/services/jupyterService.ts

Lines changed: 141 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@ import {
99
getConfigDir,
1010
getLogsDir,
1111
getVenvDir,
12-
getVenvJupyterPath,
13-
getVenvPipPath,
14-
getVenvPythonPath,
1512
getInvocationCwd,
1613
} from "./config.js";
1714

@@ -41,12 +38,105 @@ export function getDefaultPackages(): string[] {
4138
return [...DEFAULT_PACKAGES];
4239
}
4340

41+
function isUvAvailable(): boolean {
42+
try {
43+
const res = spawnSync("uv", ["--version"], { stdio: "ignore" });
44+
return res.status === 0;
45+
} catch {
46+
return false;
47+
}
48+
}
49+
50+
export async function ensureUvInstalled(opts?: {
51+
onMessage?: (line: string) => void;
52+
signal?: AbortSignal;
53+
}): Promise<void> {
54+
if (isUvAvailable()) {
55+
return; // Already installed
56+
}
57+
58+
const onMessage = opts?.onMessage ?? (() => {});
59+
const platform = os.platform();
60+
61+
onMessage("uv not found. Installing uv for faster Python environment management...");
62+
63+
try {
64+
if (platform === "win32") {
65+
// For Windows, try to use the PowerShell installer
66+
await runCommand("powershell", ["-c", "irm https://astral.sh/uv/install.ps1 | iex"], {
67+
onMessage,
68+
signal: opts?.signal,
69+
});
70+
} else {
71+
// Try curl first, fallback to wget if curl is not available
72+
let installCommand: string;
73+
let installArgs: string[];
74+
75+
try {
76+
const curlCheck = spawnSync("curl", ["--version"], { stdio: "ignore" });
77+
if (curlCheck.status === 0) {
78+
installCommand = "sh";
79+
installArgs = ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"];
80+
} else {
81+
throw new Error("curl not available");
82+
}
83+
} catch {
84+
try {
85+
const wgetCheck = spawnSync("wget", ["--version"], { stdio: "ignore" });
86+
if (wgetCheck.status === 0) {
87+
installCommand = "sh";
88+
installArgs = ["-c", "wget -qO- https://astral.sh/uv/install.sh | sh"];
89+
} else {
90+
throw new Error("Neither curl nor wget available");
91+
}
92+
} catch {
93+
throw new Error("Cannot install uv: neither curl nor wget is available");
94+
}
95+
}
96+
97+
await runCommand(installCommand, installArgs, {
98+
onMessage,
99+
signal: opts?.signal,
100+
});
101+
}
102+
103+
// Verify installation
104+
if (!isUvAvailable()) {
105+
throw new Error(
106+
"uv installation completed but uv is still not available. You may need to restart your terminal or add uv to your PATH.",
107+
);
108+
}
109+
110+
onMessage("✅ uv installed successfully!");
111+
} catch (error) {
112+
const errorMsg = error instanceof Error ? error.message : String(error);
113+
onMessage(`❌ Failed to install uv: ${errorMsg}`);
114+
onMessage(
115+
"You can install uv manually by visiting: https://docs.astral.sh/uv/getting-started/installation/",
116+
);
117+
throw new Error(`uv installation failed: ${errorMsg}`);
118+
}
119+
}
120+
44121
export function isVenvReady(): boolean {
45-
const pythonPath = getVenvPythonPath();
46-
const pipPath = getVenvPipPath();
47-
const jupyterPath = getVenvJupyterPath();
48-
// Consider the environment ready only if the venv exists AND jupyter is installed.
49-
return existsSync(pythonPath) && existsSync(pipPath) && existsSync(jupyterPath);
122+
const venvDir = getVenvDir();
123+
if (!existsSync(venvDir)) {
124+
return false;
125+
}
126+
127+
try {
128+
// Check if uv can find python in the venv and if jupyter is installed
129+
const pythonCheck = spawnSync("uv", ["run", "--python", venvDir, "python", "--version"], {
130+
stdio: "ignore",
131+
});
132+
const jupyterCheck = spawnSync("uv", ["run", "--python", venvDir, "jupyter", "--version"], {
133+
stdio: "ignore",
134+
});
135+
136+
return pythonCheck.status === 0 && jupyterCheck.status === 0;
137+
} catch {
138+
return false;
139+
}
50140
}
51141

52142
export async function ensureVenvAndPackages(opts?: {
@@ -56,14 +146,15 @@ export async function ensureVenvAndPackages(opts?: {
56146
}): Promise<void> {
57147
ensureConfigDir();
58148
const venvDir = getVenvDir();
59-
const pythonPath = getVenvPythonPath();
60149
const packages = opts?.packages ?? DEFAULT_PACKAGES;
61150
const onMessage = opts?.onMessage ?? (() => {});
62151

63-
if (!existsSync(pythonPath)) {
64-
onMessage(`Setting up Python venv at ${venvDir} ...`);
65-
const pythonExe = detectPythonExecutable();
66-
await runCommand(pythonExe.command, [...pythonExe.argsPrefix, "-m", "venv", venvDir], {
152+
// Ensure uv is installed first
153+
await ensureUvInstalled({ onMessage: opts?.onMessage, signal: opts?.signal });
154+
155+
if (!existsSync(venvDir)) {
156+
onMessage(`Setting up Python venv at ${venvDir} using uv...`);
157+
await runCommand("uv", ["venv", venvDir], {
67158
onMessage,
68159
signal: opts?.signal,
69160
});
@@ -77,37 +168,27 @@ export async function updateVenvPackages(opts?: {
77168
onMessage?: (line: string) => void;
78169
signal?: AbortSignal;
79170
}): Promise<void> {
80-
const pipPath = getVenvPipPath();
81-
const pythonPath = getVenvPythonPath();
171+
const venvDir = getVenvDir();
82172
const packages = opts?.packages ?? DEFAULT_PACKAGES;
83173
const onMessage = opts?.onMessage ?? (() => {});
84174

85-
if (!existsSync(pythonPath)) {
175+
if (!existsSync(venvDir)) {
86176
throw new Error("Python venv is not installed. Restart the app to set it up.");
87177
}
88178

89-
onMessage("Updating Python packages ...");
179+
// Ensure uv is available
180+
await ensureUvInstalled({ onMessage: opts?.onMessage, signal: opts?.signal });
181+
182+
onMessage("Updating Python packages using uv...");
90183
const pipOnMessage = (chunk: string) => {
91184
for (const line of chunk.split(/\r?\n/)) {
92185
if (!line) continue;
93186
if (line.startsWith("Requirement already satisfied:")) continue;
94187
onMessage(line);
95188
}
96189
};
97-
// Ensure pip exists; some environments may not provision pip in venv by default
98-
if (!existsSync(pipPath)) {
99-
await runCommand(pythonPath, ["-m", "ensurepip", "--upgrade"], {
100-
onMessage: pipOnMessage,
101-
signal: opts?.signal,
102-
});
103-
}
104190

105-
// Use python -m pip for better reliability on Windows
106-
await runCommand(pythonPath, ["-m", "pip", "install", "--upgrade", "pip"], {
107-
onMessage: pipOnMessage,
108-
signal: opts?.signal,
109-
});
110-
await runCommand(pythonPath, ["-m", "pip", "install", ...packages], {
191+
await runCommand("uv", ["pip", "install", "--python", venvDir, ...packages], {
111192
onMessage: pipOnMessage,
112193
signal: opts?.signal,
113194
});
@@ -132,15 +213,23 @@ export async function startServerInBackground(opts?: {
132213
}): Promise<void> {
133214
const onMessage = opts?.onMessage ?? (() => {});
134215
ensureConfigDir();
135-
const jupyterPath = getVenvJupyterPath();
216+
const venvDir = getVenvDir();
136217
const envPort = Number(process.env.JUPYTER_PORT || "");
137218
const port = opts?.port ?? (Number.isFinite(envPort) && envPort > 0 ? envPort : 8888);
138219
const notebookDir = opts?.notebookDir ?? getInvocationCwd();
139220
const logsDir = getLogsDir();
140221
const outPath = path.join(logsDir, "jupyter.out.log");
141222
const errPath = path.join(logsDir, "jupyter.err.log");
142223

143-
if (!existsSync(jupyterPath)) {
224+
// Check if jupyter is available in the venv
225+
try {
226+
const jupyterCheck = spawnSync("uv", ["run", "--python", venvDir, "jupyter", "--version"], {
227+
stdio: "ignore",
228+
});
229+
if (jupyterCheck.status !== 0) {
230+
throw new Error("Jupyter is not installed in the virtual environment.");
231+
}
232+
} catch {
144233
throw new Error("Jupyter is not installed. Run setup first.");
145234
}
146235

@@ -157,8 +246,12 @@ export async function startServerInBackground(opts?: {
157246

158247
const isWindows = os.platform() === "win32";
159248
const child = spawn(
160-
jupyterPath,
249+
"uv",
161250
[
251+
"run",
252+
"--python",
253+
venvDir,
254+
"jupyter",
162255
"notebook",
163256
// Keep browser disabled here; we'll explicitly open URLs when needed
164257
"--no-browser",
@@ -272,6 +365,21 @@ function cleanupMetaFile(): void {
272365
}
273366
}
274367

368+
export async function runInVenv(
369+
command: string,
370+
args: string[] = [],
371+
opts?: {
372+
cwd?: string;
373+
onMessage?: (line: string) => void;
374+
signal?: AbortSignal;
375+
},
376+
): Promise<void> {
377+
const venvDir = getVenvDir();
378+
await ensureUvInstalled({ onMessage: opts?.onMessage, signal: opts?.signal });
379+
380+
await runCommand("uv", ["run", "--python", venvDir, command, ...args], opts);
381+
}
382+
275383
async function runCommand(
276384
cmd: string,
277385
args: string[],
@@ -311,40 +419,6 @@ async function runCommand(
311419
});
312420
}
313421

314-
function detectPythonExecutable(): { command: string; argsPrefix: string[] } {
315-
// On Windows prefer the Python launcher `py -3` if available.
316-
// Else try `python3`, then `python`.
317-
// As a final fallback, use Node's execPath (rarely useful).
318-
const platform = os.platform();
319-
if (platform === "win32") {
320-
try {
321-
const res = spawnSync("py", ["-3", "--version"], { stdio: "ignore" });
322-
if (res.status === 0) return { command: "py", argsPrefix: ["-3"] };
323-
} catch {
324-
// ignore
325-
}
326-
try {
327-
const res = spawnSync("python", ["--version"], { stdio: "ignore" });
328-
if (res.status === 0) return { command: "python", argsPrefix: [] };
329-
} catch {
330-
// ignore
331-
}
332-
}
333-
try {
334-
const res = spawnSync("python3", ["--version"], { stdio: "ignore" });
335-
if (res.status === 0) return { command: "python3", argsPrefix: [] };
336-
} catch {
337-
// ignore
338-
}
339-
try {
340-
const res = spawnSync("python", ["--version"], { stdio: "ignore" });
341-
if (res.status === 0) return { command: "python", argsPrefix: [] };
342-
} catch {
343-
// ignore
344-
}
345-
return { command: process.execPath, argsPrefix: [] };
346-
}
347-
348422
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
349423
const deadline = Date.now() + timeoutMs;
350424
while (Date.now() < deadline) {

0 commit comments

Comments
 (0)