From 6b99102fcd4c209e1572ddc5ed4a74b42aed03e2 Mon Sep 17 00:00:00 2001 From: Marcos Navarro <m@marcosn.com> Date: Wed, 9 Oct 2024 13:18:51 -0600 Subject: [PATCH 1/9] Better messaging for missing requirements.txt or renv.lock --- extensions/vscode/src/eventErrors.test.ts | 36 ++++++ extensions/vscode/src/eventErrors.ts | 33 ++++- extensions/vscode/src/utils/errorTypes.ts | 4 +- internal/clients/connect/capabilities.go | 16 ++- internal/clients/connect/client_connect.go | 4 +- .../clients/connect/client_connect_test.go | 108 +++++++++++++++- .../connect/testdata/missing-reqs/app.py | 120 ++++++++++++++++++ .../connect/testdata/python-bundle/app.py | 120 ++++++++++++++++++ .../testdata/python-bundle/requirements.txt | 6 + internal/publish/r_package_descriptions.go | 11 ++ .../publish/r_package_descriptions_test.go | 21 +++ internal/types/error.go | 2 + 12 files changed, 466 insertions(+), 15 deletions(-) create mode 100644 internal/clients/connect/testdata/missing-reqs/app.py create mode 100644 internal/clients/connect/testdata/python-bundle/app.py create mode 100644 internal/clients/connect/testdata/python-bundle/requirements.txt diff --git a/extensions/vscode/src/eventErrors.test.ts b/extensions/vscode/src/eventErrors.test.ts index 1763560df..51215f20d 100644 --- a/extensions/vscode/src/eventErrors.test.ts +++ b/extensions/vscode/src/eventErrors.test.ts @@ -7,6 +7,8 @@ import { isCodedEventErrorMessage, isEvtErrDeploymentFailed, isEvtErrRenvLockPackagesReadingFailed, + isEvtErrRequirementsReadingFailed, + isEvtErrDeployedContentNotRunning, handleEventCodedError, } from "./eventErrors"; import { ErrorCode } from "./utils/errorTypes"; @@ -66,6 +68,30 @@ describe("Event errors", () => { expect(result).toBe(true); }); + test("isEvtErrRequirementsReadingFailed", () => { + // Message with another error code + let streamMsg = mkEventStreamMsg({}, "unknown"); + let result = isEvtErrRequirementsReadingFailed(streamMsg); + expect(result).toBe(false); + + // Message with error code + streamMsg = mkEventStreamMsg({}, "requirementsFileReadingError"); + result = isEvtErrRequirementsReadingFailed(streamMsg); + expect(result).toBe(true); + }); + + test("isEvtErrDeployedContentNotRunning", () => { + // Message with another error code + let streamMsg = mkEventStreamMsg({}, "unknown"); + let result = isEvtErrDeployedContentNotRunning(streamMsg); + expect(result).toBe(false); + + // Message with error code + streamMsg = mkEventStreamMsg({}, "deployedContentNotRunning"); + result = isEvtErrDeployedContentNotRunning(streamMsg); + expect(result).toBe(true); + }); + test("handleEventCodedError", () => { const msgData = { dashboardUrl: "https://here.it.is/content/abcdefg", @@ -86,5 +112,15 @@ describe("Event errors", () => { streamMsg = mkEventStreamMsg(msgData, "renvlockPackagesReadingError"); resultMsg = handleEventCodedError(streamMsg); expect(resultMsg).toBe("Could not scan R packages from renv lockfile"); + + msgData.message = "requirements.txt is missing"; + streamMsg = mkEventStreamMsg(msgData, "requirementsFileReadingError"); + resultMsg = handleEventCodedError(streamMsg); + expect(resultMsg).toBe("requirements.txt is missing"); + + msgData.message = "deployed content not running"; + streamMsg = mkEventStreamMsg(msgData, "deployedContentNotRunning"); + resultMsg = handleEventCodedError(streamMsg); + expect(resultMsg).toBe("deployed content not running"); }); }); diff --git a/extensions/vscode/src/eventErrors.ts b/extensions/vscode/src/eventErrors.ts index b62c74ab1..32d40241d 100644 --- a/extensions/vscode/src/eventErrors.ts +++ b/extensions/vscode/src/eventErrors.ts @@ -25,6 +25,10 @@ type lockfileReadingEvtErr = baseEvtErr & { lockfile: string; }; +type requirementsReadingEvtErr = baseEvtErr & { + requirementsFile: string; +}; + export const isEvtErrDeploymentFailed = ( emsg: EventStreamMessageErrorCoded, ): emsg is EventStreamMessageErrorCoded<baseEvtErr> => { @@ -37,14 +41,33 @@ export const isEvtErrRenvLockPackagesReadingFailed = ( return emsg.errCode === "renvlockPackagesReadingError"; }; +export const isEvtErrRequirementsReadingFailed = ( + emsg: EventStreamMessageErrorCoded, +): emsg is EventStreamMessageErrorCoded<requirementsReadingEvtErr> => { + return emsg.errCode === "requirementsFileReadingError"; +}; + +export const isEvtErrDeployedContentNotRunning = ( + emsg: EventStreamMessageErrorCoded, +): emsg is EventStreamMessageErrorCoded<baseEvtErr> => { + return emsg.errCode === "deployedContentNotRunning"; +}; + +export const useEvtErrKnownMessage = ( + emsg: EventStreamMessageErrorCoded, +): boolean => { + return ( + isEvtErrDeploymentFailed(emsg) || + isEvtErrRenvLockPackagesReadingFailed(emsg) || + isEvtErrRequirementsReadingFailed(emsg) || + isEvtErrDeployedContentNotRunning(emsg) + ); +}; + export const handleEventCodedError = ( emsg: EventStreamMessageErrorCoded, ): string => { - if (isEvtErrDeploymentFailed(emsg)) { - return emsg.data.message; - } - - if (isEvtErrRenvLockPackagesReadingFailed(emsg)) { + if (useEvtErrKnownMessage(emsg)) { return emsg.data.message; } diff --git a/extensions/vscode/src/utils/errorTypes.ts b/extensions/vscode/src/utils/errorTypes.ts index f78b67237..be8d47556 100644 --- a/extensions/vscode/src/utils/errorTypes.ts +++ b/extensions/vscode/src/utils/errorTypes.ts @@ -10,7 +10,9 @@ export type ErrorCode = | "invalidConfigFile" | "errorCertificateVerification" | "deployFailed" - | "renvlockPackagesReadingError"; + | "renvlockPackagesReadingError" + | "requirementsFileReadingError" + | "deployedContentNotRunning"; export type axiosErrorWithJson<T = { code: ErrorCode; details: unknown }> = AxiosError & { diff --git a/internal/clients/connect/capabilities.go b/internal/clients/connect/capabilities.go index f70d27407..c6144d290 100644 --- a/internal/clients/connect/capabilities.go +++ b/internal/clients/connect/capabilities.go @@ -26,11 +26,14 @@ type allSettings struct { } const requirementsFileMissing = ` -can't find the package file (%s) in the project directory. -Create the file, listing the packages your project depends on. -Or scan your project dependencies using scan button in -the Python Packages section of the UI and review the -generated file` +Missing package file %s. The file must exist and be included in the deployment. +Create the file listing your project dependencies. +Or do an automatic scan with the help of the Python Packages section +of the Publisher extension and review the generated file` + +type requirementsErrDetails struct { + RequirementsFile string `json:"requirements_file"` +} func checkRequirementsFile(base util.AbsolutePath, requirementsFilename string) error { packageFile := base.Join(requirementsFilename) @@ -39,7 +42,8 @@ func checkRequirementsFile(base util.AbsolutePath, requirementsFilename string) return err } if !exists { - return fmt.Errorf(requirementsFileMissing, requirementsFilename) + missingErr := fmt.Errorf(requirementsFileMissing, requirementsFilename) + return types.NewAgentError(types.ErrorRequirementsFileReading, missingErr, requirementsErrDetails{RequirementsFile: packageFile.String()}) } return nil } diff --git a/internal/clients/connect/client_connect.go b/internal/clients/connect/client_connect.go index e0e247c6f..27c6d1fa9 100644 --- a/internal/clients/connect/client_connect.go +++ b/internal/clients/connect/client_connect.go @@ -462,7 +462,7 @@ func (c *ConnectClient) WaitForTask(taskID types.TaskID, log logging.Logger) err } } -var errValidationFailed = errors.New("couldn't access the deployed content; see the logs in Connect for details") +var errValidationFailed = errors.New("deployed content does not seem to be running. See the logs in Connect for details") func (c *ConnectClient) ValidateDeployment(contentID types.ContentID, log logging.Logger) error { url := fmt.Sprintf("/content/%s/", contentID) @@ -475,7 +475,7 @@ func (c *ConnectClient) ValidateDeployment(contentID types.ContentID, log loggin if ok { if httpErr.Status >= 500 { // Validation failed - the content is not up and running - return errValidationFailed + return types.NewAgentError(types.ErrorDeployedContentNotRunning, errValidationFailed, nil) } else { // Other HTTP codes are acceptable, for example // if the content doesn't implement GET /, diff --git a/internal/clients/connect/client_connect_test.go b/internal/clients/connect/client_connect_test.go index c27b30fef..adc454697 100644 --- a/internal/clients/connect/client_connect_test.go +++ b/internal/clients/connect/client_connect_test.go @@ -11,11 +11,14 @@ import ( "github.com/mitchellh/mapstructure" "github.com/posit-dev/publisher/internal/accounts" + "github.com/posit-dev/publisher/internal/clients/connect/server_settings" "github.com/posit-dev/publisher/internal/clients/http_client" + "github.com/posit-dev/publisher/internal/config" "github.com/posit-dev/publisher/internal/events" "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/logging/loggingtest" "github.com/posit-dev/publisher/internal/types" + "github.com/posit-dev/publisher/internal/util" "github.com/posit-dev/publisher/internal/util/utiltest" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" @@ -360,7 +363,7 @@ func (s *ConnectClientSuite) TestValidateDeploymentAppFailure() { } contentID := types.ContentID("myContentID") err := client.ValidateDeployment(contentID, logging.New()) - s.ErrorContains(err, "couldn't access the deployed content") + s.ErrorContains(err, "deployed content does not seem to be running. See the logs in Connect for details") } func (s *ConnectClientSuite) TestValidateDeploymentHTTPNonAppErr() { @@ -590,3 +593,106 @@ func (s *ConnectClientSuite) TestContentDetails() { s.ErrorIs(err, expectedErr) httpClient.AssertExpectations(s.T()) } + +func (s *ConnectClientSuite) TestCheckCapabilities() { + lgr := logging.New() + httpClient := &http_client.MockHTTPClient{} + httpClient.On("Get", "/__api__/v1/user", mock.Anything, lgr).Return(nil) + httpClient.On("Get", "/__api__/server_settings", mock.Anything, lgr).Return(nil) + httpClient.On("Get", "/__api__/server_settings/applications", mock.Anything, lgr).Return(nil) + httpClient.On("Get", "/__api__/server_settings/scheduler/python-dash", mock.Anything, lgr).Return(nil) + httpClient.On("Get", "/__api__/v1/server_settings/r", mock.Anything, lgr).Return(nil) + httpClient.On("Get", "/__api__/v1/server_settings/quarto", mock.Anything, lgr).Return(nil) + + // Be sure to mock available python versions + httpClient.On("Get", "/__api__/v1/server_settings/python", mock.AnythingOfType("*server_settings.PyInfo"), lgr).Run(func(args mock.Arguments) { + pySettings := args.Get(1).(*server_settings.PyInfo) + pySettings.Installations = []server_settings.PyInstallation{{Version: "3.4.5"}} + }).Return(nil) + + cfg := config.New() + cfg.Type = "python-dash" + cfg.Entrypoint = "app.py" + cfg.Python = &config.Python{ + Version: "3.4.5", + PackageManager: "pip", + } + + var cwd util.AbsolutePath + bundleTestPath := cwd.Join("testdata", "python-bundle") + + client := &ConnectClient{ + client: httpClient, + } + + err := client.CheckCapabilities(bundleTestPath, cfg, lgr) + s.NoError(err) + httpClient.AssertExpectations(s.T()) +} + +func (s *ConnectClientSuite) TestCheckCapabilities_missingPythonVer() { + lgr := logging.New() + httpClient := &http_client.MockHTTPClient{} + httpClient.On("Get", "/__api__/v1/user", mock.Anything, lgr).Return(nil) + httpClient.On("Get", "/__api__/server_settings", mock.Anything, lgr).Return(nil) + httpClient.On("Get", "/__api__/server_settings/applications", mock.Anything, lgr).Return(nil) + httpClient.On("Get", "/__api__/server_settings/scheduler/python-dash", mock.Anything, lgr).Return(nil) + httpClient.On("Get", "/__api__/v1/server_settings/r", mock.Anything, lgr).Return(nil) + httpClient.On("Get", "/__api__/v1/server_settings/quarto", mock.Anything, lgr).Return(nil) + + // Mock versions, not including 3.4.5 + httpClient.On("Get", "/__api__/v1/server_settings/python", mock.AnythingOfType("*server_settings.PyInfo"), lgr).Run(func(args mock.Arguments) { + pySettings := args.Get(1).(*server_settings.PyInfo) + pySettings.Installations = []server_settings.PyInstallation{{Version: "3.3.0"}} + }).Return(nil) + + cfg := config.New() + cfg.Type = "python-dash" + cfg.Entrypoint = "app.py" + cfg.Python = &config.Python{ + Version: "3.4.5", // Not included in server settings + PackageManager: "pip", + } + + var cwd util.AbsolutePath + bundleTestPath := cwd.Join("testdata", "python-bundle") + + client := &ConnectClient{ + client: httpClient, + } + + err := client.CheckCapabilities(bundleTestPath, cfg, lgr) + s.NotNil(err) + s.Contains(err.Error(), "Python 3.4 is not available on the server.") + httpClient.AssertExpectations(s.T()) +} + +func (s *ConnectClientSuite) TestCheckCapabilities_missingRequirementsFile() { + lgr := logging.New() + httpClient := &http_client.MockHTTPClient{} + + cfg := config.New() + cfg.Type = "python-dash" + cfg.Entrypoint = "app.py" + cfg.Python = &config.Python{ + Version: "3.4.5", + PackageManager: "pip", + PackageFile: "requirements.txt", + } + + var cwd util.AbsolutePath + bundleTestPath := cwd.Join("testdata", "missing-reqs") + + client := &ConnectClient{ + client: httpClient, + } + + err := client.CheckCapabilities(bundleTestPath, cfg, lgr) + s.NotNil(err) + s.Contains(err.Error(), "Missing package file requirements.txt. The file must exist and be included in the deployment.") + + aerr, yes := types.IsAgentError(err) + s.Equal(yes, true) + s.Equal(aerr.Code, types.ErrorRequirementsFileReading) + s.Contains(aerr.Message, "Missing package file requirements.txt. The file must exist and be included in the deployment.") +} diff --git a/internal/clients/connect/testdata/missing-reqs/app.py b/internal/clients/connect/testdata/missing-reqs/app.py new file mode 100644 index 000000000..de7f34a05 --- /dev/null +++ b/internal/clients/connect/testdata/missing-reqs/app.py @@ -0,0 +1,120 @@ +import dash +import dash_core_components as dcc +import dash_html_components as html +from dash.dependencies import Input, Output + +import pandas as pd + +external_stylesheets = ["https://codepen.io/chriddyp/pen/bWLwgP.css"] + +app = dash.Dash(__name__, external_stylesheets=external_stylesheets) + +df = pd.read_csv("https://plotly.github.io/datasets/country_indicators.csv") + +available_indicators = df["Indicator Name"].unique() + +app.layout = html.Div( + [ + html.Div( + [ + html.Div( + [ + dcc.Dropdown( + id="xaxis-column", + options=[ + {"label": i, "value": i} for i in available_indicators + ], + value="Fertility rate, total (births per woman)", + ), + dcc.RadioItems( + id="xaxis-type", + options=[ + {"label": i, "value": i} for i in ["Linear", "Log"] + ], + value="Linear", + labelStyle={"display": "inline-block"}, + ), + ], + style={"width": "48%", "display": "inline-block"}, + ), + html.Div( + [ + dcc.Dropdown( + id="yaxis-column", + options=[ + {"label": i, "value": i} for i in available_indicators + ], + value="Life expectancy at birth, total (years)", + ), + dcc.RadioItems( + id="yaxis-type", + options=[ + {"label": i, "value": i} for i in ["Linear", "Log"] + ], + value="Linear", + labelStyle={"display": "inline-block"}, + ), + ], + style={"width": "48%", "float": "right", "display": "inline-block"}, + ), + ] + ), + dcc.Graph(id="indicator-graphic"), + dcc.Slider( + id="year--slider", + min=df["Year"].min(), + max=df["Year"].max(), + value=df["Year"].max(), + marks={str(year): str(year) for year in df["Year"].unique()}, + step=None, + ), + ] +) + + +@app.callback( + Output("indicator-graphic", "figure"), + [ + Input("xaxis-column", "value"), + Input("yaxis-column", "value"), + Input("xaxis-type", "value"), + Input("yaxis-type", "value"), + Input("year--slider", "value"), + ], +) +def update_graph( + xaxis_column_name, yaxis_column_name, xaxis_type, yaxis_type, year_value +): + dff = df[df["Year"] == year_value] + + return { + "data": [ + dict( + x=dff[dff["Indicator Name"] == xaxis_column_name]["Value"], + y=dff[dff["Indicator Name"] == yaxis_column_name]["Value"], + text=dff[dff["Indicator Name"] == yaxis_column_name]["Country Name"], + mode="markers", + marker={ + "size": 15, + "opacity": 0.5, + "line": {"width": 0.5, "color": "white"}, + }, + ) + ], + "layout": dict( + xaxis={ + "title": xaxis_column_name, + "type": "linear" if xaxis_type == "Linear" else "log", + }, + yaxis={ + "title": yaxis_column_name, + "type": "linear" if yaxis_type == "Linear" else "log", + }, + margin={"l": 40, "b": 40, "t": 10, "r": 0}, + hovermode="closest", + ), + } + + +if __name__ == "__main__": + app.run_server(debug=True) diff --git a/internal/clients/connect/testdata/python-bundle/app.py b/internal/clients/connect/testdata/python-bundle/app.py new file mode 100644 index 000000000..de7f34a05 --- /dev/null +++ b/internal/clients/connect/testdata/python-bundle/app.py @@ -0,0 +1,120 @@ +import dash +import dash_core_components as dcc +import dash_html_components as html +from dash.dependencies import Input, Output + +import pandas as pd + +external_stylesheets = ["https://codepen.io/chriddyp/pen/bWLwgP.css"] + +app = dash.Dash(__name__, external_stylesheets=external_stylesheets) + +df = pd.read_csv("https://plotly.github.io/datasets/country_indicators.csv") + +available_indicators = df["Indicator Name"].unique() + +app.layout = html.Div( + [ + html.Div( + [ + html.Div( + [ + dcc.Dropdown( + id="xaxis-column", + options=[ + {"label": i, "value": i} for i in available_indicators + ], + value="Fertility rate, total (births per woman)", + ), + dcc.RadioItems( + id="xaxis-type", + options=[ + {"label": i, "value": i} for i in ["Linear", "Log"] + ], + value="Linear", + labelStyle={"display": "inline-block"}, + ), + ], + style={"width": "48%", "display": "inline-block"}, + ), + html.Div( + [ + dcc.Dropdown( + id="yaxis-column", + options=[ + {"label": i, "value": i} for i in available_indicators + ], + value="Life expectancy at birth, total (years)", + ), + dcc.RadioItems( + id="yaxis-type", + options=[ + {"label": i, "value": i} for i in ["Linear", "Log"] + ], + value="Linear", + labelStyle={"display": "inline-block"}, + ), + ], + style={"width": "48%", "float": "right", "display": "inline-block"}, + ), + ] + ), + dcc.Graph(id="indicator-graphic"), + dcc.Slider( + id="year--slider", + min=df["Year"].min(), + max=df["Year"].max(), + value=df["Year"].max(), + marks={str(year): str(year) for year in df["Year"].unique()}, + step=None, + ), + ] +) + + +@app.callback( + Output("indicator-graphic", "figure"), + [ + Input("xaxis-column", "value"), + Input("yaxis-column", "value"), + Input("xaxis-type", "value"), + Input("yaxis-type", "value"), + Input("year--slider", "value"), + ], +) +def update_graph( + xaxis_column_name, yaxis_column_name, xaxis_type, yaxis_type, year_value +): + dff = df[df["Year"] == year_value] + + return { + "data": [ + dict( + x=dff[dff["Indicator Name"] == xaxis_column_name]["Value"], + y=dff[dff["Indicator Name"] == yaxis_column_name]["Value"], + text=dff[dff["Indicator Name"] == yaxis_column_name]["Country Name"], + mode="markers", + marker={ + "size": 15, + "opacity": 0.5, + "line": {"width": 0.5, "color": "white"}, + }, + ) + ], + "layout": dict( + xaxis={ + "title": xaxis_column_name, + "type": "linear" if xaxis_type == "Linear" else "log", + }, + yaxis={ + "title": yaxis_column_name, + "type": "linear" if yaxis_type == "Linear" else "log", + }, + margin={"l": 40, "b": 40, "t": 10, "r": 0}, + hovermode="closest", + ), + } + + +if __name__ == "__main__": + app.run_server(debug=True) diff --git a/internal/clients/connect/testdata/python-bundle/requirements.txt b/internal/clients/connect/testdata/python-bundle/requirements.txt new file mode 100644 index 000000000..0b385ffd1 --- /dev/null +++ b/internal/clients/connect/testdata/python-bundle/requirements.txt @@ -0,0 +1,6 @@ +# requirements.txt auto-generated by Posit Publisher +# using /Users/mn/.pyenv/shims/python3 +dash +dash_core_components +dash_html_components +pandas diff --git a/internal/publish/r_package_descriptions.go b/internal/publish/r_package_descriptions.go index afea54b8a..ee195b3cf 100644 --- a/internal/publish/r_package_descriptions.go +++ b/internal/publish/r_package_descriptions.go @@ -3,7 +3,9 @@ package publish import ( + "errors" "fmt" + "os" "github.com/posit-dev/publisher/internal/bundles" "github.com/posit-dev/publisher/internal/events" @@ -19,6 +21,12 @@ type lockfileErrDetails struct { Lockfile string } +const lockfileMissing = `Missing R lockfile %s. +The file must exist and be included in the deployment. +Create the file listing your project dependencies. +Or do an automatic scan with the help of the R Packages section +of the Publisher extension and review the generated file` + func (p *defaultPublisher) getRPackages() (bundles.PackageMap, error) { op := events.PublishGetRPackageDescriptionsOp log := p.log.WithArgs(logging.LogKeyOp, op) @@ -37,6 +45,9 @@ func (p *defaultPublisher) getRPackages() (bundles.PackageMap, error) { if err != nil { agentErr := types.NewAgentError(types.ErrorRenvLockPackagesReading, err, lockfileErrDetails{Lockfile: lockfilePath.String()}) agentErr.Message = fmt.Sprintf("Could not scan R packages from lockfile: %s", lockfileString) + if errors.Is(err, os.ErrNotExist) { + agentErr.Message = fmt.Sprintf(lockfileMissing, lockfileString) + } return nil, agentErr } log.Info("Done collecting R package descriptions") diff --git a/internal/publish/r_package_descriptions_test.go b/internal/publish/r_package_descriptions_test.go index 214d79b94..5cde29c37 100644 --- a/internal/publish/r_package_descriptions_test.go +++ b/internal/publish/r_package_descriptions_test.go @@ -4,6 +4,7 @@ package publish import ( "errors" + "os" "testing" "github.com/posit-dev/publisher/internal/bundles" @@ -135,3 +136,23 @@ func (s *RPackageDescSuite) TestGetRPackages_ScanPackagesError() { s.Contains(err.(*types.AgentError).Message, "custom.lock") s.log.AssertExpectations(s.T()) } + +func (s *RPackageDescSuite) TestGetRPackages_PackageFileNotFound() { + expectedLockfilePath := s.dirPath.Join("custom.lock") + + // With a package file + s.stateStore.Config = &config.Config{ + R: &config.R{ + PackageFile: "custom.lock", + }, + } + + s.packageMapper.On("GetManifestPackages", s.dirPath, expectedLockfilePath).Return(bundles.PackageMap{}, os.ErrNotExist) + + publisher := s.makePublisher() + _, err := publisher.getRPackages() + s.NotNil(err) + s.Contains(err.(*types.AgentError).Message, "Missing R lockfile custom.lock") + s.Contains(err.(*types.AgentError).Message, "custom.lock") + s.log.AssertExpectations(s.T()) +} diff --git a/internal/types/error.go b/internal/types/error.go index 9166df67e..4ddd13db5 100644 --- a/internal/types/error.go +++ b/internal/types/error.go @@ -18,6 +18,8 @@ const ( ErrorCredentialServiceUnavailable ErrorCode = "credentialsServiceUnavailable" ErrorCertificateVerification ErrorCode = "errorCertificateVerification" ErrorRenvLockPackagesReading ErrorCode = "renvlockPackagesReadingError" + ErrorRequirementsFileReading ErrorCode = "requirementsFileReadingError" + ErrorDeployedContentNotRunning ErrorCode = "deployedContentNotRunning" ErrorUnknown ErrorCode = "unknown" ) From f85385913751f10e18e4129326f9c4aee71711a7 Mon Sep 17 00:00:00 2001 From: Marcos Navarro <m@marcosn.com> Date: Wed, 9 Oct 2024 13:59:01 -0600 Subject: [PATCH 2/9] Fix bats deploy test --- test/bats/contract/deploy.bats | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test/bats/contract/deploy.bats b/test/bats/contract/deploy.bats index 01e1c848e..284d19888 100644 --- a/test/bats/contract/deploy.bats +++ b/test/bats/contract/deploy.bats @@ -139,11 +139,10 @@ python_content_types=( run ${EXE} deploy ${FULL_PATH} assert_failure assert_output --partial "\ -can't find the package file (requirements.txt) in the project directory. -Create the file, listing the packages your project depends on. -Or scan your project dependencies using scan button in -the Python Packages section of the UI and review the -generated file." +Missing package file requirements.txt. The file must exist and be included in the deployment. +Create the file listing your project dependencies. +Or do an automatic scan with the help of the Python Packages section +of the Publisher extension and review the generated file" else skip fi From 3dccb977fd8fd0d29c24f32c1713f275910e586f Mon Sep 17 00:00:00 2001 From: Bill Sager <whs@sager.org> Date: Thu, 10 Oct 2024 16:11:03 -0700 Subject: [PATCH 3/9] PR Recommendations - code optimization and show config type rather than app mode description --- extensions/vscode/src/eventErrors.ts | 3 +-- internal/clients/connect/apptypes.go | 26 +++++++++++++++++++ internal/clients/connect/client_connect.go | 8 +++--- .../clients/connect/client_connect_test.go | 2 +- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/extensions/vscode/src/eventErrors.ts b/extensions/vscode/src/eventErrors.ts index a7fb65f51..fb1abc59b 100644 --- a/extensions/vscode/src/eventErrors.ts +++ b/extensions/vscode/src/eventErrors.ts @@ -48,6 +48,5 @@ export const handleEventCodedError = ( return emsg.data.message; } - const unknownErrMsg = emsg.data.error || emsg.data.message; - return `${unknownErrMsg}`; + return emsg.data.error || emsg.data.message; }; diff --git a/internal/clients/connect/apptypes.go b/internal/clients/connect/apptypes.go index f982b871a..7c3164eee 100644 --- a/internal/clients/connect/apptypes.go +++ b/internal/clients/connect/apptypes.go @@ -270,3 +270,29 @@ func AppModeFromType(t config.ContentType) AppMode { } return mode } + +var contentTypeConnectMap = map[AppMode]config.ContentType{ + StaticMode: config.ContentTypeHTML, + StaticJupyterMode: config.ContentTypeJupyterNotebook, + JupyterVoilaMode: config.ContentTypeJupyterVoila, + PythonBokehMode: config.ContentTypePythonBokeh, + PythonDashMode: config.ContentTypePythonDash, + PythonFastAPIMode: config.ContentTypePythonFastAPI, + PythonAPIMode: config.ContentTypePythonFlask, + PythonShinyMode: config.ContentTypePythonShiny, + PythonStreamlitMode: config.ContentTypePythonStreamlit, + ShinyQuartoMode: config.ContentTypeQuartoShiny, + StaticQuartoMode: config.ContentTypeQuarto, + PlumberAPIMode: config.ContentTypeRPlumber, + ShinyMode: config.ContentTypeRShiny, + ShinyRmdMode: config.ContentTypeRMarkdownShiny, + StaticRmdMode: config.ContentTypeRMarkdown, +} + +func ContentTypeFromAppMode(a AppMode) config.ContentType { + contentType, ok := contentTypeConnectMap[a] + if !ok { + contentType = config.ContentTypeUnknown + } + return contentType +} diff --git a/internal/clients/connect/client_connect.go b/internal/clients/connect/client_connect.go index 7bb24d8ba..c5b64de74 100644 --- a/internal/clients/connect/client_connect.go +++ b/internal/clients/connect/client_connect.go @@ -530,10 +530,10 @@ func (c *ConnectClient) ValidateDeploymentTarget(contentID types.ContentID, cfg // Verify content type has not changed log.Info("Verifying content type is the same") - existingAppMode := content.AppMode - configAppMode := AppModeFromType(cfg.Type) - if configAppMode != existingAppMode { - msg := fmt.Sprintf("Content was previously deployed as '%s' but your configuration is set to '%s'. A new deployment must be needed.", existingAppMode.Description(), configAppMode.Description()) + existingType := ContentTypeFromAppMode(content.AppMode) + configType := cfg.Type + if existingType != configType { + msg := fmt.Sprintf("Content was previously deployed as '%s' but your configuration is set to '%s'.", existingType, configType) return types.NewAgentError(events.AppModeNotModifiableCode, errors.New(msg), nil) } diff --git a/internal/clients/connect/client_connect_test.go b/internal/clients/connect/client_connect_test.go index e779fbf04..0d8cfbf23 100644 --- a/internal/clients/connect/client_connect_test.go +++ b/internal/clients/connect/client_connect_test.go @@ -775,7 +775,7 @@ func (s *ConnectClientSuite) TestValidateDeploymentTargetAppModeNotModifiableCod } expectedErr := types.NewAgentError( events.DeploymentFailedCode, - errors.New("Content was previously deployed as 'R Shiny application' but your configuration is set to 'Dash application'. A new deployment must be needed."), + errors.New("Content was previously deployed as 'r-shiny' but your configuration is set to 'python-dash'."), nil) err := client.ValidateDeploymentTarget("e8922765-4880-43cd-abc0-d59fe59b8b4b", s.cfg, lgr) aerr, ok := types.IsAgentError(err) From 564c3fa67ac0f3a18ecf0d85c3e8fb4a2ffbc342 Mon Sep 17 00:00:00 2001 From: Marcos Navarro <m@marcosn.com> Date: Fri, 11 Oct 2024 12:39:39 -0600 Subject: [PATCH 4/9] PR feedback no messages. Address requirements txt file error when it is not included in configuration files list --- internal/clients/connect/capabilities.go | 29 ++++++++----- .../clients/connect/client_connect_test.go | 43 +++++++++++++++++-- internal/publish/r_package_descriptions.go | 6 +-- .../publish/r_package_descriptions_test.go | 3 +- 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/internal/clients/connect/capabilities.go b/internal/clients/connect/capabilities.go index c6144d290..c18c9330b 100644 --- a/internal/clients/connect/capabilities.go +++ b/internal/clients/connect/capabilities.go @@ -25,24 +25,33 @@ type allSettings struct { quarto server_settings.QuartoInfo } -const requirementsFileMissing = ` -Missing package file %s. The file must exist and be included in the deployment. -Create the file listing your project dependencies. -Or do an automatic scan with the help of the Python Packages section -of the Publisher extension and review the generated file` +const requirementsFileMissing = `Missing dependency file %s. This file must be included in the deployment.` type requirementsErrDetails struct { RequirementsFile string `json:"requirements_file"` } -func checkRequirementsFile(base util.AbsolutePath, requirementsFilename string) error { - packageFile := base.Join(requirementsFilename) +func checkRequirementsFile(base util.AbsolutePath, cfg *config.Config) error { + packageFile := base.Join(cfg.Python.PackageFile) exists, err := packageFile.Exists() if err != nil { return err } - if !exists { - missingErr := fmt.Errorf(requirementsFileMissing, requirementsFilename) + + // Confirm the package file (requirements.txt) + // is included in the configuration files list. + requirementsIsIncluded := false + for _, file := range cfg.Files { + // File paths like /requirements.txt, /some/path/requirements.txt + // should count as including the package file. + if strings.HasSuffix(file, cfg.Python.PackageFile) { + requirementsIsIncluded = true + break + } + } + + if !exists || !requirementsIsIncluded { + missingErr := fmt.Errorf(requirementsFileMissing, cfg.Python.PackageFile) return types.NewAgentError(types.ErrorRequirementsFileReading, missingErr, requirementsErrDetails{RequirementsFile: packageFile.String()}) } return nil @@ -50,7 +59,7 @@ func checkRequirementsFile(base util.AbsolutePath, requirementsFilename string) func (c *ConnectClient) CheckCapabilities(base util.AbsolutePath, cfg *config.Config, log logging.Logger) error { if cfg.Python != nil { - err := checkRequirementsFile(base, cfg.Python.PackageFile) + err := checkRequirementsFile(base, cfg) if err != nil { return err } diff --git a/internal/clients/connect/client_connect_test.go b/internal/clients/connect/client_connect_test.go index adc454697..132e20b90 100644 --- a/internal/clients/connect/client_connect_test.go +++ b/internal/clients/connect/client_connect_test.go @@ -613,8 +613,10 @@ func (s *ConnectClientSuite) TestCheckCapabilities() { cfg := config.New() cfg.Type = "python-dash" cfg.Entrypoint = "app.py" + cfg.Files = []string{"/app.py", "/requirements.txt"} cfg.Python = &config.Python{ Version: "3.4.5", + PackageFile: "requirements.txt", PackageManager: "pip", } @@ -649,8 +651,10 @@ func (s *ConnectClientSuite) TestCheckCapabilities_missingPythonVer() { cfg := config.New() cfg.Type = "python-dash" cfg.Entrypoint = "app.py" + cfg.Files = []string{"/app.py", "/requirements.txt"} cfg.Python = &config.Python{ Version: "3.4.5", // Not included in server settings + PackageFile: "requirements.txt", PackageManager: "pip", } @@ -667,13 +671,14 @@ func (s *ConnectClientSuite) TestCheckCapabilities_missingPythonVer() { httpClient.AssertExpectations(s.T()) } -func (s *ConnectClientSuite) TestCheckCapabilities_missingRequirementsFile() { +func (s *ConnectClientSuite) TestCheckCapabilities_requirementsFileDoesNotExist() { lgr := logging.New() httpClient := &http_client.MockHTTPClient{} cfg := config.New() cfg.Type = "python-dash" cfg.Entrypoint = "app.py" + cfg.Files = []string{"/app.py", "/requirements.txt"} cfg.Python = &config.Python{ Version: "3.4.5", PackageManager: "pip", @@ -689,10 +694,42 @@ func (s *ConnectClientSuite) TestCheckCapabilities_missingRequirementsFile() { err := client.CheckCapabilities(bundleTestPath, cfg, lgr) s.NotNil(err) - s.Contains(err.Error(), "Missing package file requirements.txt. The file must exist and be included in the deployment.") + s.Contains(err.Error(), "Missing dependency file requirements.txt. This file must be included in the deployment.") + + aerr, yes := types.IsAgentError(err) + s.Equal(yes, true) + s.Equal(aerr.Code, types.ErrorRequirementsFileReading) + s.Contains(aerr.Message, "Missing dependency file requirements.txt. This file must be included in the deployment.") +} + +func (s *ConnectClientSuite) TestCheckCapabilities_requirementsFileNotInConfig() { + lgr := logging.New() + httpClient := &http_client.MockHTTPClient{} + + cfg := config.New() + cfg.Type = "python-dash" + cfg.Entrypoint = "app.py" + cfg.Files = []string{"/app.py"} // requirements file not included in config files list + cfg.Python = &config.Python{ + Version: "3.4.5", + PackageManager: "pip", + PackageFile: "requirements.txt", + } + + var cwd util.AbsolutePath + // This bundle path does have requirements.txt file butis not included in configuration + bundleTestPath := cwd.Join("testdata", "python-bundle") + + client := &ConnectClient{ + client: httpClient, + } + + err := client.CheckCapabilities(bundleTestPath, cfg, lgr) + s.NotNil(err) + s.Contains(err.Error(), "Missing dependency file requirements.txt. This file must be included in the deployment.") aerr, yes := types.IsAgentError(err) s.Equal(yes, true) s.Equal(aerr.Code, types.ErrorRequirementsFileReading) - s.Contains(aerr.Message, "Missing package file requirements.txt. The file must exist and be included in the deployment.") + s.Contains(aerr.Message, "Missing dependency file requirements.txt. This file must be included in the deployment.") } diff --git a/internal/publish/r_package_descriptions.go b/internal/publish/r_package_descriptions.go index ee195b3cf..5515bf61e 100644 --- a/internal/publish/r_package_descriptions.go +++ b/internal/publish/r_package_descriptions.go @@ -21,11 +21,7 @@ type lockfileErrDetails struct { Lockfile string } -const lockfileMissing = `Missing R lockfile %s. -The file must exist and be included in the deployment. -Create the file listing your project dependencies. -Or do an automatic scan with the help of the R Packages section -of the Publisher extension and review the generated file` +const lockfileMissing = `Missing dependency lockfile %s. This file must be included in the deployment.` func (p *defaultPublisher) getRPackages() (bundles.PackageMap, error) { op := events.PublishGetRPackageDescriptionsOp diff --git a/internal/publish/r_package_descriptions_test.go b/internal/publish/r_package_descriptions_test.go index 5cde29c37..20dd7421b 100644 --- a/internal/publish/r_package_descriptions_test.go +++ b/internal/publish/r_package_descriptions_test.go @@ -152,7 +152,6 @@ func (s *RPackageDescSuite) TestGetRPackages_PackageFileNotFound() { publisher := s.makePublisher() _, err := publisher.getRPackages() s.NotNil(err) - s.Contains(err.(*types.AgentError).Message, "Missing R lockfile custom.lock") - s.Contains(err.(*types.AgentError).Message, "custom.lock") + s.Contains(err.(*types.AgentError).Message, "Missing dependency lockfile custom.lock") s.log.AssertExpectations(s.T()) } From 4d06aef8b3d3876b73e6993fa014fde2ac49fbcd Mon Sep 17 00:00:00 2001 From: Marcos Navarro <m@marcosn.com> Date: Fri, 11 Oct 2024 13:21:42 -0600 Subject: [PATCH 5/9] Fix lint on err message --- internal/clients/connect/capabilities.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/clients/connect/capabilities.go b/internal/clients/connect/capabilities.go index 28a6d539d..edaa171a0 100644 --- a/internal/clients/connect/capabilities.go +++ b/internal/clients/connect/capabilities.go @@ -51,8 +51,10 @@ func checkRequirementsFile(base util.AbsolutePath, cfg *config.Config) error { } if !exists || !requirementsIsIncluded { - missingErr := fmt.Errorf(requirementsFileMissing, cfg.Python.PackageFile) - return types.NewAgentError(types.ErrorRequirementsFileReading, missingErr, requirementsErrDetails{RequirementsFile: packageFile.String()}) + missingErr := fmt.Errorf("missing dependency file %s", cfg.Python.PackageFile) + aerr := types.NewAgentError(types.ErrorRequirementsFileReading, missingErr, requirementsErrDetails{RequirementsFile: packageFile.String()}) + aerr.Message = fmt.Sprintf(requirementsFileMissing, cfg.Python.PackageFile) + return aerr } return nil } From 7a4f2f392b9eea2bbb6ea9ff2d896e6013228e99 Mon Sep 17 00:00:00 2001 From: Marcos Navarro <m@marcosn.com> Date: Fri, 11 Oct 2024 13:34:18 -0600 Subject: [PATCH 6/9] Fix deploy e2e test --- test/bats/contract/deploy.bats | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/bats/contract/deploy.bats b/test/bats/contract/deploy.bats index 284d19888..fccd0d194 100644 --- a/test/bats/contract/deploy.bats +++ b/test/bats/contract/deploy.bats @@ -139,10 +139,7 @@ python_content_types=( run ${EXE} deploy ${FULL_PATH} assert_failure assert_output --partial "\ -Missing package file requirements.txt. The file must exist and be included in the deployment. -Create the file listing your project dependencies. -Or do an automatic scan with the help of the Python Packages section -of the Publisher extension and review the generated file" +Missing dependency file requirements.txt. This file must be included in the deployment." else skip fi From c67ffdbc271dc88be16c1937c3f2b9a40b3fb716 Mon Sep 17 00:00:00 2001 From: Marcos Navarro <m@marcosn.com> Date: Thu, 10 Oct 2024 15:21:40 -0600 Subject: [PATCH 7/9] Presentational adjustments to messaging when deployed content does not run --- extensions/vscode/src/api/types/error.test.ts | 96 +++++++++++++++++++ extensions/vscode/src/api/types/error.ts | 21 +++- .../src/components/EvenEasierDeploy.vue | 29 +++++- 3 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 extensions/vscode/src/api/types/error.test.ts diff --git a/extensions/vscode/src/api/types/error.test.ts b/extensions/vscode/src/api/types/error.test.ts new file mode 100644 index 000000000..3d29ad91c --- /dev/null +++ b/extensions/vscode/src/api/types/error.test.ts @@ -0,0 +1,96 @@ +// Copyright (C) 2024 by Posit Software, PBC. + +import { describe, expect, test } from "vitest"; +import { + isAgentErrorTypeUnknown, + isAgentErrorInvalidTOML, + isAgentErrorDeployedContentNotRunning, +} from "./error"; + +describe("Agent Errors", () => { + test("isAgentErrorTypeUnknown", () => { + let result = isAgentErrorTypeUnknown({ + code: "unknown", + msg: "", + operation: "", + data: {}, + }); + expect(result).toBe(true); + + result = isAgentErrorTypeUnknown({ + code: "resourceNotFound", + msg: "", + operation: "", + data: {}, + }); + expect(result).toBe(true); + + result = isAgentErrorTypeUnknown({ + code: "unknownTOMLKey", + msg: "", + operation: "", + data: {}, + }); + expect(result).toBe(false); + + result = isAgentErrorTypeUnknown({ + code: "invalidTOML", + msg: "", + operation: "", + data: {}, + }); + expect(result).toBe(false); + + result = isAgentErrorTypeUnknown({ + code: "deployedContentNotRunning", + msg: "", + operation: "", + data: {}, + }); + expect(result).toBe(false); + }); + + test("isAgentErrorInvalidTOML", () => { + let result = isAgentErrorInvalidTOML({ + code: "unknown", + msg: "", + operation: "", + data: {}, + }); + expect(result).toBe(false); + + result = isAgentErrorInvalidTOML({ + code: "unknownTOMLKey", + msg: "", + operation: "", + data: {}, + }); + expect(result).toBe(true); + + result = isAgentErrorInvalidTOML({ + code: "invalidTOML", + msg: "", + operation: "", + data: {}, + }); + expect(result).toBe(true); + }); + + test("isAgentErrorDeployedContentNotRunning", () => { + let result = isAgentErrorDeployedContentNotRunning({ + code: "unknown", + msg: "", + operation: "", + data: {}, + }); + expect(result).toBe(false); + + result = isAgentErrorDeployedContentNotRunning({ + code: "deployedContentNotRunning", + msg: "", + operation: "", + data: {}, + }); + expect(result).toBe(true); + }); +}); diff --git a/extensions/vscode/src/api/types/error.ts b/extensions/vscode/src/api/types/error.ts index 32e2266c7..9e992d3c0 100644 --- a/extensions/vscode/src/api/types/error.ts +++ b/extensions/vscode/src/api/types/error.ts @@ -1,12 +1,17 @@ // Copyright (C) 2023 by Posit Software, PBC. +import { ErrorCode } from "../../utils/errorTypes"; + type AgentErrorBase = { - code: string; + code: ErrorCode; msg: string; operation: string; }; -export type AgentError = AgentErrorTypeUnknown | AgentErrorInvalidTOML; +export type AgentError = + | AgentErrorTypeUnknown + | AgentErrorInvalidTOML + | AgentErrorContentNotRunning; export type AgentErrorTypeUnknown = AgentErrorBase & { data: { @@ -19,7 +24,9 @@ export type AgentErrorTypeUnknown = AgentErrorBase & { export const isAgentErrorTypeUnknown = ( e: AgentError, ): e is AgentErrorTypeUnknown => { - return !isAgentErrorInvalidTOML(e); + return ( + !isAgentErrorInvalidTOML(e) && !isAgentErrorDeployedContentNotRunning(e) + ); }; export type AgentErrorInvalidTOML = AgentErrorBase & { @@ -36,3 +43,11 @@ export const isAgentErrorInvalidTOML = ( ): e is AgentErrorInvalidTOML => { return e.code === "invalidTOML" || e.code === "unknownTOMLKey"; }; + +export type AgentErrorContentNotRunning = AgentErrorBase; + +export const isAgentErrorDeployedContentNotRunning = ( + e: AgentError, +): e is AgentErrorContentNotRunning => { + return e.code === "deployedContentNotRunning"; +}; diff --git a/extensions/vscode/webviews/homeView/src/components/EvenEasierDeploy.vue b/extensions/vscode/webviews/homeView/src/components/EvenEasierDeploy.vue index 08d76933d..6aa432dc3 100644 --- a/extensions/vscode/webviews/homeView/src/components/EvenEasierDeploy.vue +++ b/extensions/vscode/webviews/homeView/src/components/EvenEasierDeploy.vue @@ -174,7 +174,7 @@ v-if="home.selectedContentRecord.deploymentError" class="last-deployment-details last-deployment-error" > - <span class="codicon codicon-error error-icon"></span> + <span class="codicon codicon-alert"></span> <TextStringWithAnchor :message="home.selectedContentRecord?.deploymentError?.msg" :splitOptions="ErrorMessageSplitOptions" @@ -191,7 +191,7 @@ @click="viewContent" class="w-full" > - View Content + {{ deployedContentButtonLabel }} </vscode-button> </div> </div> @@ -235,6 +235,7 @@ import TextStringWithAnchor from "./TextStringWithAnchor.vue"; import { AgentError, isAgentErrorInvalidTOML, + isAgentErrorDeployedContentNotRunning, } from "../../../../src/api/types/error"; const home = useHomeStore(); @@ -438,6 +439,20 @@ const getActiveConfigTOMLErrorDetails = computed(() => { return ""; }); +const isDeployedContentOnError = computed((): boolean => { + const deploymentError = home.selectedContentRecord?.deploymentError; + return Boolean( + deploymentError && isAgentErrorDeployedContentNotRunning(deploymentError), + ); +}); + +const deployedContentButtonLabel = computed((): string => { + if (isDeployedContentOnError.value) { + return "View Deployment Logs"; + } + return "View Content"; +}); + const onEditConfigurationWithTOMLError = () => { const agentError = getActiveConfigError.value; if (agentError && isAgentErrorInvalidTOML(agentError)) { @@ -492,8 +507,14 @@ const onAssociateDeployment = () => { }; const viewContent = () => { - if (home.selectedContentRecord?.dashboardUrl) { - navigateToUrl(home.selectedContentRecord.dashboardUrl); + const record = home.selectedContentRecord; + if (!record) { + return; + } + if (isDeployedContentOnError.value && !isPreContentRecord(record)) { + navigateToUrl(record.logsUrl); + } else if (record.dashboardUrl) { + navigateToUrl(record.dashboardUrl); } }; </script> From a7429cb6124bc786dfabd1d6faa8a3cac7c9b8a7 Mon Sep 17 00:00:00 2001 From: Marcos Navarro <m@marcosn.com> Date: Fri, 11 Oct 2024 14:36:51 -0600 Subject: [PATCH 8/9] AgentError messages punctuation helper --- internal/clients/connect/capabilities.go | 5 +- internal/clients/connect/client_connect.go | 4 +- .../clients/connect/client_connect_test.go | 2 +- internal/services/api/get_deployment_test.go | 2 +- .../api/post_test_credentials_test.go | 2 +- internal/types/error.go | 18 +++++++- internal/types/error_test.go | 46 ++++++++++++++++++- 7 files changed, 68 insertions(+), 11 deletions(-) diff --git a/internal/clients/connect/capabilities.go b/internal/clients/connect/capabilities.go index edaa171a0..6384640ef 100644 --- a/internal/clients/connect/capabilities.go +++ b/internal/clients/connect/capabilities.go @@ -25,7 +25,7 @@ type allSettings struct { quarto server_settings.QuartoInfo } -const requirementsFileMissing = `Missing dependency file %s. This file must be included in the deployment.` +const requirementsFileMissing = `missing dependency file %s. This file must be included in the deployment` type requirementsErrDetails struct { RequirementsFile string `json:"requirements_file"` @@ -51,9 +51,8 @@ func checkRequirementsFile(base util.AbsolutePath, cfg *config.Config) error { } if !exists || !requirementsIsIncluded { - missingErr := fmt.Errorf("missing dependency file %s", cfg.Python.PackageFile) + missingErr := fmt.Errorf(requirementsFileMissing, cfg.Python.PackageFile) aerr := types.NewAgentError(types.ErrorRequirementsFileReading, missingErr, requirementsErrDetails{RequirementsFile: packageFile.String()}) - aerr.Message = fmt.Sprintf(requirementsFileMissing, cfg.Python.PackageFile) return aerr } return nil diff --git a/internal/clients/connect/client_connect.go b/internal/clients/connect/client_connect.go index db079bba1..1bfe629ab 100644 --- a/internal/clients/connect/client_connect.go +++ b/internal/clients/connect/client_connect.go @@ -492,9 +492,9 @@ func (c *ConnectClient) preflightAgentError(agenterr *types.AgentError, contentI agenterr.Code = events.DeploymentFailedCode if _, isNotFound := http_client.IsHTTPAgentErrorStatusOf(agenterr, http.StatusNotFound); isNotFound { - agenterr.Message = fmt.Sprintf("Cannot deploy content: ID %s - Content cannot be found", contentID) + agenterr.Message = fmt.Sprintf("Cannot deploy content: ID %s - Content cannot be found.", contentID) } else if _, isForbidden := http_client.IsHTTPAgentErrorStatusOf(agenterr, http.StatusForbidden); isForbidden { - agenterr.Message = fmt.Sprintf("Cannot deploy content: ID %s - You may need to request collaborator permissions or verify the credentials in use", contentID) + agenterr.Message = fmt.Sprintf("Cannot deploy content: ID %s - You may need to request collaborator permissions or verify the credentials in use.", contentID) } else { agenterr.Message = fmt.Sprintf("Cannot deploy content: ID %s - Unknown error: %s", contentID, agenterr.Error()) } diff --git a/internal/clients/connect/client_connect_test.go b/internal/clients/connect/client_connect_test.go index 4cf743c93..1fe13e4aa 100644 --- a/internal/clients/connect/client_connect_test.go +++ b/internal/clients/connect/client_connect_test.go @@ -668,7 +668,7 @@ func (s *ConnectClientSuite) TestValidateDeploymentTargetNotFoundFailure() { } expectedErr := types.NewAgentError( events.DeploymentFailedCode, - errors.New("Cannot deploy content: ID e8922765-4880-43cd-abc0-d59fe59b8b4b - Content cannot be found"), + errors.New("cannot deploy content: ID e8922765-4880-43cd-abc0-d59fe59b8b4b - Content cannot be found"), nil) err := client.ValidateDeploymentTarget("e8922765-4880-43cd-abc0-d59fe59b8b4b", s.cfg, lgr) aerr, ok := types.IsAgentError(err) diff --git a/internal/services/api/get_deployment_test.go b/internal/services/api/get_deployment_test.go index 22056a27f..de7ccdb1e 100644 --- a/internal/services/api/get_deployment_test.go +++ b/internal/services/api/get_deployment_test.go @@ -138,7 +138,7 @@ func (s *GetDeploymentSuite) TestGetPreDeployment() { dec.DisallowUnknownFields() s.NoError(dec.Decode(&res)) s.NotNil(res.Error) - s.Equal("test error", res.Error.Message) + s.Equal("Test error.", res.Error.Message) s.Equal(deploymentStateNew, res.State) s.Equal("myTargetName", res.Name) s.Equal(".", res.ProjectDir) diff --git a/internal/services/api/post_test_credentials_test.go b/internal/services/api/post_test_credentials_test.go index 6eee08636..72a6d254c 100644 --- a/internal/services/api/post_test_credentials_test.go +++ b/internal/services/api/post_test_credentials_test.go @@ -124,5 +124,5 @@ func (s *PostTestCredentialsHandlerSuite) TestPostTestCredentialsHandlerFuncBadA s.NoError(err) s.Nil(response.User) s.NotNil(response.Error) - s.Equal("test error from TestAuthentication", response.Error.Message) + s.Equal("Test error from TestAuthentication.", response.Error.Message) } diff --git a/internal/types/error.go b/internal/types/error.go index 4ddd13db5..c8eaa4ff9 100644 --- a/internal/types/error.go +++ b/internal/types/error.go @@ -3,6 +3,9 @@ package types // Copyright (C) 2023 by Posit Software, PBC. import ( + "slices" + "strings" + "github.com/mitchellh/mapstructure" ) @@ -39,6 +42,19 @@ type AgentError struct { Data ErrorData `json:"data" toml:"data,omitempty"` } +// Normalize punctuation on messages derived from errors +func normalizeAgentErrorMsg(errMsg string) string { + spChars := []string{"?", "!", ")", "."} + msg := strings.ToUpper(errMsg[:1]) + errMsg[1:] + + msgLastChar := msg[len(msg)-1:] + if slices.Contains(spChars, msgLastChar) { + return msg + } + + return msg + "." +} + func AsAgentError(e error) *AgentError { if e == nil { return nil @@ -55,7 +71,7 @@ func NewAgentError(code ErrorCode, err error, details any) *AgentError { msg := "" if err != nil { - msg = err.Error() + msg = normalizeAgentErrorMsg(err.Error()) } if details != nil { detailMap := make(ErrorData) diff --git a/internal/types/error_test.go b/internal/types/error_test.go index 0b4de2011..9dbf55f3f 100644 --- a/internal/types/error_test.go +++ b/internal/types/error_test.go @@ -61,7 +61,7 @@ func (s *ErrorSuite) TestNewAgentError() { originalError := errors.New("shattered glass!") aerr := NewAgentError(ErrorInvalidTOML, originalError, nil) s.Equal(aerr, &AgentError{ - Message: "shattered glass!", + Message: "Shattered glass!", Code: ErrorInvalidTOML, Err: originalError, Data: make(ErrorData), @@ -78,9 +78,51 @@ func (s *ErrorSuite) TestIsAgentError() { aerr, isIt = IsAgentError(aTrueAgentError) s.Equal(isIt, true) s.Equal(aerr, &AgentError{ - Message: "shattered glass!", + Message: "Shattered glass!", Code: ErrorInvalidTOML, Err: originalError, Data: make(ErrorData), }) } + +func (s *ErrorSuite) TestNewAgentError_MessagePunctuation() { + // Sentence case and period ending + originalError := errors.New("oh sorry, my mistake") + aerr := NewAgentError(ErrorResourceNotFound, originalError, nil) + s.Equal(aerr, &AgentError{ + Message: "Oh sorry, my mistake.", + Code: ErrorResourceNotFound, + Err: originalError, + Data: make(ErrorData), + }) + + // No need for period ending when shouting "!" + originalError = errors.New("i can't believe is October already!") + aerr = NewAgentError(ErrorResourceNotFound, originalError, nil) + s.Equal(aerr, &AgentError{ + Message: "I can't believe is October already!", + Code: ErrorResourceNotFound, + Err: originalError, + Data: make(ErrorData), + }) + + // No need for period ending when asking "?" + originalError = errors.New("can you believe 2024 is almos over?") + aerr = NewAgentError(ErrorResourceNotFound, originalError, nil) + s.Equal(aerr, &AgentError{ + Message: "Can you believe 2024 is almos over?", + Code: ErrorResourceNotFound, + Err: originalError, + Data: make(ErrorData), + }) + + // No need for period ending when ending parentheses ")" + originalError = errors.New("wrong credentials (is this one of those bad typing days?)") + aerr = NewAgentError(ErrorResourceNotFound, originalError, nil) + s.Equal(aerr, &AgentError{ + Message: "Wrong credentials (is this one of those bad typing days?)", + Code: ErrorResourceNotFound, + Err: originalError, + Data: make(ErrorData), + }) +} From c91cdc5be3b9c0cd848454b21a240ff114839bb1 Mon Sep 17 00:00:00 2001 From: Marcos Navarro <m@marcosn.com> Date: Fri, 11 Oct 2024 15:16:49 -0600 Subject: [PATCH 9/9] Fix punctuation message test --- internal/publish/publish_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/publish/publish_test.go b/internal/publish/publish_test.go index 4dd368ddc..ee0d248ee 100644 --- a/internal/publish/publish_test.go +++ b/internal/publish/publish_test.go @@ -207,7 +207,7 @@ func (s *PublishSuite) TestPublishWithClientRedeployErrors() { s.publishWithClient(target, &publishErrsMock{capErr: checksErr}, checksErr) s.Equal( checksErr.Message, - "failed", + "Failed.", ) } @@ -418,7 +418,7 @@ func (s *PublishSuite) TestEmitErrorEventsNoTarget() { s.Len(emitter.Events, 2) for _, event := range emitter.Events { s.True(strings.HasSuffix(event.Type, "/failure")) - s.Equal(expectedErr.Error(), event.Data["message"]) + s.Equal("Test error.", event.Data["message"]) } s.Equal("publish/failure", emitter.Events[1].Type) } @@ -454,7 +454,7 @@ func (s *PublishSuite) TestEmitErrorEventsWithTarget() { s.Len(emitter.Events, 2) for _, event := range emitter.Events { s.True(strings.HasSuffix(event.Type, "/failure")) - s.Equal(expectedErr.Error(), event.Data["message"]) + s.Equal("Test error.", event.Data["message"]) s.Equal(util.GetDashboardURL("connect.example.com", targetID), event.Data["dashboardUrl"]) s.Equal(util.GetDirectURL("connect.example.com", targetID), event.Data["url"]) s.Equal(util.GetLogsURL("connect.example.com", targetID), event.Data["logsUrl"])