Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Downloading plugins uses an engine event #16094

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
changes:
- type: feat
scope: cli/display
description: Plugin download progress is integrated with terminal display
2 changes: 2 additions & 0 deletions pkg/backend/display/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ func RenderDiffEvent(event engine.Event, seen map[resource.URN]engine.StepEventM
return ""
case engine.PolicyLoadEvent:
return ""
case engine.DownloadProgressEvent:
return ""

// Currently, prelude, summary, and stdout events are printed the same for both the diff and
// progress displays.
Expand Down
126 changes: 126 additions & 0 deletions pkg/backend/display/downloadProgress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright 2016-2024, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package display

import (
"fmt"
"strings"

"github.com/pulumi/pulumi/pkg/v3/engine"
"github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors"
)

const (
KiB = 1024
MiB = 1024 * 1024
GiB = 1024 * 1024 * 1024
)

func formatBytes(value int64) string {
if value >= GiB {
return fmt.Sprintf("%.02f GiB", float64(value)/GiB)
} else if value >= MiB {
return fmt.Sprintf("%.02f MiB", float64(value)/MiB)
} else if value >= KiB {
return fmt.Sprintf("%.02f KiB", float64(value)/KiB)
}
return fmt.Sprintf("%d B", value)
}

// Render the progress bar. This uses only ascii characters
func renderBarASCII(received, total int64, width int) string {
innerWidth := width - 2
if received <= 0 {
return "[" + strings.Repeat("_", innerWidth) + "]"
} else if received >= total {
return "[" + strings.Repeat("-", innerWidth) + "]"
}

offset := int(received * int64(innerWidth) / total)
output := make([]byte, innerWidth)
for i := 0; i != innerWidth; i++ {
if i == offset {
output[i] = '>'
} else if i < offset {
output[i] = '-'
} else {
output[i] = '_'
}
}
return "[" + string(output) + "]"
}

// Render the progress bar. This uses the unicode block characters, see
// https://en.wikipedia.org/wiki/Block_Elements, to draw the progress.
// These characters are well supported in terminal fonts and used extensively by
// libraries like ncurses
func renderBar(received, total int64, width int) string {
innerWidth := width - 2
if received <= 0 {
return "[" + strings.Repeat(" ", innerWidth) + "]"
} else if received >= total {
return "[" + strings.Repeat("\u2588", innerWidth) + "]"
}

offset := int(received * int64(innerWidth) / total)
subchar := int(received*int64(innerWidth)*8/total) % 8
output := make([]rune, innerWidth)
for i := 0; i != innerWidth; i++ {
if i == offset {
if subchar == 0 {
output[i] = ' '
} else {
output[i] = rune(0x2590 - subchar)
}
} else if i < offset {
output[i] = 0x2588
} else {
output[i] = ' '
}
}
return "[" + string(output) + "]"
}

func renderDownloadProgress(payload engine.DownloadProgressEventPayload, width int, ascii bool) string {
total := formatBytes(payload.Total)
received := formatBytes(payload.Received)
sizeWidth := len(total)*2 + 1
msgLength := colors.MeasureColorizedString(payload.Msg)

if msgLength+sizeWidth+10 <= width {
// room for the message, the size, and a progress bar
progressWidth := width - (msgLength + sizeWidth + 2)
if ascii {
return fmt.Sprintf("%s %s %*s/%s", payload.Msg,
renderBarASCII(payload.Received, payload.Total, progressWidth),
len(total), received, total)
}
return fmt.Sprintf("%s %s %*s/%s", payload.Msg,
renderBar(payload.Received, payload.Total, progressWidth),
len(total), received, total)
} else if msgLength+10 <= width {
// room for the message, and a progress bar
progressWidth := width - (msgLength + 1)
if ascii {
return fmt.Sprintf("%s %s", payload.Msg, renderBarASCII(payload.Received, payload.Total, progressWidth))
}
return fmt.Sprintf("%s %s", payload.Msg, renderBar(payload.Received, payload.Total, progressWidth))
} else if msgLength <= width {
// just the message
return payload.Msg
}
// truncate the message
return colors.TrimColorizedString(payload.Msg, width)
}
200 changes: 200 additions & 0 deletions pkg/backend/display/downloadProgress_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Copyright 2016-2024, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package display

import (
"testing"

"github.com/pulumi/pulumi/pkg/v3/engine"
"github.com/stretchr/testify/assert"
)

func TestDownloadProgress(t *testing.T) {
t.Parallel()

t.Run("Size formatting", func(t *testing.T) {
t.Parallel()

cases := []struct {
value int64
expected string
}{
{value: 0, expected: "0 B"},
{value: 1, expected: "1 B"},
{value: 1023, expected: "1023 B"},
{value: 1024, expected: "1.00 KiB"},
{value: 1024 * 1024, expected: "1.00 MiB"},
{value: 1024 * 1024 * 1024, expected: "1.00 GiB"},
{value: 808866, expected: "789.91 KiB"},
{value: 164344, expected: "160.49 KiB"},
{value: 174193, expected: "170.11 KiB"},
{value: 696525823, expected: "664.26 MiB"},
{value: 443626481, expected: "423.08 MiB"},
{value: 186137911, expected: "177.51 MiB"},
}
for _, testcase := range cases {
assert.Equalf(t, testcase.expected, formatBytes(testcase.value),
"%d should render as `%s`", testcase.value, testcase.expected)
}
})

t.Run("bar formatting", func(t *testing.T) {
t.Parallel()

cases := []struct {
received, total int64
width int
expected string
}{
{received: 0, total: 100, width: 20, expected: "[ ]"},
{received: 50, total: 100, width: 20, expected: "[█████████ ]"},
{received: 100, total: 100, width: 20, expected: "[██████████████████]"},
{received: -1, total: 100, width: 20, expected: "[ ]"},
{received: 101, total: 100, width: 20, expected: "[██████████████████]"},
{received: 40, total: 80, width: 12, expected: "[█████ ]"},
{received: 41, total: 80, width: 12, expected: "[█████▏ ]"},
{received: 42, total: 80, width: 12, expected: "[█████▎ ]"},
{received: 43, total: 80, width: 12, expected: "[█████▍ ]"},
{received: 44, total: 80, width: 12, expected: "[█████▌ ]"},
{received: 45, total: 80, width: 12, expected: "[█████▋ ]"},
{received: 46, total: 80, width: 12, expected: "[█████▊ ]"},
{received: 47, total: 80, width: 12, expected: "[█████▉ ]"},
{received: 48, total: 80, width: 12, expected: "[██████ ]"},
}

for _, testcase := range cases {
assert.Equalf(t, testcase.expected, renderBar(testcase.received, testcase.total, testcase.width),
"r: %d, t: %d, w:%d", testcase.received, testcase.total, testcase.width)
}
})

t.Run("ASCII bar formatting", func(t *testing.T) {
t.Parallel()

cases := []struct {
received, total int64
width int
expected string
}{
{received: 0, total: 100, width: 20, expected: "[__________________]"},
{received: 50, total: 100, width: 20, expected: "[--------->________]"},
{received: 100, total: 100, width: 20, expected: "[------------------]"},
{received: -1, total: 100, width: 20, expected: "[__________________]"},
{received: 101, total: 100, width: 20, expected: "[------------------]"},
{received: 40, total: 80, width: 12, expected: "[----->____]"},
{received: 41, total: 80, width: 12, expected: "[----->____]"},
{received: 42, total: 80, width: 12, expected: "[----->____]"},
{received: 43, total: 80, width: 12, expected: "[----->____]"},
{received: 44, total: 80, width: 12, expected: "[----->____]"},
{received: 45, total: 80, width: 12, expected: "[----->____]"},
{received: 46, total: 80, width: 12, expected: "[----->____]"},
{received: 47, total: 80, width: 12, expected: "[----->____]"},
{received: 48, total: 80, width: 12, expected: "[------>___]"},
}

for _, testcase := range cases {
assert.Equalf(t, testcase.expected, renderBarASCII(testcase.received, testcase.total, testcase.width),
"r: %d, t: %d, w:%d", testcase.received, testcase.total, testcase.width)
}
})

t.Run("Render download progress", func(t *testing.T) {
t.Parallel()

cases := []struct {
received int64
total int64
msg string
width int
expected string
}{
// simple case
{
received: 20, total: 100, msg: "Downloading plugin", width: 40,
expected: "Downloading plugin [█▍ ] 20 B/100 B",
},
// Not enough room for numbers
{
received: 20, total: 100, msg: "Downloading plugin", width: 30,
expected: "Downloading plugin [█▊ ]",
},
// Not enough room for bar
{
received: 20, total: 100, msg: "Downloading plugin", width: 20,
expected: "Downloading plugin",
},
// Not enough room for entire message
{
received: 20, total: 100, msg: "Downloading plugin", width: 15,
expected: "Downloading plu",
},
}

for _, testcase := range cases {
payload := engine.DownloadProgressEventPayload{
Received: testcase.received,
Total: testcase.total,
Msg: testcase.msg,
DownloadType: engine.PluginDownload,
ID: "id",
}
assert.Equal(t, renderDownloadProgress(payload, testcase.width, false), testcase.expected)
}
})

t.Run("ASCII Render download progress", func(t *testing.T) {
t.Parallel()

cases := []struct {
received int64
total int64
msg string
width int
expected string
}{
// simple case
{
received: 20, total: 100, msg: "Downloading plugin", width: 40,
expected: "Downloading plugin [->_____] 20 B/100 B",
},
// Not enough room for numbers
{
received: 20, total: 100, msg: "Downloading plugin", width: 30,
expected: "Downloading plugin [->_______]",
},
// Not enough room for bar
{
received: 20, total: 100, msg: "Downloading plugin", width: 20,
expected: "Downloading plugin",
},
// Not enough room for entire message
{
received: 20, total: 100, msg: "Downloading plugin", width: 15,
expected: "Downloading plu",
},
}

for _, testcase := range cases {
payload := engine.DownloadProgressEventPayload{
Received: testcase.received,
Total: testcase.total,
Msg: testcase.msg,
DownloadType: engine.PluginDownload,
ID: "id",
}
assert.Equal(t, renderDownloadProgress(payload, testcase.width, true), testcase.expected)
}
})
}
3 changes: 3 additions & 0 deletions pkg/backend/display/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ func ConvertEngineEvent(e engine.Event, showSecrets bool) (apitype.EngineEvent,
case engine.PolicyLoadEvent:
apiEvent.PolicyLoadEvent = &apitype.PolicyLoadEvent{}

case engine.DownloadProgressEvent:
apiEvent.DownloadProgressEvent = &apitype.DownloadProgressEvent{}

default:
return apiEvent, fmt.Errorf("unknown event type %q", e.Type)
}
Expand Down
25 changes: 23 additions & 2 deletions pkg/backend/display/internal/terminal/info.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package terminal

import (
"errors"
"fmt"
"io"
"strings"

gotty "github.com/ijc/Gotty"
)
Expand All @@ -26,8 +26,29 @@ type termInfo interface {

type noTermInfo int // canary used when no terminfo.

var termOps = map[string]string{
"el1": "clear-to-cursor",
"el": "clear-to-end",
"cuu": "cursor-up",
"cud": "cursor-down",
"civis": "hide-cursor",
"cnorm": "show-cursor",
}

func (ti noTermInfo) Parse(attr string, params ...interface{}) (string, error) {
return "", errors.New("noTermInfo")
opName, ok := termOps[attr]
if !ok {
opName = attr
}
if len(params) == 0 {
return fmt.Sprintf("<{%%%s%%}>", opName), nil
}
elements := make([]string, 0, 1+len(params))
elements = append(elements, opName)
for _, param := range params {
elements = append(elements, fmt.Sprint(param))
}
return fmt.Sprintf("<{%%%s%%}>", strings.Join(elements, ":")), nil
}

type info struct {
Expand Down
Loading
Loading