diff --git a/cmd/publisher/commands/deploy.go b/cmd/publisher/commands/deploy.go index 6bd2bef84..da94c4602 100644 --- a/cmd/publisher/commands/deploy.go +++ b/cmd/publisher/commands/deploy.go @@ -12,6 +12,7 @@ import ( "github.com/posit-dev/publisher/internal/deployment" "github.com/posit-dev/publisher/internal/events" "github.com/posit-dev/publisher/internal/initialize" + "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/publish" "github.com/posit-dev/publisher/internal/state" "github.com/posit-dev/publisher/internal/util" @@ -28,6 +29,7 @@ type DeployCmd struct { } func (cmd *DeployCmd) Run(args *cli_types.CommonArgs, ctx *cli_types.CLIContext) error { + log := logging.New() absPath, err := cmd.Path.Abs() if err != nil { return err @@ -58,7 +60,7 @@ func (cmd *DeployCmd) Run(args *cli_types.CommonArgs, ctx *cli_types.CLIContext) if err != nil { return err } - stateStore, err := state.New(absPath, cmd.AccountName, cmd.ConfigName, "", cmd.SaveName, ctx.Accounts, nil, false) + stateStore, err := state.New(absPath, cmd.AccountName, cmd.ConfigName, "", cmd.SaveName, ctx.Accounts, nil, false, nil, nil, log) if err != nil { return err } diff --git a/cmd/publisher/commands/redeploy.go b/cmd/publisher/commands/redeploy.go index 4fa1d368e..88abcd9ef 100644 --- a/cmd/publisher/commands/redeploy.go +++ b/cmd/publisher/commands/redeploy.go @@ -12,6 +12,7 @@ import ( "github.com/posit-dev/publisher/internal/deployment" "github.com/posit-dev/publisher/internal/events" "github.com/posit-dev/publisher/internal/initialize" + "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/publish" "github.com/posit-dev/publisher/internal/state" "github.com/posit-dev/publisher/internal/util" @@ -27,6 +28,7 @@ type RedeployCmd struct { } func (cmd *RedeployCmd) Run(args *cli_types.CommonArgs, ctx *cli_types.CLIContext) error { + log := logging.New() absPath, err := cmd.Path.Abs() if err != nil { return err @@ -43,7 +45,7 @@ func (cmd *RedeployCmd) Run(args *cli_types.CommonArgs, ctx *cli_types.CLIContex if err != nil { return fmt.Errorf("invalid deployment name '%s': %w", cmd.TargetName, err) } - stateStore, err := state.New(absPath, "", cmd.ConfigName, cmd.TargetName, "", ctx.Accounts, nil, false) + stateStore, err := state.New(absPath, "", cmd.ConfigName, cmd.TargetName, "", ctx.Accounts, nil, false, nil, nil, log) if err != nil { return err } diff --git a/extensions/vscode/src/api/client.ts b/extensions/vscode/src/api/client.ts index b13a6e8df..c0e23cd9a 100644 --- a/extensions/vscode/src/api/client.ts +++ b/extensions/vscode/src/api/client.ts @@ -6,6 +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 { EntryPoints } from "./resources/Entrypoints"; @@ -15,6 +16,7 @@ class PublishingClientApi { private client; configurations: Configurations; + interpreters: Interpreters; credentials: Credentials; contentRecords: ContentRecords; files: Files; @@ -52,6 +54,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.entrypoints = new EntryPoints(this.client); diff --git a/extensions/vscode/src/api/resources/Configurations.ts b/extensions/vscode/src/api/resources/Configurations.ts index a1f7176bf..f988279c5 100644 --- a/extensions/vscode/src/api/resources/Configurations.ts +++ b/extensions/vscode/src/api/resources/Configurations.ts @@ -8,6 +8,7 @@ import { ConfigurationError, ConfigurationInspectionResult, } from "../types/configurations"; +import { PythonExecutable, RExecutable } from "../../types/shared"; export class Configurations { private client: AxiosInstance; @@ -80,19 +81,18 @@ export class Configurations { // 500 - internal server error inspect( dir: string, - python?: string, - r?: string, + python: PythonExecutable | undefined, + r: RExecutable | undefined, params?: { entrypoint?: string; recursive?: boolean }, ) { return this.client.post( "/inspect", - { - python, - r, - }, + {}, { params: { dir, + python: python !== undefined ? python.pythonPath : undefined, + r: r !== undefined ? r.rPath : "", ...params, }, }, diff --git a/extensions/vscode/src/api/resources/ContentRecords.ts b/extensions/vscode/src/api/resources/ContentRecords.ts index b696897ae..4765458f0 100644 --- a/extensions/vscode/src/api/resources/ContentRecords.ts +++ b/extensions/vscode/src/api/resources/ContentRecords.ts @@ -8,6 +8,7 @@ import { ContentRecord, Environment, } from "../types/contentRecords"; +import { PythonExecutable, RExecutable } from "../../types/shared"; export class ContentRecords { private client: AxiosInstance; @@ -74,17 +75,15 @@ export class ContentRecords { configName: string, insecure: boolean, dir: string, + r: RExecutable | undefined, + python: PythonExecutable | undefined, secrets?: Record, - r?: string, - python?: string, ) { const data = { account: accountName, config: configName, secrets: secrets, insecure: insecure, - r: r, - python: python, }; const encodedTarget = encodeURIComponent(targetName); return this.client.post<{ localId: string }>( @@ -93,6 +92,8 @@ export class ContentRecords { { params: { dir, + r: r !== undefined ? r.rPath : "", + python: python !== undefined ? python.pythonPath : "", }, }, ); diff --git a/extensions/vscode/src/api/resources/Interpreters.ts b/extensions/vscode/src/api/resources/Interpreters.ts new file mode 100644 index 000000000..57f90a602 --- /dev/null +++ b/extensions/vscode/src/api/resources/Interpreters.ts @@ -0,0 +1,31 @@ +// 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/resources/Packages.ts b/extensions/vscode/src/api/resources/Packages.ts index d15e18cb2..29eda7924 100644 --- a/extensions/vscode/src/api/resources/Packages.ts +++ b/extensions/vscode/src/api/resources/Packages.ts @@ -6,6 +6,7 @@ import { PythonPackagesResponse, ScanPythonPackagesResponse, } from "../types/packages"; +import { PythonExecutable, RExecutable } from "../../types/shared"; export class Packages { private client: AxiosInstance; @@ -48,13 +49,20 @@ export class Packages { // 500 - internal server error createPythonRequirementsFile( dir: string, - python?: string, + python: PythonExecutable | undefined, saveName?: string, ) { return this.client.post( "packages/python/scan", - { python, saveName }, - { params: { dir } }, + { + saveName, + }, + { + params: { + dir, + python: python !== undefined ? python.pythonPath : undefined, + }, + }, ); } @@ -62,11 +70,22 @@ export class Packages { // 200 - success // 400 - bad request // 500 - internal server error - createRRequirementsFile(dir: string, saveName?: string, r?: string) { + createRRequirementsFile( + dir: string, + r: RExecutable | undefined, + saveName?: string, + ) { return this.client.post( "packages/r/scan", - { saveName, r }, - { params: { dir } }, + { + saveName, + }, + { + params: { + dir, + r: r !== undefined ? r.rPath : "", + }, + }, ); } } diff --git a/extensions/vscode/src/api/types/configurations.ts b/extensions/vscode/src/api/types/configurations.ts index 4dae3588a..a8b0f0bc1 100644 --- a/extensions/vscode/src/api/types/configurations.ts +++ b/extensions/vscode/src/api/types/configurations.ts @@ -3,6 +3,7 @@ import { AgentError } from "./error"; import { ConnectConfig } from "./connect"; import { SchemaURL } from "./schema"; +import { InterpreterDefaults } from "./interpreters"; export type ConfigurationLocation = { configurationName: string; @@ -176,3 +177,48 @@ export type Group = { name?: string; permissions: string; }; + +export function UpdateAllConfigsWithDefaults( + configs: (Configuration | ConfigurationError)[], + defaults: InterpreterDefaults, +) { + for (let i = 0; i < configs.length; i++) { + configs[i] = UpdateConfigWithDefaults(configs[i], defaults); + } + return configs; +} + +export function UpdateConfigWithDefaults( + config: Configuration | ConfigurationError, + defaults: InterpreterDefaults, +) { + if (isConfigurationError(config)) { + return config; + } + + // Fill in empty definitions with the current defaults + // but only if the section is defined (which indicates the dependency) + if (config.configuration.r !== undefined) { + if (config.configuration.r.version === "") { + config.configuration.r.version = defaults.r.version; + } + if (config.configuration.r.packageFile === "") { + config.configuration.r.packageFile = defaults.r.packageFile; + } + if (config.configuration.r.packageManager === "") { + config.configuration.r.packageManager = defaults.r.packageManager; + } + } + if (config.configuration.python !== undefined) { + if (config.configuration.python.version === "") { + config.configuration.python.version = defaults.r.version; + } + if (config.configuration.python.packageFile === "") { + config.configuration.python.packageFile = defaults.r.packageFile; + } + if (config.configuration.python.packageManager === "") { + config.configuration.python.packageManager = defaults.r.packageManager; + } + } + return config; +} diff --git a/extensions/vscode/src/api/types/interpreters.ts b/extensions/vscode/src/api/types/interpreters.ts new file mode 100644 index 000000000..65ee4145b --- /dev/null +++ b/extensions/vscode/src/api/types/interpreters.ts @@ -0,0 +1,10 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { PythonConfig, RConfig } from "./configurations"; + +export type InterpreterDefaults = { + python: PythonConfig; + preferredPythonPath: string; + r: RConfig; + preferredRPath: string; +}; diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index 2afc54eb7..72ced651f 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -30,6 +30,18 @@ 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(); @@ -61,9 +73,14 @@ vi.mock("vscode", () => { showInformationMessage: vi.fn(), }; + const workspaceStateMock = { + get: vi.fn(), + }; + return { Disposable: disposableMock, window: windowMock, + workspace: workspaceStateMock, }; }); @@ -249,11 +266,12 @@ describe("PublisherState", () => { const contentRecordState: DeploymentSelectorState = selectionStateFactory.build(); - const { mockContext } = mkExtensionContextStateMock({}); + const { mockContext, mockWorkspace } = mkExtensionContextStateMock({}); const publisherState = new PublisherState(mockContext); // No config get due to no content record set let currentConfig = await publisherState.getSelectedConfiguration(); + expect(mockWorkspace.get).toHaveBeenCalled(); expect(currentConfig).toEqual(undefined); expect(mockClient.configurations.get).not.toHaveBeenCalled(); diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index 944f587f4..51ac5ae76 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -11,6 +11,8 @@ import { isContentRecordError, PreContentRecord, PreContentRecordWithConfig, + UpdateAllConfigsWithDefaults, + UpdateConfigWithDefaults, useApi, } from "src/api"; import { normalizeURL } from "src/utils/url"; @@ -27,6 +29,7 @@ import { } from "src/utils/errorTypes"; import { DeploymentSelector, SelectionState } from "src/types/shared"; import { LocalState, Views } from "./constants"; +import { getPythonInterpreterPath, getRInterpreterPath } from "./utils/vscode"; function findContentRecord< T extends ContentRecord | PreContentRecord | PreContentRecordWithConfig, @@ -202,11 +205,19 @@ export class PublisherState implements Disposable { // if not found, then retrieve it and add it to our cache. try { const api = await useApi(); + const python = await getPythonInterpreterPath(); + const r = await getRInterpreterPath(); + const response = await api.configurations.get( contentRecord.configurationName, contentRecord.projectDir, ); - const cfg = response.data; + const defaults = await api.interpreters.get( + contentRecord.projectDir, + r, + python, + ); + const cfg = UpdateConfigWithDefaults(response.data, defaults.data); // its not foolproof, but it may help if (!this.findConfig(cfg.configurationName, cfg.projectDir)) { this.configurations.push(cfg); @@ -267,10 +278,17 @@ export class PublisherState implements Disposable { Views.HomeView, async () => { const api = await useApi(); + const python = await getPythonInterpreterPath(); + const r = await getRInterpreterPath(); + const response = await api.configurations.getAll(".", { recursive: true, }); - this.configurations = response.data; + const defaults = await api.interpreters.get(".", r, python); + this.configurations = UpdateAllConfigsWithDefaults( + response.data, + defaults.data, + ); }, ); } catch (error: unknown) { diff --git a/extensions/vscode/src/types/shared.ts b/extensions/vscode/src/types/shared.ts index 8fa50360d..9f6a5db7c 100644 --- a/extensions/vscode/src/types/shared.ts +++ b/extensions/vscode/src/types/shared.ts @@ -29,3 +29,21 @@ export type DeploymentObjects = { configuration: Configuration; credential: Credential; }; + +export type RExecutable = { + rPath: string; +}; +export function NewRExecutable(rPath: string): RExecutable { + return { + rPath, + }; +} + +export type PythonExecutable = { + pythonPath: string; +}; +export function NewPythonExecutable(pythonPath: string): PythonExecutable { + return { + pythonPath, + }; +} diff --git a/extensions/vscode/src/utils/vscode.ts b/extensions/vscode/src/utils/vscode.ts index 9070a84eb..84c900542 100644 --- a/extensions/vscode/src/utils/vscode.ts +++ b/extensions/vscode/src/utils/vscode.ts @@ -5,6 +5,12 @@ import { fileExists, isDir } from "./files"; import { delay } from "./throttle"; import { substituteVariables } from "./variables"; import { LanguageRuntimeMetadata, PositronApi } from "positron"; +import { + NewPythonExecutable, + NewRExecutable, + PythonExecutable, + RExecutable, +} from "src/types/shared"; declare global { function acquirePositronApi(): PositronApi; @@ -106,17 +112,19 @@ async function getPythonInterpreterFromVSCode(): Promise { return python; } -export async function getPythonInterpreterPath(): Promise { +export async function getPythonInterpreterPath(): Promise< + PythonExecutable | undefined +> { let python: string | undefined; python = await getPreferredRuntimeFromPositron("python"); if (python !== undefined) { console.log("Using selected Python interpreter", python); - return python; + return NewPythonExecutable(python); } python = await getPythonInterpreterFromVSCode(); if (python !== undefined) { console.log("Using Python from VSCode", python); - return python; + return NewPythonExecutable(python); } // We don't know the interpreter path. // The backend will run Python from PATH. @@ -124,11 +132,11 @@ export async function getPythonInterpreterPath(): Promise { return python; } -export async function getRInterpreterPath(): Promise { +export async function getRInterpreterPath(): Promise { const r = await getPreferredRuntimeFromPositron("r"); if (r !== undefined) { console.log("Using selected R interpreter", r); - return r; + return NewRExecutable(r); } // We don't know the interpreter path. // The backend will run R from PATH. diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index ec52885f7..39d38129e 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -207,9 +207,9 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { configurationName, !extensionSettings.verifyCertificates(), // insecure = !verifyCertificates projectDir, - secrets, r, python, + secrets, ); deployProject( deploymentName, @@ -756,8 +756,8 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { return await api.packages.createRRequirementsFile( activeConfiguration.projectDir, - relPathPackageFile, r, + relPathPackageFile, ); }, ); diff --git a/internal/config/config.go b/internal/config/config.go index 224b5ad02..ebd31de99 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,7 +69,6 @@ func FromFile(path util.AbsolutePath) (*Config, error) { if err != nil { return nil, err } - cfg.FillDefaults() cfg.Comments, err = readLeadingComments(path) if err != nil { return nil, err @@ -109,26 +108,6 @@ func (cfg *Config) WriteFile(path util.AbsolutePath) error { return cfg.Write(f) } -func (cfg *Config) FillDefaults() { - if cfg.R != nil { - if cfg.R.PackageFile == "" { - cfg.R.PackageFile = "renv.lock" - } - if cfg.R.PackageManager == "" { - cfg.R.PackageManager = "renv" - } - } - - if cfg.Python != nil { - if cfg.Python.PackageFile == "" { - cfg.Python.PackageFile = "requirements.txt" - } - if cfg.Python.PackageManager == "" { - cfg.Python.PackageManager = "pip" - } - } -} - func (cfg *Config) AddSecret(secret string) error { // Check if the secret already exists before adding for _, s := range cfg.Secrets { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8e4203434..92f5504f0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -85,42 +85,6 @@ func (s *ConfigSuite) TestFromExampleFile() { s.Equal(true, *valuePtr) } -func (s *ConfigSuite) TestFromFileFillsDefaultsForPython() { - configFile := GetConfigPath(s.cwd, "defaults") - cfg := New() - cfg.Type = "python-streamlit" - cfg.Entrypoint = "app.py" - cfg.Python = &Python{ - Version: "3.4.5", - } - err := cfg.WriteFile(configFile) - s.NoError(err) - - cfgFromFile, err := FromFile(configFile) - s.NoError(err) - s.NotNil(cfgFromFile) - s.Equal(cfgFromFile.Python.PackageFile, "requirements.txt") - s.Equal(cfgFromFile.Python.PackageManager, "pip") -} - -func (s *ConfigSuite) TestFromFileFillsDefaultsForR() { - configFile := GetConfigPath(s.cwd, "defaults") - cfg := New() - cfg.Type = "r-shiny" - cfg.Entrypoint = "app.R" - cfg.R = &R{ - Version: "4.4.1", - } - err := cfg.WriteFile(configFile) - s.NoError(err) - - cfgFromFile, err := FromFile(configFile) - s.NoError(err) - s.NotNil(cfgFromFile) - s.Equal(cfgFromFile.R.PackageFile, "renv.lock") - s.Equal(cfgFromFile.R.PackageManager, "renv") -} - func (s *ConfigSuite) TestFromFileErr() { cfg, err := FromFile(s.cwd.Join("nonexistent.toml")) s.ErrorIs(err, fs.ErrNotExist) @@ -193,49 +157,6 @@ func (s *ConfigSuite) TestReadComments() { s.Equal([]string{" These are comments.", " They will be preserved."}, cfg.Comments) } -func (s *ConfigSuite) TestFillDefaultsDoesNotAddROrPythonSection() { - cfg := New() - cfg.FillDefaults() - s.Nil(cfg.R) - s.Nil(cfg.Python) -} - -func (s *ConfigSuite) TestFillDefaultsAddsPackageFileAndPackageManager() { - cfg := New() - cfg.R = &R{Version: "4.4.1"} - cfg.Python = &Python{Version: "3.12.7"} - cfg.FillDefaults() - - s.NotNil(cfg.R) - s.NotNil(cfg.Python) - - s.Equal(cfg.R.Version, "4.4.1") - s.Equal(cfg.R.PackageFile, "renv.lock") - s.Equal(cfg.R.PackageManager, "renv") - - s.Equal(cfg.Python.Version, "3.12.7") - s.Equal(cfg.Python.PackageFile, "requirements.txt") - s.Equal(cfg.Python.PackageManager, "pip") -} - -func (s *ConfigSuite) TestFillDefaultsDoesNotOverwrite() { - cfg := New() - cfg.R = &R{Version: "4.4.1", PackageFile: "custom.lock", PackageManager: "custom"} - cfg.Python = &Python{Version: "3.12.7", PackageFile: "custom.txt", PackageManager: "custom"} - cfg.FillDefaults() - - s.NotNil(cfg.R) - s.NotNil(cfg.Python) - - s.Equal(cfg.R.Version, "4.4.1") - s.Equal(cfg.R.PackageFile, "custom.lock") - s.Equal(cfg.R.PackageManager, "custom") - - s.Equal(cfg.Python.Version, "3.12.7") - s.Equal(cfg.Python.PackageFile, "custom.txt") - s.Equal(cfg.Python.PackageManager, "custom") -} - func (s *ConfigSuite) TestApplySecretActionAdd() { cfg := New() cfg.Secrets = []string{} diff --git a/internal/config/types.go b/internal/config/types.go index b125c5ffa..b70a5adec 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -1,5 +1,7 @@ package config +import "github.com/posit-dev/publisher/internal/interpreters" + // Copyright (C) 2023 by Posit Software, PBC. type ContentType string @@ -123,17 +125,63 @@ func (c *Config) HasSecret(secret string) bool { type Environment = map[string]string type Python struct { - Version string `toml:"version" json:"version"` + Version string `toml:"version,omitempty" json:"version"` PackageFile string `toml:"package_file,omitempty" json:"packageFile"` PackageManager string `toml:"package_manager,omitempty" json:"packageManager"` } +func (p *Python) FillDefaults( + pythonInterpreter *interpreters.PythonInterpreter, +) { + if p != nil && pythonInterpreter != nil && (*pythonInterpreter).IsPythonExecutableValid() { + python := *pythonInterpreter + if p.Version == "" { + pythonVersion, pythonVersionError := python.GetPythonVersion() + if pythonVersionError == nil { + p.Version = pythonVersion + } + } + if p.PackageFile == "" { + pythonLockFile, _, pythonLockFileError := python.GetLockFilePath() + if pythonLockFileError == nil { + p.PackageFile = pythonLockFile.String() + } + } + if p.PackageManager == "" { + p.PackageManager = python.GetPackageManager() + } + } +} + type R struct { - Version string `toml:"version" json:"version"` + Version string `toml:"version,omitempty" json:"version"` PackageFile string `toml:"package_file,omitempty" json:"packageFile"` PackageManager string `toml:"package_manager,omitempty" json:"packageManager"` } +func (r *R) FillDefaults( + rInterpreter *interpreters.RInterpreter, +) { + if r != nil && rInterpreter != nil && (*rInterpreter).IsRExecutableValid() { + rLang := *rInterpreter + if r.Version == "" { + rVersion, rVersionError := rLang.GetRVersion() + if rVersionError == nil { + r.Version = rVersion + } + } + if r.PackageFile == "" { + rLockFile, _, rLockFileError := rLang.GetLockFilePath() + if rLockFileError == nil { + r.PackageFile = rLockFile.String() + } + } + if r.PackageManager == "" { + r.PackageManager = rLang.GetPackageManager() + } + } +} + type Jupyter struct { HideAllInput bool `toml:"hide_all_input,omitempty" json:"hideAllInput"` HideTaggedInput bool `toml:"hide_tagged_input,omitempty" json:"hideTaggedInput"` diff --git a/internal/config/types_defaults_test.go b/internal/config/types_defaults_test.go new file mode 100644 index 000000000..81d6de439 --- /dev/null +++ b/internal/config/types_defaults_test.go @@ -0,0 +1,269 @@ +package config + +// Copyright (C) 2024 by Posit Software, PBC. + +import ( + "errors" + "testing" + + "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/spf13/afero" + "github.com/stretchr/testify/suite" +) + +type ConfigFillDefaultsSuite struct { + suite.Suite + cwd util.AbsolutePath + log logging.Logger + rInterpreter *interpreters.RInterpreter + rMissingInterpreter *interpreters.RInterpreter + pythonInterpreter *interpreters.PythonInterpreter + pythonMissingInterpreter *interpreters.PythonInterpreter +} + +func (s *ConfigFillDefaultsSuite) 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("1.2.3", nil) + relPath := util.NewRelativePath("renv.lock", s.cwd.Fs()) + iMock.On("GetLockFilePath").Return(relPath, true, nil) + iMock.On("GetPackageManager").Return("renv") + return iMock +} + +func (s *ConfigFillDefaultsSuite) 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") + return iMock +} + +func (s *ConfigFillDefaultsSuite) 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("GetLockFilePath").Return("requirements.txt", true, nil) + return iMock +} + +func (s *ConfigFillDefaultsSuite) 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("GetLockFilePath").Return("", false, missingError) + return iMock +} + +func (s *ConfigFillDefaultsSuite) SetupTest() { + fs := afero.NewMemMapFs() + cwd, err := util.Getwd(fs) + s.Nil(err) + s.cwd = cwd + s.cwd.MkdirAll(0700) + s.log = logging.New() + + rMock1 := s.createMockRInterpreter() + s.rInterpreter = &rMock1 + rMock2 := s.createMockRMissingInterpreter() + s.rMissingInterpreter = &rMock2 + pythonMock1 := s.createMockPythonInterpreter() + s.pythonInterpreter = &pythonMock1 + pythonMock2 := s.createMockPythonMissingInterpreter() + s.pythonMissingInterpreter = &pythonMock2 +} + +func TestConfig_FillDefaults(t *testing.T) { + suite.Run(t, new(ConfigFillDefaultsSuite)) +} + +func (s *ConfigFillDefaultsSuite) TestFillDefaultsR_Empty() { + r := &R{} + r.FillDefaults(s.rInterpreter) + expectedR := &R{ + Version: "1.2.3", + PackageFile: "renv.lock", + PackageManager: "renv", + } + s.Equal(expectedR, r) +} + +func (s *ConfigFillDefaultsSuite) TestFillDefaultsR_NoVersion() { + r := &R{ + PackageFile: "lock", + PackageManager: "another_renv", + } + r.FillDefaults(s.rInterpreter) + expectedR := &R{ + Version: "1.2.3", + PackageFile: "lock", + PackageManager: "another_renv", + } + s.Equal(expectedR, r) +} + +func (s *ConfigFillDefaultsSuite) TestFillDefaultsR_NoPackageManager() { + r := &R{ + Version: "9.9.9", + PackageFile: "lock", + } + r.FillDefaults(s.rInterpreter) + expectedR := &R{ + Version: "9.9.9", + PackageFile: "lock", + PackageManager: "renv", + } + s.Equal(expectedR, r) +} + +func (s *ConfigFillDefaultsSuite) TestFillDefaultsR_NoPackageFile() { + r := &R{ + Version: "9.9.9", + PackageManager: "another_renv", + } + r.FillDefaults(s.rInterpreter) + expectedR := &R{ + Version: "9.9.9", + PackageFile: "renv.lock", + PackageManager: "another_renv", + } + s.Equal(expectedR, r) +} + +func (s *ConfigFillDefaultsSuite) TestFillDefaultsR_NoDefaultsNeeded() { + r := &R{ + Version: "9.9.9", + PackageFile: "lock", + PackageManager: "another_renv", + } + r.FillDefaults(s.rInterpreter) + expectedR := &R{ + Version: "9.9.9", + PackageFile: "lock", + PackageManager: "another_renv", + } + s.Equal(expectedR, r) +} + +func (s *ConfigFillDefaultsSuite) TestFillDefaultsR_NoInterpreter() { + r := &R{ + Version: "9.9.9", + PackageFile: "lock", + PackageManager: "another_renv", + } + r.FillDefaults(s.rMissingInterpreter) + expectedR := &R{ + Version: "9.9.9", + PackageFile: "lock", + PackageManager: "another_renv", + } + s.Equal(expectedR, r) + + r = &R{} + r.FillDefaults(s.rMissingInterpreter) + expectedR = &R{} + s.Equal(expectedR, r) +} + +func (s *ConfigFillDefaultsSuite) TestFillDefaultsPython_Empty() { + p := &Python{} + p.FillDefaults(s.pythonInterpreter) + expectedPython := &Python{ + Version: "1.2.3", + PackageFile: "requirements.txt", + PackageManager: "pip", + } + s.Equal(expectedPython, p) +} + +func (s *ConfigFillDefaultsSuite) TestFillDefaultsPython_NoVersion() { + p := &Python{ + PackageFile: "requirements.txt", + PackageManager: "pip", + } + p.FillDefaults(s.pythonInterpreter) + expectedPython := &Python{ + Version: "1.2.3", + PackageFile: "requirements.txt", + PackageManager: "pip", + } + s.Equal(expectedPython, p) +} + +func (s *ConfigFillDefaultsSuite) TestFillDefaultsPython_NoPackageManager() { + p := &Python{ + Version: "9.9.9", + PackageFile: "lock", + } + p.FillDefaults(s.pythonInterpreter) + expectedPython := &Python{ + Version: "9.9.9", + PackageFile: "lock", + PackageManager: "pip", + } + s.Equal(expectedPython, p) +} + +func (s *ConfigFillDefaultsSuite) TestFillDefaultsPython_NoPackageFile() { + p := &Python{ + Version: "9.9.9", + PackageManager: "another", + } + p.FillDefaults(s.pythonInterpreter) + expectedPython := &Python{ + Version: "9.9.9", + PackageFile: "requirements.txt", + PackageManager: "another", + } + s.Equal(expectedPython, p) +} + +func (s *ConfigFillDefaultsSuite) TestFillDefaultsPython_NoDefaultsNeeded() { + p := &Python{ + Version: "9.9.9", + PackageFile: "lock", + PackageManager: "another_pip", + } + p.FillDefaults(s.pythonInterpreter) + expectedPython := &Python{ + Version: "9.9.9", + PackageFile: "lock", + PackageManager: "another_pip", + } + s.Equal(expectedPython, p) +} + +func (s *ConfigFillDefaultsSuite) TestFillDefaultsPython_NoInterpreter() { + p := &Python{ + Version: "9.9.9", + PackageFile: "lock", + PackageManager: "another_pip", + } + p.FillDefaults(s.pythonMissingInterpreter) + expectedPython := &Python{ + Version: "9.9.9", + PackageFile: "lock", + PackageManager: "another_pip", + } + s.Equal(expectedPython, p) + + p = &Python{} + p.FillDefaults(s.pythonMissingInterpreter) + expectedPython = &Python{} + s.Equal(expectedPython, p) +} diff --git a/internal/config/types_test.go b/internal/config/types_secrets_test.go similarity index 100% rename from internal/config/types_test.go rename to internal/config/types_secrets_test.go diff --git a/internal/initialize/initialize.go b/internal/initialize/initialize.go index cfcd8547a..46e9b8d82 100644 --- a/internal/initialize/initialize.go +++ b/internal/initialize/initialize.go @@ -124,8 +124,7 @@ func (i *defaultInitialize) inspectProject(base util.AbsolutePath, pythonExecuta log.Debug("Error while inspecting to generate a python based configuration", "error", err.Error()) return nil, err } - cfg.Python = pyConfig - cfg.Files = append(cfg.Files, fmt.Sprint("/", cfg.Python.PackageFile)) + cfg.Files = append(cfg.Files, fmt.Sprint("/", pyConfig.PackageFile)) } rInspector, err := i.rInspectorFactory(base, rExecutable, log, i.rInterpreterFactory, nil) @@ -146,8 +145,7 @@ func (i *defaultInitialize) inspectProject(base util.AbsolutePath, pythonExecuta log.Debug("Error while inspecting to generate an R based configuration", "error", err.Error()) return nil, err } - cfg.R = rConfig - cfg.Files = append(cfg.Files, fmt.Sprint("/", cfg.R.PackageFile)) + cfg.Files = append(cfg.Files, fmt.Sprint("/", rConfig.PackageFile)) } cfg.Comments = strings.Split(initialComment, "\n") @@ -204,8 +202,7 @@ func (i *defaultInitialize) normalizeConfig( log.Debug("Error while inspecting to generate a python based configuration", "error", err.Error()) return err } - cfg.Python = pyConfig - cfg.Files = append(cfg.Files, fmt.Sprint("/", cfg.Python.PackageFile)) + cfg.Files = append(cfg.Files, fmt.Sprint("/", pyConfig.PackageFile)) } rInspector, err := i.rInspectorFactory(base, rExecutable, log, i.rInterpreterFactory, nil) @@ -224,8 +221,7 @@ func (i *defaultInitialize) normalizeConfig( log.Debug("Error while inspecting to generate an R based configuration", "error", err.Error()) return err } - cfg.R = rConfig - cfg.Files = append(cfg.Files, fmt.Sprint("/", cfg.R.PackageFile)) + cfg.Files = append(cfg.Files, fmt.Sprint("/", rConfig.PackageFile)) } cfg.Comments = strings.Split(initialComment, "\n") diff --git a/internal/initialize/initialize_test.go b/internal/initialize/initialize_test.go index 40bb1869e..a66159b7c 100644 --- a/internal/initialize/initialize_test.go +++ b/internal/initialize/initialize_test.go @@ -183,12 +183,16 @@ func (s *InitializeSuite) createRequirementsFile() { s.NoError(err) } +var emptyPyConfig = &config.Python{} + var expectedPyConfig = &config.Python{ Version: "3.4.5", PackageManager: "pip", PackageFile: "requirements.txt", } +var emptyRConfig = &config.R{} + var expectedRConfig = &config.R{ Version: "1.2.3", PackageManager: "renv", @@ -214,7 +218,7 @@ func (s *InitializeSuite) TestInitInferredType() { cfg2, err := config.FromFile(configPath) s.NoError(err) s.Equal(config.ContentTypePythonFlask, cfg.Type) - s.Equal(expectedPyConfig, cfg.Python) + s.Equal(emptyPyConfig, cfg.Python) s.Equal(cfg, cfg2) } @@ -238,7 +242,7 @@ func (s *InitializeSuite) TestInitRequirementsFile() { cfg2, err := config.FromFile(configPath) s.NoError(err) s.Equal(cfg.Type, config.ContentTypeHTML) - s.Equal("3.4.5", cfg.Python.Version) + s.Equal(true, cfg.Python == nil) s.Equal(cfg, cfg2) } @@ -261,7 +265,7 @@ func (s *InitializeSuite) TestInitIfNeededWhenNeeded() { cfg, err := config.FromFile(configPath) s.NoError(err) s.Equal(cfg.Type, config.ContentTypePythonFlask) - s.Equal("3.4.5", cfg.Python.Version) + s.Equal(emptyPyConfig, cfg.Python) } func (s *InitializeSuite) TestInitIfNeededWhenNotNeeded() { @@ -276,7 +280,6 @@ func (s *InitializeSuite) TestInitIfNeededWhenNotNeeded() { PackageManager: "pip", } cfg.WriteFile(configPath) - cfg.FillDefaults() pythonInspectorFactory := func( util.AbsolutePath, @@ -334,7 +337,7 @@ func (s *InitializeSuite) TestGetPossibleRConfig() { s.Equal(config.ContentTypeRShiny, configs[0].Type) s.Equal("app.R", configs[0].Entrypoint) s.Equal([]string{"/app.R", "/renv.lock"}, configs[0].Files) - s.Equal(expectedRConfig, configs[0].R) + s.Equal(emptyRConfig, configs[0].R) } func (s *InitializeSuite) TestGetPossiblePythonConfig() { @@ -359,7 +362,7 @@ func (s *InitializeSuite) TestGetPossiblePythonConfig() { s.Equal(config.ContentTypePythonFlask, configs[0].Type) s.Equal("app.py", configs[0].Entrypoint) s.Equal([]string{"/app.py", "/requirements.txt"}, configs[0].Files) - s.Equal(expectedPyConfig, configs[0].Python) + s.Equal(emptyPyConfig, configs[0].Python) } func (s *InitializeSuite) TestGetPossibleHTMLConfig() { diff --git a/internal/inspect/python_test.go b/internal/inspect/python_test.go index d9215f4b5..9d8d69841 100644 --- a/internal/inspect/python_test.go +++ b/internal/inspect/python_test.go @@ -118,6 +118,8 @@ func (s *PythonSuite) TestInspectPython_PythonNotAvailable() { i.On("IsPythonExecutableValid").Return(false) i.On("GetPythonExecutable").Return(util.AbsolutePath{}, interpreters.MissingPythonError) i.On("GetPythonVersion").Return("", interpreters.MissingPythonError) + i.On("GetPackageManager").Return("pip") + i.On("GetLockFilePath").Return("requirements.txt", true, nil) return i, nil } diff --git a/internal/inspect/r_test.go b/internal/inspect/r_test.go index 041612995..442293958 100644 --- a/internal/inspect/r_test.go +++ b/internal/inspect/r_test.go @@ -76,6 +76,8 @@ func (s *RSuite) TestInspectWithRFound() { i.On("GetRVersion").Return("1.2.3", nil) relPath = util.NewRelativePath(s.cwd.Join("renv.lock").String(), s.cwd.Fs()) i.On("GetLockFilePath").Return(relPath, true, nil) + i.On("GetPackageManager").Return("renv") + return i, nil } log := logging.New() diff --git a/internal/interpreters/python.go b/internal/interpreters/python.go index bb15cd6c1..a72bd7b2d 100644 --- a/internal/interpreters/python.go +++ b/internal/interpreters/python.go @@ -21,7 +21,10 @@ var pythonVersionCache = make(map[string]string) type PythonInterpreter interface { IsPythonExecutableValid() bool GetPythonExecutable() (util.AbsolutePath, error) + GetLockFilePath() (util.RelativePath, bool, error) GetPythonVersion() (string, error) + GetPackageManager() string + GetPreferredPath() string } type defaultPythonInterpreter struct { @@ -239,3 +242,18 @@ func (i *defaultPythonInterpreter) GetPythonVersion() (string, error) { func (i *defaultPythonInterpreter) IsPythonExecutableValid() bool { return i.pythonExecutable.String() != "" && i.version != "" } + +func (i *defaultPythonInterpreter) GetPackageManager() string { + return "pip" +} + +func (i *defaultPythonInterpreter) GetPreferredPath() string { + return i.preferredPath.String() +} + +func (i *defaultPythonInterpreter) GetLockFilePath() (util.RelativePath, bool, error) { + lockFile := "requirements.txt" + lockFileAbsPath := i.base.Join(lockFile) + exists, err := i.existsFunc(lockFileAbsPath.Path) + return util.NewRelativePath(lockFile, i.fs), exists, err +} diff --git a/internal/interpreters/python_mock.go b/internal/interpreters/python_mock.go index 35313bbca..39b1a06da 100644 --- a/internal/interpreters/python_mock.go +++ b/internal/interpreters/python_mock.go @@ -67,3 +67,26 @@ func (m *MockPythonInterpreter) GetPythonVersion() (string, error) { } } } + +func (m *MockPythonInterpreter) GetPackageManager() string { + args := m.Called() + arg0 := args.Get(0) + return arg0.(string) +} + +// (util.RelativePath, bool, error) +func (m *MockPythonInterpreter) GetLockFilePath() (util.RelativePath, bool, error) { + args := m.Called() + arg0 := args.Get(0) + arg1 := args.Get(1) + if arg0 == nil { + return util.NewRelativePath("", nil), false, args.Error(1) + } + return util.NewRelativePath(arg0.(string), nil), arg1.(bool), args.Error(2) +} + +func (m *MockPythonInterpreter) GetPreferredPath() string { + args := m.Called() + arg0 := args.Get(0) + return arg0.(string) +} diff --git a/internal/interpreters/python_test.go b/internal/interpreters/python_test.go index 8ba18eddb..b5a4dc35a 100644 --- a/internal/interpreters/python_test.go +++ b/internal/interpreters/python_test.go @@ -66,6 +66,8 @@ func (s *PythonSuite) TestGetPythonVersionFromExecutable() { i, err := NewPythonInterpreter(s.cwd, pythonPath.Path, log, executor, pathLooker, MockExistsTrue) s.NoError(err) + s.Equal(pythonPath.String(), i.GetPreferredPath()) + defaultPython := i.(*defaultPythonInterpreter) s.Equal("3.10.4", defaultPython.version) diff --git a/internal/interpreters/r.go b/internal/interpreters/r.go index 1f1faaf16..a1e9d6dc1 100644 --- a/internal/interpreters/r.go +++ b/internal/interpreters/r.go @@ -43,9 +43,12 @@ type renvCommandObj struct { } type RInterpreter interface { + IsRExecutableValid() bool GetRExecutable() (util.AbsolutePath, error) GetRVersion() (string, error) GetLockFilePath() (util.RelativePath, bool, error) + GetPackageManager() string + GetPreferredPath() string CreateLockfile(util.AbsolutePath) error RenvEnvironmentErrorCheck() *types.AgentError } @@ -515,3 +518,11 @@ func (i *defaultRInterpreter) CreateLockfile(lockfilePath util.AbsolutePath) err i.log.Debug("renv::snapshot()", "out", string(stdout), "err", string(stderr)) return err } + +func (i *defaultRInterpreter) GetPackageManager() string { + return "renv" +} + +func (i *defaultRInterpreter) GetPreferredPath() string { + return i.preferredPath.String() +} diff --git a/internal/interpreters/r_mock.go b/internal/interpreters/r_mock.go index e64687dc1..8ba9e8aab 100644 --- a/internal/interpreters/r_mock.go +++ b/internal/interpreters/r_mock.go @@ -24,6 +24,21 @@ func (m *MockRInterpreter) Init() error { return nil } +func (m *MockRInterpreter) IsRExecutableValid() bool { + args := m.Called() + arg0 := args.Get(0) + if arg0 == nil { + return false + } else { + var i interface{} = arg0 + if b, ok := i.(bool); ok { + return b + } else { + return false + } + } +} + func (m *MockRInterpreter) GetRExecutable() (util.AbsolutePath, error) { args := m.Called() arg0 := args.Get(0) @@ -84,3 +99,15 @@ func (m *MockRInterpreter) RenvEnvironmentErrorCheck() *types.AgentError { args := m.Called() return args.Error(0).(*types.AgentError) } + +func (m *MockRInterpreter) GetPackageManager() string { + args := m.Called() + arg0 := args.Get(0) + return arg0.(string) +} + +func (m *MockRInterpreter) GetPreferredPath() string { + args := m.Called() + arg0 := args.Get(0) + return arg0.(string) +} diff --git a/internal/interpreters/r_test.go b/internal/interpreters/r_test.go index 8381582af..802461f44 100644 --- a/internal/interpreters/r_test.go +++ b/internal/interpreters/r_test.go @@ -47,6 +47,9 @@ func (s *RSuite) TestNewRInterpreter() { pathLooker.On("LookPath", "R").Return("", nil) i, _ := NewRInterpreter(s.cwd, rPath, log, nil, pathLooker, nil) + + s.Equal(rPath.String(), i.GetPreferredPath()) + interpreter := i.(*defaultRInterpreter) s.Equal(rPath, interpreter.preferredPath) s.Equal(log, interpreter.log) diff --git a/internal/publish/publish.go b/internal/publish/publish.go index 6d676d54d..a5125bc6f 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -331,6 +331,8 @@ func (p *defaultPublisher) publishWithClient( } manifest.Packages = rPackages } + p.log.Debug("Generated manifest:", manifest) + bundler, err := bundles.NewBundler(p.Dir, manifest, p.Config.Files, p.log) if err != nil { return err diff --git a/internal/schema/schemas/draft/posit-publishing-schema-v3.json b/internal/schema/schemas/draft/posit-publishing-schema-v3.json index 3e62dcec1..9412e0bc0 100644 --- a/internal/schema/schemas/draft/posit-publishing-schema-v3.json +++ b/internal/schema/schemas/draft/posit-publishing-schema-v3.json @@ -99,7 +99,6 @@ "type": "object", "additionalProperties": false, "description": "Python language and dependencies.", - "required": ["version"], "properties": { "version": { "type": "string", @@ -115,9 +114,8 @@ "package_manager": { "type": "string", "default": "pip", - "enum": ["pip", "conda", "pipenv", "poetry", "none"], "description": "Package manager that will install the dependencies. If package-manager is none, dependencies are assumed to be pre-installed on the server. The default is 'pip'.", - "examples": ["pip"] + "examples": ["pip", "conda", "pipenv", "poetry", "none"] } } }, @@ -125,7 +123,6 @@ "type": "object", "additionalProperties": false, "description": "R language and dependencies.", - "required": ["version"], "properties": { "version": { "type": "string", @@ -141,9 +138,8 @@ "package_manager": { "type": "string", "default": "renv", - "enum": ["renv", "none"], "description": "Package manager that will install the dependencies. If package-manager is none, dependencies will be assumed to be pre-installed on the server.", - "examples": ["renv"] + "examples": ["renv", "none"] } } }, diff --git a/internal/schema/schemas/posit-publishing-schema-v3.json b/internal/schema/schemas/posit-publishing-schema-v3.json index 2c6d98d6c..f5d29a5d0 100644 --- a/internal/schema/schemas/posit-publishing-schema-v3.json +++ b/internal/schema/schemas/posit-publishing-schema-v3.json @@ -88,7 +88,6 @@ "type": "object", "additionalProperties": false, "description": "Python language and dependencies.", - "required": ["version"], "properties": { "version": { "type": "string", @@ -104,9 +103,8 @@ "package_manager": { "type": "string", "default": "pip", - "enum": ["pip", "none"], "description": "Package manager that will install the dependencies. If package-manager is none, dependencies will not be installed.", - "examples": ["pip"] + "examples": ["pip", "none"] } } }, @@ -114,7 +112,6 @@ "type": "object", "additionalProperties": false, "description": "R language and dependencies.", - "required": ["version"], "properties": { "version": { "type": "string", @@ -130,9 +127,8 @@ "package_manager": { "type": "string", "default": "renv", - "enum": ["renv", "none"], "description": "Package manager that will install the dependencies. If package-manager is none, dependencies will be assumed to be pre-installed on the server.", - "examples": ["renv"] + "examples": ["renv", "none"] } } }, diff --git a/internal/services/api/api_helpers.go b/internal/services/api/api_helpers.go index 27ed0d231..340e186dc 100644 --- a/internal/services/api/api_helpers.go +++ b/internal/services/api/api_helpers.go @@ -9,6 +9,7 @@ import ( "html" "net/http" + "github.com/posit-dev/publisher/internal/interpreters" "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/util" ) @@ -82,3 +83,28 @@ func ProjectDirFromRequest(base util.AbsolutePath, w http.ResponseWriter, req *h } return projectDir, relProjectDir, nil } + +func InterpretersFromRequest( + base util.AbsolutePath, + w http.ResponseWriter, + req *http.Request, + log logging.Logger, +) ( + *interpreters.RInterpreter, + *interpreters.PythonInterpreter, + error, +) { + rExecutable := req.URL.Query().Get("r") + rInterpreter, err := interpreters.NewRInterpreter(base, util.NewPath(rExecutable, nil), log, nil, nil, nil) + if err != nil { + InternalError(w, req, log, err) + return nil, nil, err + } + pythonExecutable := req.URL.Query().Get("python") + pythonInterpreter, err := interpreters.NewPythonInterpreter(base, util.NewPath(pythonExecutable, nil), log, nil, nil, nil) + if err != nil { + InternalError(w, req, log, err) + return nil, nil, err + } + return &rInterpreter, &pythonInterpreter, nil +} diff --git a/internal/services/api/api_service.go b/internal/services/api/api_service.go index a7cbe961d..cc2533425 100644 --- a/internal/services/api/api_service.go +++ b/internal/services/api/api_service.go @@ -181,6 +181,10 @@ 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 new file mode 100644 index 000000000..55e941440 --- /dev/null +++ b/internal/services/api/get_interpreters.go @@ -0,0 +1,70 @@ +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"` +} + +// toGetAccountResponse converts an internal Account object +// 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 new file mode 100644 index 000000000..92977e51a --- /dev/null +++ b/internal/services/api/get_interpreters_test.go @@ -0,0 +1,225 @@ +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") + 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("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("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", + } + expectedR := &config.R{ + Version: "3.4.5", + PackageFile: "renv.lock", + PackageManager: "renv", + } + + 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) +} diff --git a/internal/services/api/post_deployment.go b/internal/services/api/post_deployment.go index 7cecbf82a..76eaf9385 100644 --- a/internal/services/api/post_deployment.go +++ b/internal/services/api/post_deployment.go @@ -48,6 +48,12 @@ func PostDeploymentHandlerFunc( // Response already returned by ProjectDirFromRequest return } + rInterpreter, pythonInterpreter, err := InterpretersFromRequest(projectDir, w, req, log) + if err != nil { + // Response already returned by InterpretersFromRequest + return + } + dec := json.NewDecoder(req.Body) dec.DisallowUnknownFields() var b PostDeploymentRequestBody @@ -61,7 +67,7 @@ func PostDeploymentHandlerFunc( InternalError(w, req, log, err) return } - newState, err := stateFactory(projectDir, b.AccountName, b.ConfigName, name, "", accountList, b.Secrets, b.Insecure) + newState, err := stateFactory(projectDir, b.AccountName, b.ConfigName, name, "", accountList, b.Secrets, b.Insecure, rInterpreter, pythonInterpreter, log) if err != nil { if errors.Is(err, accounts.ErrAccountNotFound) { log.Error("Deployment initialization failure - account not found", "error", err.Error()) diff --git a/internal/services/api/post_deployment_test.go b/internal/services/api/post_deployment_test.go index a10070ea4..7b174f234 100644 --- a/internal/services/api/post_deployment_test.go +++ b/internal/services/api/post_deployment_test.go @@ -16,6 +16,7 @@ import ( "github.com/posit-dev/publisher/internal/config" "github.com/posit-dev/publisher/internal/deployment" "github.com/posit-dev/publisher/internal/events" + "github.com/posit-dev/publisher/internal/interpreters" "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/publish" "github.com/posit-dev/publisher/internal/state" @@ -61,7 +62,26 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFunc() { log := logging.New() rec := httptest.NewRecorder() - req, err := http.NewRequest("POST", "/api/deployments/myTargetName", nil) + + // Base URL + baseURL := "/api/deployments/myTargetName" + + // 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("POST", parsedURL.String(), nil) s.NoError(err) req = mux.SetURLVars(req, map[string]string{"name": "myTargetName"}) @@ -75,7 +95,12 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFunc() { publisher := &mockPublisher{} publisher.On("PublishDirectory", mock.Anything).Return(nil) - publisherFactory = func(*state.State, util.Path, util.Path, events.Emitter, logging.Logger) (publish.Publisher, error) { + publisherFactory = func( + state *state.State, + rExecutable util.Path, + pythonExecutable util.Path, + emitter events.Emitter, + log logging.Logger) (publish.Publisher, error) { return publisher, nil } stateFactory = func( @@ -83,7 +108,11 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFunc() { accountName, configName, targetName, saveName string, accountList accounts.AccountList, secrets map[string]string, - insecure bool) (*state.State, error) { + insecure bool, + rInterpreter *interpreters.RInterpreter, + pythonInterpreter *interpreters.PythonInterpreter, + log logging.Logger, + ) (*state.State, error) { s.Equal(s.cwd, path) s.Equal("myTargetName", targetName) @@ -91,6 +120,10 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFunc() { s.Equal("default", configName) s.Equal("", saveName) + // confirm that the interpreter paths made it through the request. + s.Equal("bin/my_r", (*rInterpreter).GetPreferredPath()) + s.Equal("bin/my_python", (*pythonInterpreter).GetPreferredPath()) + st := state.Empty() st.Account = &accounts.Account{} st.Account.Insecure = insecure @@ -129,7 +162,11 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFuncStateErr() accountName, configName, targetName, saveName string, accountList accounts.AccountList, secrets map[string]string, - insecure bool) (*state.State, error) { + insecure bool, + rInterpreter *interpreters.RInterpreter, + pythonInterpreter *interpreters.PythonInterpreter, + log logging.Logger, + ) (*state.State, error) { return nil, errors.New("test error from state factory") } @@ -196,7 +233,11 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFuncPublishErr accountName, configName, targetName, saveName string, accountList accounts.AccountList, secrets map[string]string, - insecure bool) (*state.State, error) { + insecure bool, + rInterpreter *interpreters.RInterpreter, + pythonInterpreter *interpreters.PythonInterpreter, + log logging.Logger, + ) (*state.State, error) { st := state.Empty() st.Account = &accounts.Account{} @@ -251,7 +292,11 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentSubdir() { accountName, configName, targetName, saveName string, accountList accounts.AccountList, secrets map[string]string, - insecure bool) (*state.State, error) { + insecure bool, + rInterpreter *interpreters.RInterpreter, + pythonInterpreter *interpreters.PythonInterpreter, + log logging.Logger, + ) (*state.State, error) { s.Equal(s.cwd, path) s.Equal("myTargetName", targetName) @@ -301,7 +346,11 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFuncWithSecret accountName, configName, targetName, saveName string, accountList accounts.AccountList, secrets map[string]string, - insecure bool) (*state.State, error) { + insecure bool, + rInterpreter *interpreters.RInterpreter, + pythonInterpreter *interpreters.PythonInterpreter, + log logging.Logger, + ) (*state.State, error) { s.Equal(s.cwd, path) s.Equal("myTargetName", targetName) diff --git a/internal/state/state.go b/internal/state/state.go index a422770bd..ea749edae 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -10,6 +10,8 @@ import ( "github.com/posit-dev/publisher/internal/accounts" "github.com/posit-dev/publisher/internal/config" "github.com/posit-dev/publisher/internal/deployment" + "github.com/posit-dev/publisher/internal/interpreters" + "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/util" ) @@ -96,7 +98,19 @@ func Empty() *State { var ErrServerURLMismatch = errors.New("the account provided is for a different server; it must match the server for this deployment") -func New(path util.AbsolutePath, accountName, configName, targetName string, saveName string, accountList accounts.AccountList, secrets map[string]string, insecure bool) (*State, error) { +func New( + path util.AbsolutePath, + accountName string, + configName string, + targetName string, + saveName string, + accountList accounts.AccountList, + secrets map[string]string, + insecure bool, + rInterpreter *interpreters.RInterpreter, + pythonInterpreter *interpreters.PythonInterpreter, + log logging.Logger, +) (*State, error) { var target *deployment.Deployment var account *accounts.Account var cfg *config.Config @@ -149,6 +163,18 @@ func New(path util.AbsolutePath, accountName, configName, targetName string, sav return nil, err } + // Update defaults using values from active interpreters + if cfg.R != nil { + log.Debug("applying defaults to Config.R section", "before", cfg.R) + cfg.R.FillDefaults(rInterpreter) + log.Debug("after applying defaults to Config.R section", "after", cfg.R) + } + if cfg.Python != nil { + log.Debug("applying defaults to Config.Python section", "before", cfg.Python) + cfg.Python.FillDefaults(pythonInterpreter) + log.Debug("after applying defaults to Config.Python section", "after", cfg.Python) + } + // Check that the secrets passed are in the config for secret := range secrets { if !cfg.HasSecret(secret) { diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 794762971..9d0907f44 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -11,7 +11,9 @@ import ( "github.com/posit-dev/publisher/internal/accounts" "github.com/posit-dev/publisher/internal/config" "github.com/posit-dev/publisher/internal/deployment" + "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" @@ -269,15 +271,13 @@ func (s *StateSuite) TestNewLocalID() { s.NotEqual(id, id2) } -func (s *StateSuite) makeConfiguration(name string) *config.Config { +func (s *StateSuite) makeConfiguration(name string, pythonConfig *config.Python, rConfig *config.R) *config.Config { path := config.GetConfigPath(s.cwd, name) cfg := config.New() - cfg.Type = config.ContentTypePythonDash + cfg.Type = config.ContentTypeUnknown cfg.Entrypoint = "app.py" - cfg.Python = &config.Python{ - Version: "3.4.5", - PackageManager: "pip", - } + cfg.Python = pythonConfig + cfg.R = rConfig err := cfg.WriteFile(path) s.NoError(err) r, err := config.FromFile(path) @@ -287,7 +287,14 @@ func (s *StateSuite) makeConfiguration(name string) *config.Config { func (s *StateSuite) makeConfigurationWithSecrets(name string, secrets []string) *config.Config { path := config.GetConfigPath(s.cwd, name) - cfg := s.makeConfiguration(name) + cfg := s.makeConfiguration( + name, + &config.Python{ + Version: "3.4.5", + PackageManager: "pip", + }, + nil, + ) cfg.Secrets = secrets err := cfg.WriteFile(path) s.NoError(err) @@ -299,9 +306,16 @@ func (s *StateSuite) TestNew() { acct := accounts.Account{} accts.On("GetAllAccounts").Return([]accounts.Account{acct}, nil) - cfg := s.makeConfiguration("default") + cfg := s.makeConfiguration( + "default", + &config.Python{ + Version: "3.4.5", + PackageManager: "pip", + }, + nil, + ) - state, err := New(s.cwd, "", "", "", "", accts, nil, false) + state, err := New(s.cwd, "", "", "", "", accts, nil, false, nil, nil, s.log) s.NoError(err) s.NotNil(state) s.Equal(state.AccountName, "") @@ -321,11 +335,18 @@ func (s *StateSuite) TestNewNonDefaultConfig() { accts.On("GetAllAccounts").Return([]accounts.Account{acct}, nil) configName := "staging" - cfg := s.makeConfiguration(configName) + cfg := s.makeConfiguration( + configName, + &config.Python{ + Version: "3.4.5", + PackageManager: "pip", + }, + nil, + ) insecure := true acct.Insecure = insecure - state, err := New(s.cwd, "", configName, "", "", accts, nil, insecure) + state, err := New(s.cwd, "", configName, "", "", accts, nil, insecure, nil, nil, s.log) s.NoError(err) s.NotNil(state) s.Equal("", state.AccountName) @@ -343,7 +364,7 @@ func (s *StateSuite) TestNewConfigErr() { acct := accounts.Account{} accts.On("GetAllAccounts").Return([]accounts.Account{acct}, nil) - state, err := New(s.cwd, "", "", "", "", accts, nil, false) + state, err := New(s.cwd, "", "", "", "", accts, nil, false, nil, nil, s.log) s.NotNil(err) s.ErrorContains(err, "couldn't load configuration") s.Nil(state) @@ -365,7 +386,14 @@ func (s *StateSuite) TestNewWithTarget() { accts.On("GetAccountByServerURL", "https://saved.server.example.com").Return(&acct1, nil) accts.On("GetAccountByServerURL", "https://another.server.example.com").Return(&acct2, nil) - cfg := s.makeConfiguration("savedConfigName") + cfg := s.makeConfiguration( + "savedConfigName", + &config.Python{ + Version: "3.4.5", + PackageManager: "pip", + }, + nil, + ) targetPath := deployment.GetDeploymentPath(s.cwd, "myTargetName") d := deployment.New() @@ -381,7 +409,7 @@ func (s *StateSuite) TestNewWithTarget() { _, err := d.WriteFile(targetPath, "", s.log) s.NoError(err) - state, err := New(s.cwd, "", "", "myTargetName", "", accts, nil, false) + state, err := New(s.cwd, "", "", "myTargetName", "", accts, nil, false, nil, nil, s.log) s.NoError(err) s.NotNil(state) s.Equal("acct1", state.AccountName) @@ -408,7 +436,14 @@ func (s *StateSuite) TestNewWithTargetAndAccount() { accts.On("GetAccountByName", "acct2").Return(&acct2, nil) accts.On("GetAccountByServerURL", "https://saved.server.example.com").Return(&acct1, nil) - cfg := s.makeConfiguration("savedConfigName") + cfg := s.makeConfiguration( + "savedConfigName", + &config.Python{ + Version: "3.4.5", + PackageManager: "pip", + }, + nil, + ) targetPath := deployment.GetDeploymentPath(s.cwd, "myTargetName") d := deployment.New() @@ -422,7 +457,7 @@ func (s *StateSuite) TestNewWithTargetAndAccount() { _, err := d.WriteFile(targetPath, "", s.log) s.NoError(err) - state, err := New(s.cwd, "acct2", "", "myTargetName", "mySaveName", accts, nil, false) + state, err := New(s.cwd, "acct2", "", "myTargetName", "mySaveName", accts, nil, false, nil, nil, s.log) s.NoError(err) s.NotNil(state) s.Equal("acct2", state.AccountName) @@ -445,7 +480,7 @@ func (s *StateSuite) TestNewWithSecrets() { "DB_PASSWORD": "password456", } - state, err := New(s.cwd, "", "", "", "", accts, secrets, false) + state, err := New(s.cwd, "", "", "", "", accts, secrets, false, nil, nil, s.log) s.NoError(err) s.NotNil(state) s.Equal(secrets, state.Secrets) @@ -455,18 +490,206 @@ func (s *StateSuite) TestNewWithInvalidSecret() { accts := &accounts.MockAccountList{} acct := accounts.Account{} accts.On("GetAllAccounts").Return([]accounts.Account{acct}, nil) - s.makeConfiguration("default") + s.makeConfiguration( + "default", + &config.Python{ + Version: "3.4.5", + PackageManager: "pip", + }, + nil, + ) secrets := map[string]string{ "INVALID_SECRET": "secret123", } - state, err := New(s.cwd, "", "", "", "", accts, secrets, false) + state, err := New(s.cwd, "", "", "", "", accts, secrets, false, nil, nil, s.log) s.NotNil(err) s.ErrorContains(err, "secret 'INVALID_SECRET' is not in the configuration") s.Nil(state) } +func (s *StateSuite) 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("1.2.3", nil) + relPath := util.NewRelativePath("renv.lock", s.cwd.Fs()) + iMock.On("GetLockFilePath").Return(relPath, true, nil) + iMock.On("GetPackageManager").Return("renv") + return iMock +} + +func (s *StateSuite) 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") + return iMock +} + +func (s *StateSuite) 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("GetLockFilePath").Return("requirements.txt", true, nil) + return iMock +} + +func (s *StateSuite) 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("GetLockFilePath").Return("", false, missingError) + return iMock +} + +func (s *StateSuite) TestNewWithInterpreterDefaultFillsNotNeeded() { + accts := &accounts.MockAccountList{} + acct := accounts.Account{} + accts.On("GetAllAccounts").Return([]accounts.Account{acct}, nil) + + configName := "staging" + cfg := s.makeConfiguration( + configName, + &config.Python{ + Version: "9.9.9", + PackageManager: "my-pip", + PackageFile: "my-file.txt", + }, + &config.R{ + Version: "9.9.8", + PackageManager: "my-renv", + PackageFile: "my-renv.lock", + }, + ) + insecure := true + acct.Insecure = insecure + + rInterpreter := s.createMockRInterpreter() + pythonInterpreter := s.createMockPythonInterpreter() + + state, err := New(s.cwd, "", configName, "", "", accts, nil, insecure, &rInterpreter, &pythonInterpreter, s.log) + s.NoError(err) + s.NotNil(state) + s.Equal("", state.AccountName) + s.Equal(configName, state.ConfigName) + s.Equal("", state.TargetName) + s.Equal(&acct, state.Account) + s.Equal(cfg, state.Config) + s.Equal(state.Account.Insecure, true) + // Target is never nil. We create a new target if no target ID was provided. + s.NotNil(state.Target) +} + +func (s *StateSuite) TestNewWithInterpreterDefaultFillsNeeded() { + accts := &accounts.MockAccountList{} + acct := accounts.Account{} + accts.On("GetAllAccounts").Return([]accounts.Account{acct}, nil) + + configName := "staging" + cfg := s.makeConfiguration( + configName, + &config.Python{}, + &config.R{}, + ) + insecure := true + acct.Insecure = insecure + + rInterpreter := s.createMockRInterpreter() + pythonInterpreter := s.createMockPythonInterpreter() + + // We expect that the New method will call these, so we'll call it ourselves + // on our expected values + cfg.R.FillDefaults(&rInterpreter) + cfg.Python.FillDefaults(&pythonInterpreter) + + state, err := New(s.cwd, "", configName, "", "", accts, nil, insecure, &rInterpreter, &pythonInterpreter, s.log) + s.NoError(err) + s.NotNil(state) + s.Equal("", state.AccountName) + s.Equal(configName, state.ConfigName) + s.Equal("", state.TargetName) + s.Equal(&acct, state.Account) + s.Equal(cfg, state.Config) + s.Equal(state.Account.Insecure, true) + // Target is never nil. We create a new target if no target ID was provided. + s.NotNil(state.Target) +} + +func (s *StateSuite) TestNewWithInterpreterDefaultFillsNeededButNoInterpreters() { + accts := &accounts.MockAccountList{} + acct := accounts.Account{} + accts.On("GetAllAccounts").Return([]accounts.Account{acct}, nil) + + configName := "staging" + cfg := s.makeConfiguration( + configName, + &config.Python{}, + &config.R{}, + ) + insecure := true + acct.Insecure = insecure + + // By having the interpreters not be valid, calling the Fill Defaults shouldn't modify anything + rInterpreter := s.createMockRMissingInterpreter() + pythonInterpreter := s.createMockPythonMissingInterpreter() + + state, err := New(s.cwd, "", configName, "", "", accts, nil, insecure, &rInterpreter, &pythonInterpreter, s.log) + s.NoError(err) + s.NotNil(state) + s.Equal("", state.AccountName) + s.Equal(configName, state.ConfigName) + s.Equal("", state.TargetName) + s.Equal(&acct, state.Account) + s.Equal(cfg, state.Config) + s.Equal(state.Account.Insecure, true) + // Target is never nil. We create a new target if no target ID was provided. + s.NotNil(state.Target) +} + +func (s *StateSuite) TestNewWithInterpreterNoInterpreterSections() { + accts := &accounts.MockAccountList{} + acct := accounts.Account{} + accts.On("GetAllAccounts").Return([]accounts.Account{acct}, nil) + + configName := "staging" + cfg := s.makeConfiguration( + configName, + nil, + nil, + ) + insecure := true + acct.Insecure = insecure + + // With interpreters set to nil, there should be no change to the sections + rInterpreter := s.createMockRInterpreter() + pythonInterpreter := s.createMockPythonInterpreter() + + state, err := New(s.cwd, "", configName, "", "", accts, nil, insecure, &rInterpreter, &pythonInterpreter, s.log) + s.NoError(err) + s.NotNil(state) + s.Equal("", state.AccountName) + s.Equal(configName, state.ConfigName) + s.Equal("", state.TargetName) + s.Equal(&acct, state.Account) + s.Equal(cfg, state.Config) + s.Equal(state.Account.Insecure, true) + // Target is never nil. We create a new target if no target ID was provided. + s.NotNil(state.Target) +} + func (s *StateSuite) TestGetDefaultAccountNone() { actual, err := getDefaultAccount([]accounts.Account{}) s.Nil(actual)