diff --git a/extensions/vscode/src/api/client.ts b/extensions/vscode/src/api/client.ts index d655e2c8a..2ed51a3f6 100644 --- a/extensions/vscode/src/api/client.ts +++ b/extensions/vscode/src/api/client.ts @@ -6,7 +6,7 @@ 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 +20,6 @@ class PublishingClientApi { private client; configurations: Configurations; - interpreters: Interpreters; credentials: Credentials; contentRecords: ContentRecords; files: Files; @@ -57,7 +56,7 @@ 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 57f90a602..000000000 --- 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/api/types/configurations.test.ts b/extensions/vscode/src/api/types/configurations.test.ts index bcba28c28..bcb2ee281 100644 --- a/extensions/vscode/src/api/types/configurations.test.ts +++ b/extensions/vscode/src/api/types/configurations.test.ts @@ -45,11 +45,13 @@ describe("Configurations Types", () => { version: "4.4.0", packageFile: "renv.lock", packageManager: "renv", + requiresR: ">= 4.4.0", }); expect(config.configuration.python).toEqual({ version: "3.11.0", packageFile: "requirements.txt", packageManager: "pip", + requiresPython: ">=3.11", }); }); @@ -74,8 +76,14 @@ describe("Configurations Types", () => { UpdateConfigWithDefaults(config, interpreterDefaults); - const expectedRConf: RConfig = { ...incomingIntprConfig }; - const expectedPythonConf: PythonConfig = { ...incomingIntprConfig }; + const expectedRConf: RConfig = { + ...incomingIntprConfig, + requiresR: ">= 4.4.0", + }; + const expectedPythonConf: PythonConfig = { + ...incomingIntprConfig, + requiresPython: ">=3.11", + }; if (version === "") { expectedRConf.version = "4.4.0"; @@ -96,6 +104,78 @@ describe("Configurations Types", () => { expect(config.configuration.python).toEqual(expectedPythonConf); }, ); + + test("fills in requiresPython from defaults when config has no requiresPython", () => { + const config: Configuration = configurationFactory.build(); + config.configuration.python = { ...blankIntprConf }; + + UpdateConfigWithDefaults(config, interpreterDefaults); + expect(config.configuration.python!.requiresPython).toBe(">=3.11"); + }); + + test("preserves existing requiresPython when already set", () => { + const config: Configuration = configurationFactory.build(); + config.configuration.python = { + ...blankIntprConf, + requiresPython: ">=3.9,<4", + }; + + UpdateConfigWithDefaults(config, interpreterDefaults); + expect(config.configuration.python!.requiresPython).toBe(">=3.9,<4"); + }); + + test("fills in requiresR from defaults when config has no requiresR", () => { + const config: Configuration = configurationFactory.build(); + config.configuration.r = { ...blankIntprConf }; + + UpdateConfigWithDefaults(config, interpreterDefaults); + expect(config.configuration.r!.requiresR).toBe(">= 4.4.0"); + }); + + test("preserves existing requiresR when already set", () => { + const config: Configuration = configurationFactory.build(); + config.configuration.r = { + ...blankIntprConf, + requiresR: ">= 4.1.0", + }; + + UpdateConfigWithDefaults(config, interpreterDefaults); + expect(config.configuration.r!.requiresR).toBe(">= 4.1.0"); + }); + + test("does not set requiresPython when defaults have no requiresPython", () => { + const config: Configuration = configurationFactory.build(); + config.configuration.python = { ...blankIntprConf }; + + const defaultsWithoutRequires: InterpreterDefaults = { + ...interpreterDefaults, + python: { + version: "3.11.0", + packageFile: "requirements.txt", + packageManager: "pip", + }, + }; + + UpdateConfigWithDefaults(config, defaultsWithoutRequires); + expect(config.configuration.python!.requiresPython).toBeUndefined(); + }); + + test("does not set requiresR when defaults have no requiresR", () => { + const config: Configuration = configurationFactory.build(); + config.configuration.r = { ...blankIntprConf }; + + const defaultsWithoutRequires: InterpreterDefaults = { + ...interpreterDefaults, + r: { + version: "4.4.0", + packageFile: "renv.lock", + packageManager: "renv", + }, + }; + + UpdateConfigWithDefaults(config, defaultsWithoutRequires); + expect(config.configuration.r!.requiresR).toBeUndefined(); + }); }); describe("UpdateAllConfigsWithDefaults", () => { @@ -119,11 +199,13 @@ describe("Configurations Types", () => { version: "4.4.0", packageFile: "renv.lock", packageManager: "renv", + requiresR: ">= 4.4.0", }); expect(cf.configuration.python).toEqual({ version: "3.11.0", packageFile: "requirements.txt", packageManager: "pip", + requiresPython: ">=3.11", }); }); }); diff --git a/extensions/vscode/src/api/types/configurations.ts b/extensions/vscode/src/api/types/configurations.ts index cc6465ee0..ed9fb149c 100644 --- a/extensions/vscode/src/api/types/configurations.ts +++ b/extensions/vscode/src/api/types/configurations.ts @@ -217,6 +217,9 @@ export function UpdateConfigWithDefaults( if (!config.configuration.r.packageManager) { config.configuration.r.packageManager = defaults.r.packageManager; } + if (!config.configuration.r.requiresR) { + config.configuration.r.requiresR = defaults.r.requiresR; + } } if (config.configuration.python !== undefined) { if (!config.configuration.python.version) { @@ -229,6 +232,10 @@ export function UpdateConfigWithDefaults( config.configuration.python.packageManager = defaults.python.packageManager; } + if (!config.configuration.python.requiresPython) { + config.configuration.python.requiresPython = + defaults.python.requiresPython; + } } return config; } diff --git a/extensions/vscode/src/interpreters/integration.test.ts b/extensions/vscode/src/interpreters/integration.test.ts index f12f877c7..bb10f650a 100644 --- a/extensions/vscode/src/interpreters/integration.test.ts +++ b/extensions/vscode/src/interpreters/integration.test.ts @@ -531,4 +531,25 @@ describe("getInterpreterDefaults end-to-end", async () => { } }); }); + + test.skipIf(!pythonAvailable)( + "detects requirements.txt when given an absolute projectDir", + () => { + clearPythonVersionCache(); + return withTempDir(async (dir) => { + await writeFile( + path.join(dir, "requirements.txt"), + "requests\n", + "utf-8", + ); + + // dir is already absolute (from mkdtemp), matching the fixed call sites + // in state.ts which now resolve projectDir against the workspace root. + const result = await getInterpreterDefaults(dir, pythonCmd); + + expect(result.python.packageFile).toBe("requirements.txt"); + expect(result.python.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + }, + ); }); diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index 41b9cee27..b8f6ca603 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -1,5 +1,7 @@ // Copyright (C) 2024 by Posit Software, PBC. +import path from "node:path"; + import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { window } from "vscode"; import { AxiosError, AxiosHeaders } from "axios"; @@ -15,6 +17,7 @@ import { LocalState } from "./constants"; import { PublisherState } from "./state"; import { AllContentRecordTypes, PreContentRecord } from "src/api"; import { ConfigurationLoadError } from "src/toml"; +import { getInterpreterDefaults } from "src/interpreters"; class mockApiClient { readonly contentRecords = { @@ -26,18 +29,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(); @@ -71,6 +62,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), @@ -333,6 +335,13 @@ describe("PublisherState", () => { expect(currentConfig).toEqual(config); expect(publisherState.configurations).toEqual([config]); + // getInterpreterDefaults should receive absolute path (workspace root + projectDir) + expect(vi.mocked(getInterpreterDefaults)).toHaveBeenCalledWith( + path.join("/workspace", contentRecord.projectDir), + undefined, + undefined, + ); + // second time calls from cache currentConfig = await publisherState.getSelectedConfiguration(); @@ -594,7 +603,21 @@ describe("PublisherState", () => { test.todo("refreshContentRecords", () => {}); - test.todo("refreshConfigurations", () => {}); + test("refreshConfigurations passes absolute workspace root to getInterpreterDefaults", async () => { + const { mockContext } = mkExtensionContextStateMock({}); + const publisherState = new PublisherState(mockContext); + + mockLoadAllConfigurationsRecursive.mockResolvedValue([]); + vi.mocked(getInterpreterDefaults).mockClear(); + + await publisherState.refreshConfigurations(); + + expect(vi.mocked(getInterpreterDefaults)).toHaveBeenCalledWith( + "/workspace", + undefined, + undefined, + ); + }); test.todo("validConfigs", () => {}); diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index 2e95ea9aa..b714b4f61 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -1,5 +1,7 @@ // Copyright (C) 2025 by Posit Software, PBC. +import path from "node:path"; + import { Disposable, env, @@ -25,6 +27,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, @@ -249,15 +252,14 @@ export class PublisherState implements Disposable { root, ); - const api = await useApi(); const python = await getPythonInterpreterPath(); const r = await getRInterpreterPath(); - const defaults = await api.interpreters.get( - contentRecord.projectDir, - r, - python, + const defaults = await getInterpreterDefaults( + path.join(root, contentRecord.projectDir), + python?.pythonPath, + r?.rPath, ); - const updated = UpdateConfigWithDefaults(cfg, defaults.data); + const updated = UpdateConfigWithDefaults(cfg, defaults); // its not foolproof, but it may help if (!this.findConfig(updated.configurationName, updated.projectDir)) { this.configurations.push(updated); @@ -332,16 +334,16 @@ export class PublisherState implements Disposable { return; } - const api = await useApi(); const python = await getPythonInterpreterPath(); const r = await getRInterpreterPath(); const configs = await loadAllConfigurationsRecursive(root); - const defaults = await api.interpreters.get(".", r, python); - this.configurations = UpdateAllConfigsWithDefaults( - configs, - defaults.data, + const defaults = await getInterpreterDefaults( + root, + python?.pythonPath, + r?.rPath, ); + this.configurations = UpdateAllConfigsWithDefaults(configs, defaults); }, ); } catch (error: unknown) { diff --git a/extensions/vscode/src/test/unit-test-utils/factories.ts b/extensions/vscode/src/test/unit-test-utils/factories.ts index b8d20d36a..2f72b5c5b 100644 --- a/extensions/vscode/src/test/unit-test-utils/factories.ts +++ b/extensions/vscode/src/test/unit-test-utils/factories.ts @@ -45,12 +45,14 @@ export const interpreterDefaultsFactory = Factory.define( packageFile: "requirements.txt", packageManager: "pip", version: "3.11.0", + requiresPython: ">=3.11", }, preferredRPath: "usr/bin/R", r: { packageFile: "renv.lock", packageManager: "renv", version: "4.4.0", + requiresR: ">= 4.4.0", }, }), ); diff --git a/internal/services/api/api_service.go b/internal/services/api/api_service.go index 99c0d2209..c44882827 100644 --- a/internal/services/api/api_service.go +++ b/internal/services/api/api_service.go @@ -156,10 +156,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 3df34fb74..000000000 --- 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 4232c9e80..000000000 --- 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) -}