diff --git a/extensions/vscode/src/api/client.ts b/extensions/vscode/src/api/client.ts index d655e2c8a0..029dedb0df 100644 --- a/extensions/vscode/src/api/client.ts +++ b/extensions/vscode/src/api/client.ts @@ -6,7 +6,6 @@ import { Credentials } from "./resources/Credentials"; import { ContentRecords } from "./resources/ContentRecords"; import { Configurations } from "./resources/Configurations"; import { Files } from "./resources/Files"; -import { Interpreters } from "./resources/Interpreters"; import { Packages } from "./resources/Packages"; import { Secrets } from "./resources/Secrets"; import { SnowflakeConnections } from "./resources/SnowflakeConnections"; @@ -20,7 +19,6 @@ class PublishingClientApi { private client; configurations: Configurations; - interpreters: Interpreters; credentials: Credentials; contentRecords: ContentRecords; files: Files; @@ -57,7 +55,6 @@ class PublishingClientApi { this.credentials = new Credentials(this.client); this.contentRecords = new ContentRecords(this.client); this.files = new Files(this.client); - this.interpreters = new Interpreters(this.client); this.packages = new Packages(this.client); this.secrets = new Secrets(this.client); this.integrationRequests = new IntegrationRequests(this.client); diff --git a/extensions/vscode/src/api/resources/Interpreters.ts b/extensions/vscode/src/api/resources/Interpreters.ts deleted file mode 100644 index 57f90a6029..0000000000 --- a/extensions/vscode/src/api/resources/Interpreters.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (C) 2025 by Posit Software, PBC. - -import { AxiosInstance } from "axios"; - -import { PythonExecutable, RExecutable } from "../../types/shared"; -import { InterpreterDefaults } from "../types/interpreters"; - -export class Interpreters { - private client: AxiosInstance; - - constructor(client: AxiosInstance) { - this.client = client; - } - - // Returns: - // 200 - success - // 500 - internal server error - get( - dir: string, - r: RExecutable | undefined, - python: PythonExecutable | undefined, - ) { - return this.client.get(`/interpreters`, { - params: { - dir, - r: r !== undefined ? r.rPath : "", - python: python !== undefined ? python.pythonPath : "", - }, - }); - } -} diff --git a/extensions/vscode/src/interpreters/index.test.ts b/extensions/vscode/src/interpreters/index.test.ts new file mode 100644 index 0000000000..54d48a5606 --- /dev/null +++ b/extensions/vscode/src/interpreters/index.test.ts @@ -0,0 +1,164 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getInterpreterDefaults } from "./index"; + +const { mockDetectPython, mockDetectR } = vi.hoisted(() => ({ + mockDetectPython: vi.fn(), + mockDetectR: vi.fn(), +})); + +vi.mock("./pythonInterpreter", () => ({ + detectPythonInterpreter: mockDetectPython, +})); + +vi.mock("./rInterpreter", () => ({ + detectRInterpreter: mockDetectR, +})); + +describe("getInterpreterDefaults", () => { + beforeEach(() => { + mockDetectPython.mockReset(); + mockDetectR.mockReset(); + }); + + test("returns results when both detections succeed", async () => { + mockDetectPython.mockResolvedValue({ + config: { + version: "3.11.5", + packageFile: "requirements.txt", + packageManager: "auto", + }, + preferredPath: "/usr/bin/python3", + }); + mockDetectR.mockResolvedValue({ + config: { + version: "4.3.2", + packageFile: "renv.lock", + packageManager: "renv", + }, + preferredPath: "/usr/bin/R", + }); + + const result = await getInterpreterDefaults( + "/project", + "/usr/bin/python3", + "/usr/bin/R", + ); + + expect(result.python).toEqual({ + version: "3.11.5", + packageFile: "requirements.txt", + packageManager: "auto", + }); + expect(result.preferredPythonPath).toBe("/usr/bin/python3"); + expect(result.r).toEqual({ + version: "4.3.2", + packageFile: "renv.lock", + packageManager: "renv", + }); + expect(result.preferredRPath).toBe("/usr/bin/R"); + }); + + test("returns empty Python config when Python detection rejects", async () => { + mockDetectPython.mockRejectedValue(new Error("python exploded")); + mockDetectR.mockResolvedValue({ + config: { + version: "4.3.2", + packageFile: "renv.lock", + packageManager: "renv", + }, + preferredPath: "/usr/bin/R", + }); + + const result = await getInterpreterDefaults( + "/project", + "/usr/bin/python3", + "/usr/bin/R", + ); + + expect(result.python).toEqual({ + version: "", + packageFile: "", + packageManager: "", + }); + expect(result.preferredPythonPath).toBe("/usr/bin/python3"); + // R still succeeds + expect(result.r.version).toBe("4.3.2"); + }); + + test("returns empty R config when R detection rejects", async () => { + mockDetectPython.mockResolvedValue({ + config: { + version: "3.11.5", + packageFile: "requirements.txt", + packageManager: "auto", + }, + preferredPath: "/usr/bin/python3", + }); + mockDetectR.mockRejectedValue(new Error("R exploded")); + + const result = await getInterpreterDefaults( + "/project", + "/usr/bin/python3", + "/usr/bin/R", + ); + + // Python still succeeds + expect(result.python.version).toBe("3.11.5"); + expect(result.r).toEqual({ + version: "", + packageFile: "", + packageManager: "", + }); + expect(result.preferredRPath).toBe("/usr/bin/R"); + }); + + test("returns empty configs when both detections reject", async () => { + mockDetectPython.mockRejectedValue(new Error("python exploded")); + mockDetectR.mockRejectedValue(new Error("R exploded")); + + const result = await getInterpreterDefaults( + "/project", + "/usr/bin/python3", + "/usr/bin/R", + ); + + expect(result.python).toEqual({ + version: "", + packageFile: "", + packageManager: "", + }); + expect(result.r).toEqual({ + version: "", + packageFile: "", + packageManager: "", + }); + }); + + test("handles undefined paths in rejection fallback", async () => { + mockDetectPython.mockRejectedValue(new Error("fail")); + mockDetectR.mockRejectedValue(new Error("fail")); + + const result = await getInterpreterDefaults("/project"); + + expect(result.preferredPythonPath).toBe(""); + expect(result.preferredRPath).toBe(""); + }); + + test("passes paths through to detectors", async () => { + mockDetectPython.mockResolvedValue({ + config: { version: "", packageFile: "", packageManager: "" }, + preferredPath: "", + }); + mockDetectR.mockResolvedValue({ + config: { version: "", packageFile: "", packageManager: "" }, + preferredPath: "", + }); + + await getInterpreterDefaults("/project", "/my/python", "/my/R"); + + expect(mockDetectPython).toHaveBeenCalledWith("/project", "/my/python"); + expect(mockDetectR).toHaveBeenCalledWith("/project", "/my/R"); + }); +}); diff --git a/extensions/vscode/src/interpreters/index.ts b/extensions/vscode/src/interpreters/index.ts new file mode 100644 index 0000000000..bcb2d61131 --- /dev/null +++ b/extensions/vscode/src/interpreters/index.ts @@ -0,0 +1,43 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { InterpreterDefaults } from "src/api/types/interpreters"; +import { detectPythonInterpreter } from "./pythonInterpreter"; +import { detectRInterpreter } from "./rInterpreter"; + +/** + * Detect Python and R interpreter defaults for a project directory. + * Runs both detections concurrently via Promise.allSettled. + */ +export async function getInterpreterDefaults( + projectDir: string, + pythonPath?: string, + rPath?: string, +): Promise { + const [pythonResult, rResult] = await Promise.allSettled([ + detectPythonInterpreter(projectDir, pythonPath), + detectRInterpreter(projectDir, rPath), + ]); + + const python = + pythonResult.status === "fulfilled" + ? pythonResult.value + : { + config: { version: "", packageFile: "", packageManager: "" }, + preferredPath: pythonPath || "", + }; + + const r = + rResult.status === "fulfilled" + ? rResult.value + : { + config: { version: "", packageFile: "", packageManager: "" }, + preferredPath: rPath || "", + }; + + return { + python: python.config, + preferredPythonPath: python.preferredPath, + r: r.config, + preferredRPath: r.preferredPath, + }; +} diff --git a/extensions/vscode/src/interpreters/pythonInterpreter.test.ts b/extensions/vscode/src/interpreters/pythonInterpreter.test.ts new file mode 100644 index 0000000000..ba320ecb5f --- /dev/null +++ b/extensions/vscode/src/interpreters/pythonInterpreter.test.ts @@ -0,0 +1,218 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + detectPythonInterpreter, + clearPythonVersionCache, +} from "./pythonInterpreter"; + +const { mockExecFile } = vi.hoisted(() => ({ + mockExecFile: vi.fn(), +})); + +vi.mock("child_process", () => ({ + execFile: mockExecFile, +})); + +vi.mock("vscode", () => ({ + Uri: { + file: (path: string) => ({ fsPath: path, path }), + joinPath: (base: { path: string }, ...segments: string[]) => { + const joined = [base.path, ...segments].join("/"); + return { fsPath: joined, path: joined }; + }, + }, + workspace: { + fs: { + stat: vi.fn(), + }, + }, +})); + +// Mock fileExists +let mockFileExistsResult = false; +vi.mock("src/utils/files", () => ({ + fileExists: vi.fn(() => Promise.resolve(mockFileExistsResult)), +})); + +describe("detectPythonInterpreter", () => { + beforeEach(() => { + clearPythonVersionCache(); + mockFileExistsResult = false; + mockExecFile.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("returns empty config when no path provided", async () => { + const result = await detectPythonInterpreter("/project"); + expect(result.config.version).toBe(""); + expect(result.config.packageFile).toBe(""); + expect(result.config.packageManager).toBe(""); + expect(result.preferredPath).toBe(""); + }); + + test("returns empty config when python fails to execute", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(new Error("not found"), ""); + }, + ); + + const result = await detectPythonInterpreter( + "/project", + "/usr/bin/python3", + ); + expect(result.config.version).toBe(""); + }); + + test("detects version from python executable", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(null, "3.11.5\n"); + }, + ); + + const result = await detectPythonInterpreter( + "/project", + "/usr/bin/python3", + ); + expect(result.config.version).toBe("3.11.5"); + expect(result.config.packageManager).toBe("auto"); + expect(result.preferredPath).toBe("/usr/bin/python3"); + }); + + test("returns requirements.txt as packageFile when present", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(null, "3.10.0\n"); + }, + ); + mockFileExistsResult = true; + + const result = await detectPythonInterpreter( + "/project", + "/usr/bin/python3", + ); + expect(result.config.packageFile).toBe("requirements.txt"); + }); + + test("returns empty packageFile when requirements.txt not present", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(null, "3.10.0\n"); + }, + ); + mockFileExistsResult = false; + + const result = await detectPythonInterpreter( + "/project", + "/usr/bin/python3", + ); + expect(result.config.packageFile).toBe(""); + }); + + test("returns empty config when python outputs empty stdout", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(null, ""); + }, + ); + + const result = await detectPythonInterpreter( + "/project", + "/usr/bin/python3", + ); + expect(result.config.version).toBe(""); + }); + + test("returns empty config when python outputs only whitespace", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(null, " \n "); + }, + ); + + const result = await detectPythonInterpreter( + "/project", + "/usr/bin/python3", + ); + expect(result.config.version).toBe(""); + }); + + test("caches version for non-shim paths", async () => { + let callCount = 0; + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + callCount++; + cb(null, "3.10.0\n"); + }, + ); + + await detectPythonInterpreter("/project", "/usr/bin/python3"); + await detectPythonInterpreter("/project", "/usr/bin/python3"); + expect(callCount).toBe(1); + }); + + test("does not cache version for pyenv shims", async () => { + let callCount = 0; + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + callCount++; + cb(null, "3.10.0\n"); + }, + ); + + await detectPythonInterpreter( + "/project", + "/home/user/.pyenv/shims/python3", + ); + await detectPythonInterpreter( + "/project", + "/home/user/.pyenv/shims/python3", + ); + expect(callCount).toBe(2); + }); +}); diff --git a/extensions/vscode/src/interpreters/pythonInterpreter.ts b/extensions/vscode/src/interpreters/pythonInterpreter.ts new file mode 100644 index 0000000000..c22dee9abe --- /dev/null +++ b/extensions/vscode/src/interpreters/pythonInterpreter.ts @@ -0,0 +1,91 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { execFile } from "child_process"; +import { Uri } from "vscode"; +import { PythonConfig } from "src/api/types/configurations"; +import { fileExists } from "src/utils/files"; + +const REQUIREMENTS_TXT = "requirements.txt"; + +const pythonVersionCache = new Map(); + +export interface PythonDetectionResult { + config: PythonConfig; + preferredPath: string; +} + +/** + * Detect Python interpreter info for a project directory. + * Runs the interpreter to get its version, checks for requirements.txt, + * and reads Python version requirements from project metadata. + */ +export async function detectPythonInterpreter( + projectDir: string, + preferredPath?: string, +): Promise { + const empty: PythonDetectionResult = { + config: { version: "", packageFile: "", packageManager: "" }, + preferredPath: preferredPath || "", + }; + + let version = ""; + if (preferredPath) { + version = await getPythonVersionFromExecutable(preferredPath, projectDir); + } + + if (!version) { + return empty; + } + + // Check for requirements.txt + const reqUri = Uri.joinPath(Uri.file(projectDir), REQUIREMENTS_TXT); + const hasRequirements = await fileExists(reqUri); + const packageFile = hasRequirements ? REQUIREMENTS_TXT : ""; + + return { + config: { + version, + packageFile, + packageManager: "auto", + }, + preferredPath: preferredPath || "", + }; +} + +function getPythonVersionFromExecutable( + pythonPath: string, + cwd: string, +): Promise { + // Skip cache for pyenv shims (where the real interpreter may vary) + if (!pythonPath.includes("shims")) { + const cached = pythonVersionCache.get(pythonPath); + if (cached) { + return Promise.resolve(cached); + } + } + + return new Promise((resolve) => { + const args = [ + "-E", + "-c", + 'import sys; v = sys.version_info; print("%d.%d.%d" % (v[0], v[1], v[2]))', + ]; + + execFile(pythonPath, args, { cwd, timeout: 15000 }, (error, stdout) => { + if (error) { + resolve(""); + return; + } + const version = stdout.trim(); + if (version && !pythonPath.includes("shims")) { + pythonVersionCache.set(pythonPath, version); + } + resolve(version); + }); + }); +} + +// Exported for testing +export function clearPythonVersionCache() { + pythonVersionCache.clear(); +} diff --git a/extensions/vscode/src/interpreters/pythonRequires.test.ts b/extensions/vscode/src/interpreters/pythonRequires.test.ts new file mode 100644 index 0000000000..32a1008cf0 --- /dev/null +++ b/extensions/vscode/src/interpreters/pythonRequires.test.ts @@ -0,0 +1,121 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getPythonRequires } from "./pythonRequires"; + +const mockFiles: Record = {}; + +vi.mock("vscode", () => { + return { + Uri: { + file: (path: string) => ({ fsPath: path, path }), + joinPath: (base: { path: string }, ...segments: string[]) => { + const joined = [base.path, ...segments].join("/"); + return { fsPath: joined, path: joined }; + }, + }, + workspace: { + fs: { + readFile: vi.fn((uri: { path: string }) => { + const content = mockFiles[uri.path]; + if (content === undefined) { + throw new Error(`File not found: ${uri.path}`); + } + return new TextEncoder().encode(content); + }), + }, + }, + }; +}); + +function setFile(projectDir: string, filename: string, content: string) { + mockFiles[`${projectDir}/${filename}`] = content; +} + +describe("getPythonRequires", () => { + beforeEach(() => { + // Clear all mock files + for (const key of Object.keys(mockFiles)) { + delete mockFiles[key]; + } + }); + + test("returns empty string when no files exist", async () => { + const result = await getPythonRequires("/project"); + expect(result).toBe(""); + }); + + describe(".python-version", () => { + test("reads a bare version and adapts it", async () => { + setFile("/project", ".python-version", "3.9.17"); + const result = await getPythonRequires("/project"); + expect(result).toBe("~=3.9.0"); + }); + + test("reads comma-separated versions", async () => { + setFile("/project", ".python-version", ">=3.8, <3.12"); + const result = await getPythonRequires("/project"); + expect(result).toBe(">=3.8,<3.12"); + }); + + test("returns empty string for invalid version", async () => { + setFile("/project", ".python-version", "3.10rc1"); + const result = await getPythonRequires("/project"); + expect(result).toBe(""); + }); + + test("takes priority over pyproject.toml", async () => { + setFile("/project", ".python-version", "3.11"); + setFile( + "/project", + "pyproject.toml", + '[project]\nrequires-python = ">=3.8"', + ); + const result = await getPythonRequires("/project"); + expect(result).toBe("~=3.11.0"); + }); + }); + + describe("pyproject.toml", () => { + test("reads requires-python from [project] section", async () => { + setFile( + "/project", + "pyproject.toml", + '[project]\nname = "myproject"\nrequires-python = ">=3.8"\n', + ); + const result = await getPythonRequires("/project"); + expect(result).toBe(">=3.8"); + }); + + test("returns empty string when requires-python is absent", async () => { + setFile("/project", "pyproject.toml", '[project]\nname = "myproject"\n'); + const result = await getPythonRequires("/project"); + expect(result).toBe(""); + }); + + test("takes priority over setup.cfg", async () => { + setFile( + "/project", + "pyproject.toml", + '[project]\nrequires-python = ">=3.9"', + ); + setFile("/project", "setup.cfg", "[options]\npython_requires = >=3.7\n"); + const result = await getPythonRequires("/project"); + expect(result).toBe(">=3.9"); + }); + }); + + describe("setup.cfg", () => { + test("reads python_requires from [options] section", async () => { + setFile("/project", "setup.cfg", "[options]\npython_requires = >=3.9\n"); + const result = await getPythonRequires("/project"); + expect(result).toBe(">=3.9"); + }); + + test("ignores python_requires in wrong section", async () => { + setFile("/project", "setup.cfg", "[metadata]\npython_requires = >=3.9\n"); + const result = await getPythonRequires("/project"); + expect(result).toBe(""); + }); + }); +}); diff --git a/extensions/vscode/src/interpreters/pythonRequires.ts b/extensions/vscode/src/interpreters/pythonRequires.ts new file mode 100644 index 0000000000..60399885b7 --- /dev/null +++ b/extensions/vscode/src/interpreters/pythonRequires.ts @@ -0,0 +1,132 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { Uri, workspace } from "vscode"; +import { adaptPythonRequires } from "./versionConstraints"; + +/** + * Find the Python version requested by the project, checking in order: + * 1. .python-version + * 2. pyproject.toml + * 3. setup.cfg + * + * Returns the PEP 440 version specification, or empty string if not found. + */ +export async function getPythonRequires(projectDir: string): Promise { + const baseUri = Uri.file(projectDir); + + const fromVersionFile = await readPythonVersionFile(baseUri); + if (fromVersionFile) { + return fromVersionFile; + } + + const fromPyProject = await readPyProjectToml(baseUri); + if (fromPyProject) { + return fromPyProject; + } + + const fromSetupCfg = await readSetupCfg(baseUri); + if (fromSetupCfg) { + return fromSetupCfg; + } + + return ""; +} + +async function readFileText(uri: Uri): Promise { + try { + const data = await workspace.fs.readFile(uri); + return new TextDecoder().decode(data); + } catch { + return null; + } +} + +/** + * Read .python-version file. Plain text, possibly comma-separated versions. + * Each part is adapted through adaptPythonRequires(). + */ +async function readPythonVersionFile( + baseUri: Uri, +): Promise { + const fileUri = Uri.joinPath(baseUri, ".python-version"); + const content = await readFileText(fileUri); + if (content === null) { + return undefined; + } + + const parts = content.split(","); + const adapted: string[] = []; + + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed) { + continue; + } + const result = adaptPythonRequires(trimmed); + if (result === null) { + return ""; + } + adapted.push(result); + } + + return adapted.join(",") || undefined; +} + +const requiresPythonRe = /^\s*requires-python\s*=\s*["']([^"']+)["']\s*$/m; + +/** + * Read pyproject.toml and extract requires-python from [project] section. + * Uses regex extraction instead of a full TOML parser. + */ +async function readPyProjectToml(baseUri: Uri): Promise { + const fileUri = Uri.joinPath(baseUri, "pyproject.toml"); + const content = await readFileText(fileUri); + if (content === null) { + return undefined; + } + + const match = requiresPythonRe.exec(content); + if (match && match[1]) { + return match[1]; + } + + return undefined; +} + +/** + * Read setup.cfg and extract python_requires from [options] section. + * Simple line-by-line INI parsing. + */ +async function readSetupCfg(baseUri: Uri): Promise { + const fileUri = Uri.joinPath(baseUri, "setup.cfg"); + const content = await readFileText(fileUri); + if (content === null) { + return undefined; + } + + const lines = content.split("\n"); + let inOptionsSection = false; + + for (const line of lines) { + const trimmed = line.trim(); + + // Section header + if (trimmed.startsWith("[")) { + inOptionsSection = trimmed.toLowerCase() === "[options]"; + continue; + } + + if (inOptionsSection) { + // Match key = value within the [options] section + const eqIdx = trimmed.indexOf("="); + if (eqIdx !== -1) { + const key = trimmed.substring(0, eqIdx).trim(); + if (key === "python_requires") { + return trimmed.substring(eqIdx + 1).trim(); + } + } + } + } + + return undefined; +} diff --git a/extensions/vscode/src/interpreters/rInterpreter.test.ts b/extensions/vscode/src/interpreters/rInterpreter.test.ts new file mode 100644 index 0000000000..ad537078cf --- /dev/null +++ b/extensions/vscode/src/interpreters/rInterpreter.test.ts @@ -0,0 +1,315 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { detectRInterpreter } from "./rInterpreter"; + +const { mockExecFile } = vi.hoisted(() => ({ + mockExecFile: vi.fn(), +})); + +vi.mock("child_process", () => ({ + execFile: mockExecFile, +})); + +vi.mock("vscode", () => ({ + Uri: { + file: (path: string) => ({ fsPath: path, path }), + joinPath: (base: { path: string }, ...segments: string[]) => { + const joined = [base.path, ...segments].join("/"); + return { fsPath: joined, path: joined }; + }, + }, + workspace: { + fs: { + stat: vi.fn(), + }, + }, +})); + +let mockFileExistsResult = false; +vi.mock("src/utils/files", () => ({ + fileExists: vi.fn(() => Promise.resolve(mockFileExistsResult)), +})); + +describe("detectRInterpreter", () => { + beforeEach(() => { + mockFileExistsResult = false; + mockExecFile.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("returns empty config when no path provided", async () => { + const result = await detectRInterpreter("/project"); + expect(result.config.version).toBe(""); + expect(result.config.packageFile).toBe(""); + expect(result.config.packageManager).toBe(""); + expect(result.preferredPath).toBe(""); + }); + + test("returns empty config when R fails to execute", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(new Error("not found"), "", ""); + }, + ); + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.version).toBe(""); + }); + + test("detects version from R --version stdout", async () => { + // First call: R --version + // Second call: renv::paths$lockfile() + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, 'R version 4.3.2 (2023-10-31) -- "Eye Holes"\n', ""); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(new Error("renv not installed"), "", ""); + }, + ); + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.version).toBe("4.3.2"); + expect(result.config.packageManager).toBe("renv"); + expect(result.preferredPath).toBe("/usr/bin/R"); + }); + + test("detects version from R --version stderr", async () => { + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, "", "R version 4.2.1 (2022-06-23)\n"); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(new Error("renv not installed"), "", ""); + }, + ); + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.version).toBe("4.2.1"); + }); + + test("uses renv lockfile path from renv::paths$lockfile()", async () => { + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, "R version 4.3.0 (2023-04-21)\n", ""); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, '[1] "/project/custom/renv.lock"\n', ""); + }, + ); + mockFileExistsResult = true; + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.packageFile).toBe("custom/renv.lock"); + }); + + test("falls back to default renv.lock when renv fails", async () => { + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, "R version 4.3.0 (2023-04-21)\n", ""); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(new Error("renv not installed"), "", ""); + }, + ); + mockFileExistsResult = true; + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.packageFile).toBe("renv.lock"); + }); + + test("detects version when R --version returns non-zero exit with valid output", async () => { + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + // Some platforms return non-zero but still output the version + cb( + new Error("exit code 1"), + "", + 'R version 4.1.3 (2022-03-10) -- "One Push-Up"\n', + ); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(new Error("renv not installed"), "", ""); + }, + ); + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.version).toBe("4.1.3"); + }); + + test("returns empty version when R output does not match version regex", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, "some unexpected output\n", ""); + }, + ); + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.version).toBe(""); + }); + + test("returns absolute lockfile path when outside projectDir", async () => { + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, "R version 4.3.0 (2023-04-21)\n", ""); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + // renv returns a path outside the project directory + cb(null, '[1] "/other/location/renv.lock"\n', ""); + }, + ); + mockFileExistsResult = true; + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.packageFile).toBe("/other/location/renv.lock"); + }); + + test("falls back to default lockfile when renv output is unparseable", async () => { + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, "R version 4.3.0 (2023-04-21)\n", ""); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + // renv succeeds but output doesn't match expected format + cb(null, "Warning: some renv message\n", ""); + }, + ); + mockFileExistsResult = true; + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.packageFile).toBe("renv.lock"); + }); + + test("returns empty packageFile when lockfile does not exist", async () => { + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, "R version 4.3.0 (2023-04-21)\n", ""); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(new Error("renv not installed"), "", ""); + }, + ); + mockFileExistsResult = false; + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.packageFile).toBe(""); + }); +}); diff --git a/extensions/vscode/src/interpreters/rInterpreter.ts b/extensions/vscode/src/interpreters/rInterpreter.ts new file mode 100644 index 0000000000..18ea66084f --- /dev/null +++ b/extensions/vscode/src/interpreters/rInterpreter.ts @@ -0,0 +1,139 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { execFile } from "child_process"; +import { Uri } from "vscode"; +import { RConfig } from "src/api/types/configurations"; +import { fileExists } from "src/utils/files"; + +const DEFAULT_RENV_LOCKFILE = "renv.lock"; +const R_VERSION_TIMEOUT = 15000; +const RENV_LOCKFILE_TIMEOUT = 15000; + +const rVersionRe = /^R version (\d+\.\d+\.\d+)/; +const renvLockPathRe = /^\[1\] "(.*)"/; + +export interface RDetectionResult { + config: RConfig; + preferredPath: string; +} + +/** + * Detect R interpreter info for a project directory. + * Runs `R --version` to get the version, resolves the renv lockfile path, + * and checks for lockfile existence. + */ +export async function detectRInterpreter( + projectDir: string, + preferredPath?: string, +): Promise { + const empty: RDetectionResult = { + config: { version: "", packageFile: "", packageManager: "" }, + preferredPath: preferredPath || "", + }; + + let version = ""; + if (preferredPath) { + version = await getRVersionFromExecutable(preferredPath); + } + + if (!version) { + return empty; + } + + // Resolve the renv lockfile path + const lockfilePath = await resolveRenvLockfile(preferredPath!, projectDir); + const lockfileUri = Uri.joinPath(Uri.file(projectDir), lockfilePath); + const lockfilePresent = await fileExists(lockfileUri); + const packageFile = lockfilePresent ? lockfilePath : ""; + + return { + config: { + version, + packageFile, + packageManager: "renv", + }, + preferredPath: preferredPath || "", + }; +} + +/** + * Get R version by running `R --version` and parsing the output. + * R may output the version on stdout or stderr, so we check both. + */ +function getRVersionFromExecutable(rPath: string): Promise { + return new Promise((resolve) => { + execFile( + rPath, + ["--version"], + { timeout: R_VERSION_TIMEOUT }, + (_error, stdout, stderr) => { + // R --version may return non-zero on some platforms, but still + // outputs the version. Check output even on error. + const combined = (stdout || "") + (stderr || ""); + const lines = combined.split("\n"); + for (const line of lines) { + const match = rVersionRe.exec(line); + if (match && match[1]) { + resolve(match[1]); + return; + } + } + resolve(""); + }, + ); + }); +} + +/** + * Resolve the renv lockfile path by trying renv::paths$lockfile() first, + * falling back to the default "renv.lock". + */ +async function resolveRenvLockfile( + rPath: string, + projectDir: string, +): Promise { + const lockfilePath = await getRenvLockfileFromR(rPath, projectDir); + if (lockfilePath) { + // The renv lockfile path is absolute; make it relative to projectDir + if (lockfilePath.startsWith(projectDir)) { + const relative = lockfilePath.substring(projectDir.length); + // Remove leading path separator + return relative.replace(/^[/\\]/, ""); + } + return lockfilePath; + } + return DEFAULT_RENV_LOCKFILE; +} + +/** + * Get the renv lockfile path from R by running renv::paths$lockfile(). + * Returns the absolute path or empty string on failure. + */ +function getRenvLockfileFromR( + rPath: string, + cwd: string, +): Promise { + return new Promise((resolve) => { + execFile( + rPath, + ["-s", "-e", "renv::paths$lockfile()"], + { cwd, timeout: RENV_LOCKFILE_TIMEOUT }, + (error, stdout, stderr) => { + if (error) { + resolve(undefined); + return; + } + const combined = (stdout || "") + (stderr || ""); + const lines = combined.split("\n"); + for (const line of lines) { + const match = renvLockPathRe.exec(line); + if (match && match[1]) { + resolve(match[1]); + return; + } + } + resolve(undefined); + }, + ); + }); +} diff --git a/extensions/vscode/src/interpreters/rRequires.test.ts b/extensions/vscode/src/interpreters/rRequires.test.ts new file mode 100644 index 0000000000..725544cf16 --- /dev/null +++ b/extensions/vscode/src/interpreters/rRequires.test.ts @@ -0,0 +1,133 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getRRequires } from "./rRequires"; + +const mockFiles: Record = {}; + +vi.mock("vscode", () => { + return { + Uri: { + file: (path: string) => ({ fsPath: path, path }), + joinPath: (base: { path: string }, ...segments: string[]) => { + const joined = [base.path, ...segments].join("/"); + return { fsPath: joined, path: joined }; + }, + }, + workspace: { + fs: { + readFile: vi.fn((uri: { path: string }) => { + const content = mockFiles[uri.path]; + if (content === undefined) { + throw new Error(`File not found: ${uri.path}`); + } + return new TextEncoder().encode(content); + }), + }, + }, + }; +}); + +function setFile(projectDir: string, filename: string, content: string) { + mockFiles[`${projectDir}/${filename}`] = content; +} + +describe("getRRequires", () => { + beforeEach(() => { + for (const key of Object.keys(mockFiles)) { + delete mockFiles[key]; + } + }); + + test("returns empty string when no files exist", async () => { + const result = await getRRequires("/project"); + expect(result).toBe(""); + }); + + describe("DESCRIPTION", () => { + test("reads R version from Depends line", async () => { + setFile( + "/project", + "DESCRIPTION", + "Depends: package1, R (>= 3.5.0), package3", + ); + const result = await getRRequires("/project"); + expect(result).toBe(">= 3.5.0"); + }); + + test("reads R version from continuation lines", async () => { + setFile( + "/project", + "DESCRIPTION", + "Depends: package1\n R (>3.5)\n package3", + ); + const result = await getRRequires("/project"); + expect(result).toBe(">3.5"); + }); + + test("reads R version from tab-indented continuation", async () => { + setFile( + "/project", + "DESCRIPTION", + "Depends: package1\n\tR (>7.3)\n\tpackage3", + ); + const result = await getRRequires("/project"); + expect(result).toBe(">7.3"); + }); + + test("returns empty when no R dependency", async () => { + setFile( + "/project", + "DESCRIPTION", + "Depends: package1, package2, package3", + ); + const result = await getRRequires("/project"); + expect(result).toBe(""); + }); + + test("does not match tinyR", async () => { + setFile( + "/project", + "DESCRIPTION", + "Depends: package1\n tinyR (<3.5)\n package3", + ); + const result = await getRRequires("/project"); + expect(result).toBe(""); + }); + + test("takes priority over renv.lock", async () => { + setFile("/project", "DESCRIPTION", "Depends: R (>= 4.0.0)"); + setFile( + "/project", + "renv.lock", + JSON.stringify({ R: { Version: "4.3.2" } }), + ); + const result = await getRRequires("/project"); + expect(result).toBe(">= 4.0.0"); + }); + }); + + describe("renv.lock", () => { + test("reads R version and adapts to compatible constraint", async () => { + setFile( + "/project", + "renv.lock", + JSON.stringify({ R: { Version: "4.3.2" } }), + ); + const result = await getRRequires("/project"); + expect(result).toBe("~=4.3.0"); + }); + + test("returns empty string for missing R section", async () => { + setFile("/project", "renv.lock", JSON.stringify({ Packages: {} })); + const result = await getRRequires("/project"); + expect(result).toBe(""); + }); + + test("returns empty string for invalid JSON", async () => { + setFile("/project", "renv.lock", "not json"); + const result = await getRRequires("/project"); + expect(result).toBe(""); + }); + }); +}); diff --git a/extensions/vscode/src/interpreters/rRequires.ts b/extensions/vscode/src/interpreters/rRequires.ts new file mode 100644 index 0000000000..cbacb28912 --- /dev/null +++ b/extensions/vscode/src/interpreters/rRequires.ts @@ -0,0 +1,96 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { Uri, workspace } from "vscode"; +import { adaptToCompatibleConstraint } from "./versionConstraints"; + +/** + * Find the R version requested by the project, checking in order: + * 1. DESCRIPTION file (Depends: R (>= x.y.z)) + * 2. renv.lock (R.Version) + * + * Returns the version specification, or empty string if not found. + */ +export async function getRRequires(projectDir: string): Promise { + const baseUri = Uri.file(projectDir); + + const fromDescription = await readDescriptionFile(baseUri); + if (fromDescription) { + return fromDescription; + } + + const fromRenvLock = await readRenvLock(baseUri); + if (fromRenvLock) { + return fromRenvLock; + } + + return ""; +} + +async function readFileText(uri: Uri): Promise { + try { + const data = await workspace.fs.readFile(uri); + return new TextDecoder().decode(data); + } catch { + return null; + } +} + +/** + * Read DESCRIPTION file and look for R version in the Depends: section. + * Matches patterns like "R (>= 3.5.0)". + */ +async function readDescriptionFile(baseUri: Uri): Promise { + const fileUri = Uri.joinPath(baseUri, "DESCRIPTION"); + const content = await readFileText(fileUri); + if (content === null) { + return undefined; + } + + const lines = content.split("\n"); + const deps: string[] = []; + let found = false; + + for (const line of lines) { + if (line.startsWith("Depends:")) { + deps.push(line.substring("Depends:".length)); + found = true; + } else if (found && (line.startsWith(" ") || line.startsWith("\t"))) { + deps.push(line.trim()); + } else if (found) { + break; + } + } + + const all = deps.join(" "); + const re = /\bR\s*\(([^)]+)\)/; + const match = re.exec(all); + if (match && match[1]) { + return match[1]; + } + + return undefined; +} + +/** + * Read renv.lock (JSON) and extract R.Version, converting it to a + * compatible constraint via adaptToCompatibleConstraint. + */ +async function readRenvLock(baseUri: Uri): Promise { + const fileUri = Uri.joinPath(baseUri, "renv.lock"); + const content = await readFileText(fileUri); + if (content === null) { + return undefined; + } + + try { + const parsed = JSON.parse(content); + const version = parsed?.R?.Version; + if (typeof version === "string" && version) { + return adaptToCompatibleConstraint(version); + } + } catch { + // Invalid JSON, ignore + } + + return undefined; +} diff --git a/extensions/vscode/src/interpreters/versionConstraints.test.ts b/extensions/vscode/src/interpreters/versionConstraints.test.ts new file mode 100644 index 0000000000..496c10ba2b --- /dev/null +++ b/extensions/vscode/src/interpreters/versionConstraints.test.ts @@ -0,0 +1,62 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { describe, expect, test } from "vitest"; +import { + adaptPythonRequires, + adaptToCompatibleConstraint, +} from "./versionConstraints"; + +describe("adaptPythonRequires", () => { + const cases: Array<{ input: string; expected: string | null }> = [ + { input: "3.9.17", expected: "~=3.9.0" }, + { input: ">=3.7", expected: ">=3.7" }, + { input: "==3.11.*", expected: "==3.11.*" }, + { input: "3.8.0", expected: "~=3.8.0" }, + { input: "3.11.10", expected: "~=3.11.0" }, + { input: "3.11", expected: "~=3.11.0" }, + { input: "3.11.0", expected: "~=3.11.0" }, + { input: "3", expected: "~=3.0" }, + { input: "3.0", expected: "~=3.0.0" }, + { input: "3.0.0", expected: "~=3.0.0" }, + { input: "3.8.*", expected: "==3.8.*" }, + { input: " 3.9.0 ", expected: "~=3.9.0" }, + { input: "~=3.10", expected: "~=3.10" }, + { input: "< 4.0", expected: "< 4.0" }, + // Pre-release and special implementations are rejected + { input: "3.10rc1", expected: null }, + { input: "3.11b2", expected: null }, + { input: "3.8a1", expected: null }, + { input: "cpython-3.8", expected: null }, + { input: "3.9/pypy", expected: null }, + { input: "3.10@foo", expected: null }, + // Invalid versions + { input: "", expected: null }, + { input: "abc", expected: null }, + { input: "3..8", expected: null }, + { input: "3.8.1.*", expected: null }, + ]; + + cases.forEach(({ input, expected }) => { + test(`"${input}" -> ${expected === null ? "null" : `"${expected}"`}`, () => { + expect(adaptPythonRequires(input)).toBe(expected); + }); + }); +}); + +describe("adaptToCompatibleConstraint", () => { + test("major only", () => { + expect(adaptToCompatibleConstraint("3")).toBe("~=3.0"); + }); + + test("major.minor", () => { + expect(adaptToCompatibleConstraint("3.8")).toBe("~=3.8.0"); + }); + + test("major.minor.patch", () => { + expect(adaptToCompatibleConstraint("3.8.11")).toBe("~=3.8.0"); + }); + + test("major.minor.patch (zeroes)", () => { + expect(adaptToCompatibleConstraint("4.3.0")).toBe("~=4.3.0"); + }); +}); diff --git a/extensions/vscode/src/interpreters/versionConstraints.ts b/extensions/vscode/src/interpreters/versionConstraints.ts new file mode 100644 index 0000000000..1d379ee729 --- /dev/null +++ b/extensions/vscode/src/interpreters/versionConstraints.ts @@ -0,0 +1,65 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +const pep440Operators = /(==|!=|<=|>=|~=|<|>)/; +const validVersion = /^\d+(\.\d+)*(\.\*)?$/; + +/** + * Adapts a raw Python version string from `.python-version` into a PEP 440 + * constraint suitable for deployment. Returns the adapted constraint string + * or null if the input is invalid. + */ +export function adaptPythonRequires(raw: string): string | null { + const constraint = raw.trim(); + + if (!constraint) { + return null; + } + + if (/[-/@]/.test(constraint)) { + return null; + } + if ( + constraint.includes("rc") || + constraint.includes("b") || + constraint.includes("a") + ) { + return null; + } + + const dotCount = (constraint.match(/\./g) || []).length; + if (dotCount > 2) { + return null; + } + + // If it's already a PEP 440 constraint, return it as is + if (pep440Operators.test(constraint)) { + return constraint; + } + + // Otherwise it should be a version string + if (!validVersion.test(constraint)) { + return null; + } + + // If the version has a wildcard, use equivalence + // e.g. 3.8.* -> ==3.8.* + if (constraint.includes("*")) { + return "==" + constraint; + } + + return adaptToCompatibleConstraint(constraint); +} + +/** + * Converts a bare version string into a compatible release constraint (~=). + * - "3" -> "~=3.0" + * - "3.8" -> "~=3.8.0" + * - "3.8.11" -> "~=3.8.0" + */ +export function adaptToCompatibleConstraint(constraint: string): string { + const parts = constraint.split("."); + if (parts.length === 1) { + return `~=${parts[0]}.0`; + } + return `~=${parts[0]}.${parts[1]}.0`; +} diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index c5de95e464..e45e3be361 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -30,18 +30,6 @@ class mockApiClient { list: vi.fn(), reset: vi.fn(), }; - - readonly interpreters = { - get: vi.fn(() => { - return { - data: { - dir: "/usr/proj", - r: "/usr/bin/r", - python: "/usr/bin/python", - }, - }; - }), - }; } const mockClient = new mockApiClient(); @@ -75,6 +63,17 @@ vi.mock("src/utils/vscode", () => ({ getRInterpreterPath: vi.fn(), })); +vi.mock("src/interpreters", () => ({ + getInterpreterDefaults: vi.fn(() => + Promise.resolve({ + python: { version: "", packageFile: "", packageManager: "" }, + preferredPythonPath: "", + r: { version: "", packageFile: "", packageManager: "" }, + preferredRPath: "", + }), + ), +})); + const mockSyncAllCredentials = vi.fn(); vi.mock("src/credentialSecretStorage", () => ({ syncAllCredentials: (...args: unknown[]) => mockSyncAllCredentials(...args), diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index fc9ffe3ca0..90bb380820 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -25,6 +25,7 @@ import { useApi, } from "src/api"; import { normalizeURL } from "src/utils/url"; +import { getInterpreterDefaults } from "src/interpreters"; import { showProgress } from "src/utils/progress"; import { getStatusFromError, @@ -241,12 +242,12 @@ export class PublisherState implements Disposable { contentRecord.configurationName, contentRecord.projectDir, ); - const defaults = await api.interpreters.get( + const defaults = await getInterpreterDefaults( contentRecord.projectDir, - r, - python, + python?.pythonPath, + r?.rPath, ); - const cfg = UpdateConfigWithDefaults(response.data, defaults.data); + const cfg = UpdateConfigWithDefaults(response.data, defaults); // its not foolproof, but it may help if (!this.findConfig(cfg.configurationName, cfg.projectDir)) { this.configurations.push(cfg); @@ -315,10 +316,14 @@ export class PublisherState implements Disposable { const response = await api.configurations.getAll(".", { recursive: true, }); - const defaults = await api.interpreters.get(".", r, python); + const defaults = await getInterpreterDefaults( + ".", + python?.pythonPath, + r?.rPath, + ); this.configurations = UpdateAllConfigsWithDefaults( response.data, - defaults.data, + defaults, ); }, ); diff --git a/internal/services/api/api_service.go b/internal/services/api/api_service.go index 207f84fcd7..289d515f7d 100644 --- a/internal/services/api/api_service.go +++ b/internal/services/api/api_service.go @@ -168,10 +168,6 @@ func RouterHandlerFunc(base util.AbsolutePath, lister accounts.AccountList, log r.Handle(ToPath("deployments", "{name}", "environment"), GetDeploymentEnvironmentHandlerFunc(base, log, lister)). Methods(http.MethodGet) - // GET /api/interpreters - r.Handle(ToPath("interpreters"), GetActiveInterpretersHandlerFunc(base, log)). - Methods(http.MethodGet) - // POST /api/packages/python/scan r.Handle(ToPath("packages", "python", "scan"), NewPostPackagesPythonScanHandler(base, log)). Methods(http.MethodPost) diff --git a/internal/services/api/get_interpreters.go b/internal/services/api/get_interpreters.go deleted file mode 100644 index 3df34fb74c..0000000000 --- a/internal/services/api/get_interpreters.go +++ /dev/null @@ -1,70 +0,0 @@ -package api - -// Copyright (C) 2025 by Posit Software, PBC. - -import ( - "encoding/json" - "net/http" - - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/interpreters" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/util" -) - -var interpretersFromRequest = InterpretersFromRequest - -// getInterpreterResponse is the format of returned interpreter data. -// It represents the defaults of the active interpreters, passed in -// the request. -type getInterpreterResponse struct { - Python *config.Python `json:"python,omitempty"` - PreferredPythonPath string `json:"preferredPythonPath,omitempty"` - R *config.R `json:"r,omitempty"` - PreferredRPath string `json:"preferredRPath,omitempty"` -} - -// toGetInterpreterResponse converts interpreter objects -// to the DTO type we return from the API. -func toGetInterpreterResponse(rInterpreter interpreters.RInterpreter, pythonInterpreter interpreters.PythonInterpreter) getInterpreterResponse { - - rConfig := &config.R{} - rConfig.FillDefaults(rInterpreter) - preferredRPath := "" - if rInterpreter != nil { - preferredRPath = (rInterpreter).GetPreferredPath() - } - - pythonConfig := &config.Python{} - pythonConfig.FillDefaults(pythonInterpreter) - preferredPythonPath := "" - if pythonInterpreter != nil { - preferredPythonPath = (pythonInterpreter).GetPreferredPath() - } - - return getInterpreterResponse{ - R: rConfig, - PreferredRPath: preferredRPath, - Python: pythonConfig, - PreferredPythonPath: preferredPythonPath, - } -} - -func GetActiveInterpretersHandlerFunc(base util.AbsolutePath, log logging.Logger) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - projectDir, _, err := ProjectDirFromRequest(base, w, req, log) - if err != nil { - // Response already returned by ProjectDirFromRequest - return - } - rInterpreter, pythonInterpreter, err := interpretersFromRequest(projectDir, w, req, log) - if err != nil { - // Response already returned by InterpretersFromRequest - return - } - - response := toGetInterpreterResponse(rInterpreter, pythonInterpreter) - w.Header().Set("content-type", "application/json") - json.NewEncoder(w).Encode(response) - } -} diff --git a/internal/services/api/get_interpreters_test.go b/internal/services/api/get_interpreters_test.go deleted file mode 100644 index 4232c9e809..0000000000 --- a/internal/services/api/get_interpreters_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package api - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/interpreters" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/types" - "github.com/posit-dev/publisher/internal/util" - "github.com/posit-dev/publisher/internal/util/utiltest" - "github.com/spf13/afero" - "github.com/stretchr/testify/suite" -) - -type GetInterpretersSuite struct { - utiltest.Suite - log logging.Logger - cwd util.AbsolutePath -} - -func TestGetInterpretersSuite(t *testing.T) { - suite.Run(t, new(GetInterpretersSuite)) -} - -func (s *GetInterpretersSuite) SetupSuite() { - s.log = logging.New() -} - -func (s *GetInterpretersSuite) SetupTest() { - fs := afero.NewMemMapFs() - cwd, err := util.Getwd(fs) - s.Nil(err) - s.cwd = cwd - s.cwd.MkdirAll(0700) -} - -func (s *GetInterpretersSuite) createMockRInterpreter() interpreters.RInterpreter { - iMock := interpreters.NewMockRInterpreter() - iMock.On("Init").Return(nil) - iMock.On("IsRExecutableValid").Return(true) - iMock.On("GetRExecutable").Return(util.NewAbsolutePath("R", s.cwd.Fs()), nil) - iMock.On("GetRVersion").Return("3.4.5", nil) - relPath := util.NewRelativePath("renv.lock", s.cwd.Fs()) - iMock.On("GetLockFilePath").Return(relPath, true, nil) - iMock.On("GetPackageManager").Return("renv") - iMock.On("GetPreferredPath").Return("bin/my_r") - iMock.On("GetRRequires").Return(">=3.1.1") - return iMock -} - -func (s *GetInterpretersSuite) createMockRMissingInterpreter() interpreters.RInterpreter { - iMock := interpreters.NewMockRInterpreter() - missingError := types.NewAgentError(types.ErrorRExecNotFound, errors.New("no r"), nil) - iMock.On("Init").Return(nil) - iMock.On("IsRExecutableValid").Return(false) - iMock.On("GetRExecutable").Return(util.NewAbsolutePath("", s.cwd.Fs()), missingError) - iMock.On("GetRVersion").Return("", missingError) - relPath := util.NewRelativePath("", s.cwd.Fs()) - iMock.On("GetLockFilePath").Return(relPath, false, missingError) - iMock.On("GetPackageManager").Return("renv") - iMock.On("GetPreferredPath").Return("bin/my_r") - return iMock -} - -func (s *GetInterpretersSuite) createMockPythonInterpreter() interpreters.PythonInterpreter { - iMock := interpreters.NewMockPythonInterpreter() - iMock.On("IsPythonExecutableValid").Return(true) - iMock.On("GetPythonExecutable").Return(util.NewAbsolutePath("/bin/python", s.cwd.Fs()), nil) - iMock.On("GetPythonVersion").Return("1.2.3", nil) - iMock.On("GetPackageManager").Return("pip") - iMock.On("GetPythonRequires").Return(">=1.1.3") - iMock.On("GetLockFilePath").Return("requirements.txt", true, nil) - iMock.On("GetPreferredPath").Return("bin/my_python") - return iMock -} - -func (s *GetInterpretersSuite) createMockPythonMissingInterpreter() interpreters.PythonInterpreter { - iMock := interpreters.NewMockPythonInterpreter() - missingError := types.NewAgentError(types.ErrorPythonExecNotFound, errors.New("no python"), nil) - iMock.On("IsPythonExecutableValid").Return(false) - iMock.On("GetPythonExecutable").Return(util.NewAbsolutePath("", s.cwd.Fs()), missingError) - iMock.On("GetPythonVersion").Return("", missingError) - iMock.On("GetPackageManager").Return("pip") - iMock.On("GetPythonRequires").Return("") - iMock.On("GetLockFilePath").Return("", false, missingError) - iMock.On("GetPreferredPath").Return("bin/my_python") - return iMock -} - -func (s *GetInterpretersSuite) TestGetInterpretersWhenPassedIn() { - - h := GetActiveInterpretersHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - - // Base URL - baseURL := "/api/interpreters" - - // Create a url.URL struct - parsedURL, err := url.Parse(baseURL) - if err != nil { - panic(err) - } - - // Create a url.Values to hold query parameters - queryParams := url.Values{} - queryParams.Add("dir", ".") - queryParams.Add("r", "bin/my_r") - queryParams.Add("python", "bin/my_python") - - // Encode query parameters and set them to the URL - parsedURL.RawQuery = queryParams.Encode() - - req, err := http.NewRequest("GET", parsedURL.String(), nil) - s.NoError(err) - - interpretersFromRequest = func( - util.AbsolutePath, - http.ResponseWriter, - *http.Request, - logging.Logger, - ) (interpreters.RInterpreter, interpreters.PythonInterpreter, error) { - r := s.createMockRInterpreter() - python := s.createMockPythonInterpreter() - - return r, python, nil - } - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := getInterpreterResponse{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - - expectedPython := &config.Python{ - Version: "1.2.3", - PackageFile: "requirements.txt", - PackageManager: "pip", - RequiresPythonVersion: ">=1.1.3", - } - expectedR := &config.R{ - Version: "3.4.5", - PackageFile: "renv.lock", - PackageManager: "renv", - RequiresRVersion: ">=3.1.1", - } - - s.Equal(expectedPython, res.Python) - s.Equal("bin/my_r", res.PreferredRPath) - s.Equal(expectedR, res.R) - s.Equal("bin/my_python", res.PreferredPythonPath) -} - -func (s *GetInterpretersSuite) TestGetInterpretersWhenNoneFound() { - - h := GetActiveInterpretersHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - - // Base URL - baseURL := "/api/interpreters" - - // Create a url.URL struct - parsedURL, err := url.Parse(baseURL) - if err != nil { - panic(err) - } - - // Create a url.Values to hold query parameters - queryParams := url.Values{} - queryParams.Add("dir", ".") - queryParams.Add("r", "bin/my_r") - queryParams.Add("python", "bin/my_python") - - // Encode query parameters and set them to the URL - parsedURL.RawQuery = queryParams.Encode() - - req, err := http.NewRequest("GET", parsedURL.String(), nil) - s.NoError(err) - - interpretersFromRequest = func( - util.AbsolutePath, - http.ResponseWriter, - *http.Request, - logging.Logger, - ) (interpreters.RInterpreter, interpreters.PythonInterpreter, error) { - r := s.createMockRMissingInterpreter() - python := s.createMockPythonMissingInterpreter() - - return r, python, nil - } - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := getInterpreterResponse{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - - expectedPython := &config.Python{ - Version: "", - PackageFile: "", - PackageManager: "", - } - expectedR := &config.R{ - Version: "", - PackageFile: "", - PackageManager: "", - } - - s.Equal(expectedPython, res.Python) - s.Equal("bin/my_r", res.PreferredRPath) - s.Equal(expectedR, res.R) - s.Equal("bin/my_python", res.PreferredPythonPath) -}