Merge pull request #122 from mortent/TablePrinterLib

Break out functionality for table printing into library
This commit is contained in:
Kubernetes Prow Robot 2020-04-13 13:51:48 -07:00 committed by GitHub
commit c813e58c26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 945 additions and 0 deletions

11
pkg/print/common/ansii.go Normal file
View File

@ -0,0 +1,11 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package common
const (
// RESET is the escape sequence for unsetting any previous commands.
RESET = 0
// ESC is the escape sequence used to send ANSI commands in the terminal.
ESC = 27
)

46
pkg/print/common/color.go Normal file
View File

@ -0,0 +1,46 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package common
import (
"fmt"
"sigs.k8s.io/cli-utils/pkg/kstatus/status"
)
// color is a type that captures the ANSI code for colors on the
// terminal.
type Color int
var (
RED Color = 31
GREEN Color = 32
YELLOW Color = 33
)
// SprintfWithColor formats according to the provided pattern and returns
// the result as a string with the necessary ansii escape codes for
// color
func SprintfWithColor(color Color, format string, a ...interface{}) string {
return fmt.Sprintf("%c[%dm", ESC, color) +
fmt.Sprintf(format, a...) +
fmt.Sprintf("%c[%dm", ESC, RESET)
}
// ColorForStatus returns the appropriate Color, which represents
// the ansii escape code, for different status values.
func ColorForStatus(s status.Status) (color Color, setColor bool) {
switch s {
case status.CurrentStatus:
color = GREEN
setColor = true
case status.InProgressStatus:
color = YELLOW
setColor = true
case status.FailedStatus:
color = RED
setColor = true
}
return
}

View File

@ -0,0 +1,70 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package common
import (
"testing"
"gotest.tools/assert"
"sigs.k8s.io/cli-utils/pkg/kstatus/status"
)
func TestSprintfWithColor(t *testing.T) {
testCases := map[string]struct {
color Color
format string
args []interface{}
expectedResult string
}{
"no args with color": {
color: GREEN,
format: "This is a test",
args: []interface{}{},
expectedResult: "\x1b[32mThis is a test\x1b[0m",
},
"with args and color": {
color: YELLOW,
format: "%s %s",
args: []interface{}{"sonic", "youth"},
expectedResult: "\x1b[33msonic youth\x1b[0m",
},
}
for tn, tc := range testCases {
t.Run(tn, func(t *testing.T) {
result := SprintfWithColor(tc.color, tc.format, tc.args...)
if want, got := tc.expectedResult, result; want != got {
t.Errorf("expected %q, but got %q", want, got)
}
})
}
}
func TestColorForStatus(t *testing.T) {
testCases := map[string]struct {
status status.Status
expectedSetColor bool
expectedColor Color
}{
"status with color": {
status: status.CurrentStatus,
expectedSetColor: true,
expectedColor: GREEN,
},
"status without color": {
status: status.NotFoundStatus,
expectedSetColor: false,
},
}
for tn, tc := range testCases {
t.Run(tn, func(t *testing.T) {
color, setColor := ColorForStatus(tc.status)
assert.Equal(t, setColor, tc.expectedSetColor)
if tc.expectedSetColor {
assert.Equal(t, color, tc.expectedColor)
}
})
}
}

152
pkg/print/table/base.go Normal file
View File

@ -0,0 +1,152 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package table
import (
"fmt"
"io"
"strings"
"unicode/utf8"
"k8s.io/cli-runtime/pkg/genericclioptions"
pe "sigs.k8s.io/cli-utils/pkg/kstatus/polling/event"
"sigs.k8s.io/cli-utils/pkg/object"
"sigs.k8s.io/cli-utils/pkg/print/common"
)
// ColumnDefinition defines the columns that should be printed.
type ColumnDefinition interface {
Name() string
Header() string
Width() int
PrintResource(w io.Writer, width int, r Resource) (int, error)
}
// ResourceStates defines the interface that must be implemented
// by the object that provides information about the resources
// that should be printed.
type ResourceStates interface {
Resources() []Resource
}
// Resource defines the interface that each of the Resource
// objects must implement.
type Resource interface {
Identifier() object.ObjMetadata
ResourceStatus() *pe.ResourceStatus
SubResources() []Resource
}
// BaseTablePrinter provides functionality for printing information
// about a set of resources into a table format.
// The printer will print to the Out stream defined in IOStreams,
// and will print into the format defined by the Column definitions.
type BaseTablePrinter struct {
IOStreams genericclioptions.IOStreams
Columns []ColumnDefinition
}
// PrintTable prints the resources defined in ResourceStates. It will
// print subresources if they exist.
// moveUpCount defines how many lines the printer should move up
// before starting printing. The return value is how many lines
// were printed.
func (t *BaseTablePrinter) PrintTable(rs ResourceStates,
moveUpCount int) int {
for i := 0; i < moveUpCount; i++ {
t.moveUp()
t.eraseCurrentLine()
}
linePrintCount := 0
for i, column := range t.Columns {
format := fmt.Sprintf("%%-%ds", column.Width())
t.printOrDie(format, column.Header())
if i == len(t.Columns)-1 {
t.printOrDie("\n")
linePrintCount++
} else {
t.printOrDie(" ")
}
}
for _, resource := range rs.Resources() {
for i, column := range t.Columns {
written, err := column.PrintResource(t.IOStreams.Out, column.Width(), resource)
if err != nil {
panic(err)
}
remainingSpace := column.Width() - written
t.printOrDie(strings.Repeat(" ", remainingSpace))
if i == len(t.Columns)-1 {
t.printOrDie("\n")
linePrintCount++
} else {
t.printOrDie(" ")
}
}
linePrintCount += t.printSubTable(resource.SubResources(), "")
}
return linePrintCount
}
// printSubTable prints out any subresources that belong to the
// top-level resources. This function takes care of printing the correct tree
// structure and indentation.
func (t *BaseTablePrinter) printSubTable(resources []Resource,
prefix string) int {
linePrintCount := 0
for j, resource := range resources {
for i, column := range t.Columns {
availableWidth := column.Width()
if column.Name() == "resource" {
if j < len(resources)-1 {
t.printOrDie(prefix + `├─ `)
} else {
t.printOrDie(prefix + `└─ `)
}
availableWidth -= utf8.RuneCountInString(prefix) + 3
}
written, err := column.PrintResource(t.IOStreams.Out,
availableWidth, resource)
if err != nil {
panic(err)
}
remainingSpace := availableWidth - written
t.printOrDie(strings.Repeat(" ", remainingSpace))
if i == len(t.Columns)-1 {
t.printOrDie("\n")
linePrintCount++
} else {
t.printOrDie(" ")
}
}
var prefix string
if j < len(resources)-1 {
prefix = ``
} else {
prefix = " "
}
linePrintCount += t.printSubTable(resource.SubResources(), prefix)
}
return linePrintCount
}
func (t *BaseTablePrinter) printOrDie(format string, a ...interface{}) {
_, err := fmt.Fprintf(t.IOStreams.Out, format, a...)
if err != nil {
panic(err)
}
}
func (t *BaseTablePrinter) moveUp() {
t.printOrDie("%c[%dA", common.ESC, 1)
}
func (t *BaseTablePrinter) eraseCurrentLine() {
t.printOrDie("%c[2K\r", common.ESC)
}

View File

@ -0,0 +1,184 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package table
import (
"fmt"
"io"
"strings"
"testing"
"gotest.tools/assert"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/cli-runtime/pkg/genericclioptions"
pe "sigs.k8s.io/cli-utils/pkg/kstatus/polling/event"
"sigs.k8s.io/cli-utils/pkg/object"
)
var (
endColumnDef = ColumnDef{
ColumnName: "end",
ColumnHeader: "END",
ColumnWidth: 3,
PrintResourceFunc: func(w io.Writer, width int, r Resource) (i int,
err error) {
return fmt.Fprint(w, "end")
},
}
)
func TestBaseTablePrinter_PrintTable(t *testing.T) {
testCases := map[string]struct {
columnDefinitions []ColumnDefinition
resources []Resource
expectedOutput string
}{
"no resources": {
columnDefinitions: []ColumnDefinition{
MustColumn("resource"),
endColumnDef,
},
resources: []Resource{},
expectedOutput: `
RESOURCE END
`,
},
"with resource": {
columnDefinitions: []ColumnDefinition{
MustColumn("resource"),
endColumnDef,
},
resources: []Resource{
&fakeResource{
resourceStatus: &pe.ResourceStatus{
Identifier: object.ObjMetadata{
Namespace: "default",
Name: "Foo",
GroupKind: schema.GroupKind{
Group: "apps",
Kind: "Deployment",
},
},
},
},
},
expectedOutput: `
RESOURCE END
Deployment/Foo end
`,
},
"sub resources": {
columnDefinitions: []ColumnDefinition{
MustColumn("resource"),
endColumnDef,
},
resources: []Resource{
&fakeResource{
resourceStatus: &pe.ResourceStatus{
Identifier: object.ObjMetadata{
Namespace: "default",
Name: "Foo",
GroupKind: schema.GroupKind{
Group: "apps",
Kind: "Deployment",
},
},
GeneratedResources: []*pe.ResourceStatus{
{
Identifier: object.ObjMetadata{
Namespace: "default",
Name: "Bar",
GroupKind: schema.GroupKind{
Group: "apps",
Kind: "ReplicaSet",
},
},
},
},
},
},
},
expectedOutput: `
RESOURCE END
Deployment/Foo end
ReplicaSet/Bar end
`,
},
"trim long content": {
columnDefinitions: []ColumnDefinition{
MustColumn("resource"),
endColumnDef,
},
resources: []Resource{
&fakeResource{
resourceStatus: &pe.ResourceStatus{
Identifier: object.ObjMetadata{
Namespace: "default",
Name: "VeryLongNameThatShouldBeTrimmed",
GroupKind: schema.GroupKind{
Group: "apps",
Kind: "Deployment",
},
},
},
},
},
expectedOutput: `
RESOURCE END
Deployment/VeryLongNameThatShouldBeTrimm end
`,
},
}
for tn, tc := range testCases {
t.Run(tn, func(t *testing.T) {
ioStreams, _, outBuffer, _ := genericclioptions.NewTestIOStreams()
printer := &BaseTablePrinter{
IOStreams: ioStreams,
Columns: tc.columnDefinitions,
}
resourceStates := &fakeResourceStates{
resources: tc.resources,
}
printer.PrintTable(resourceStates, 0)
assert.Equal(t,
strings.TrimSpace(tc.expectedOutput),
strings.TrimSpace(outBuffer.String()))
})
}
}
type fakeResourceStates struct {
resources []Resource
}
func (r *fakeResourceStates) Resources() []Resource {
return r.resources
}
type fakeResource struct {
resourceStatus *pe.ResourceStatus
}
func (r *fakeResource) Identifier() object.ObjMetadata {
return r.resourceStatus.Identifier
}
func (r *fakeResource) ResourceStatus() *pe.ResourceStatus {
return r.resourceStatus
}
func (r *fakeResource) SubResources() []Resource {
var resources []Resource
for _, res := range r.resourceStatus.GeneratedResources {
resources = append(resources, &fakeResource{
resourceStatus: res,
})
}
return resources
}

View File

@ -0,0 +1,243 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package table
import (
"fmt"
"io"
"time"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/utils/integer"
"sigs.k8s.io/cli-utils/pkg/print/common"
)
// ColumnDef is an implementation of the ColumnDefinition interface.
// It can be used to define simple columns that doesn't need additional
// knowledge about the actual type of the provided Resource besides the
// information available through the interface.
type ColumnDef struct {
ColumnName string
ColumnHeader string
ColumnWidth int
PrintResourceFunc func(w io.Writer, width int, r Resource) (int, error)
}
// Name returns the name of the column.
func (c ColumnDef) Name() string {
return c.ColumnName
}
// Header returns the header that should be printed for
// the column.
func (c ColumnDef) Header() string {
return c.ColumnHeader
}
// Width returns the width of the column.
func (c ColumnDef) Width() int {
return c.ColumnWidth
}
// PrintResource is called by the BaseTablePrinter to output the
// content of a particular column. This implementation just delegates
// to the provided PrintResourceFunc.
func (c ColumnDef) PrintResource(w io.Writer, width int, r Resource) (int, error) {
return c.PrintResourceFunc(w, width, r)
}
// MustColumn returns the pre-defined column definition with the
// provided name. If the name doesn't exist, it will panic.
func MustColumn(name string) ColumnDef {
c, found := columnDefinitions[name]
if !found {
panic(fmt.Errorf("unknown column name %q", name))
}
return c
}
var (
columnDefinitions = map[string]ColumnDef{
// namespace defines a column that output the namespace of the
// resource, or nothing in the case of clusterscoped resources.
"namespace": {
ColumnName: "namespace",
ColumnHeader: "NAMESPACE",
ColumnWidth: 10,
PrintResourceFunc: func(w io.Writer, width int, r Resource) (int,
error) {
namespace := r.Identifier().Namespace
if len(namespace) > width {
namespace = namespace[:width]
}
_, err := fmt.Fprint(w, namespace)
return len(namespace), err
},
},
// resource defines a column that outputs the kind and name of a
// resource.
"resource": {
ColumnName: "resource",
ColumnHeader: "RESOURCE",
ColumnWidth: 40,
PrintResourceFunc: func(w io.Writer, width int, r Resource) (int,
error) {
text := fmt.Sprintf("%s/%s", r.Identifier().GroupKind.Kind,
r.Identifier().Name)
if len(text) > width {
text = text[:width]
}
_, err := fmt.Fprint(w, text)
return len(text), err
},
},
// status defines a column that outputs the status of a resource. It
// will use ansii escape codes to color the output.
"status": {
ColumnName: "status",
ColumnHeader: "STATUS",
ColumnWidth: 10,
PrintResourceFunc: func(w io.Writer, width int, r Resource) (int,
error) {
rs := r.ResourceStatus()
if rs == nil {
return 0, nil
}
s := rs.Status.String()
if len(s) > width {
s = s[:width]
}
color, setColor := common.ColorForStatus(rs.Status)
var outputStatus string
if setColor {
outputStatus = common.SprintfWithColor(color, s)
} else {
outputStatus = s
}
_, err := fmt.Fprint(w, outputStatus)
return len(s), err
},
},
// conditions defines a column that outputs the conditions for
// a resource. The output will be in colors.
"conditions": {
ColumnName: "conditions",
ColumnHeader: "CONDITIONS",
ColumnWidth: 40,
PrintResourceFunc: func(w io.Writer, width int, r Resource) (int,
error) {
rs := r.ResourceStatus()
if rs == nil {
return 0, nil
}
u := rs.Resource
if u == nil {
return fmt.Fprintf(w, "-")
}
conditions, found, err := unstructured.NestedSlice(u.Object,
"status", "conditions")
if !found || err != nil || len(conditions) == 0 {
return fmt.Fprintf(w, "<None>")
}
realLength := 0
for i, cond := range conditions {
condition := cond.(map[string]interface{})
conditionType := condition["type"].(string)
conditionStatus := condition["status"].(string)
var color common.Color
switch conditionStatus {
case "True":
color = common.GREEN
case "False":
color = common.RED
default:
color = common.YELLOW
}
remainingWidth := width - realLength
if len(conditionType) > remainingWidth {
conditionType = conditionType[:remainingWidth]
}
_, err := fmt.Fprint(w, common.SprintfWithColor(color, conditionType))
if err != nil {
return realLength, err
}
realLength += len(conditionType)
if i < len(conditions)-1 && width-realLength > 2 {
_, err = fmt.Fprintf(w, ",")
if err != nil {
return realLength, err
}
realLength += 1
}
}
return realLength, nil
},
},
// age defines a column that outputs the age of a resource computed
// by looking at the creationTimestamp field.
"age": {
ColumnName: "age",
ColumnHeader: "AGE",
ColumnWidth: 6,
PrintResourceFunc: func(w io.Writer, width int, r Resource) (i int, err error) {
rs := r.ResourceStatus()
if rs == nil {
return 0, nil
}
u := rs.Resource
if u == nil {
return fmt.Fprint(w, "-")
}
timestamp, found, err := unstructured.NestedString(u.Object,
"metadata", "creationTimestamp")
if !found || err != nil || timestamp == "" {
return fmt.Fprint(w, "-")
}
parsedTime, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
return fmt.Fprint(w, "-")
}
age := time.Since(parsedTime)
switch {
case age.Seconds() <= 90:
return fmt.Fprintf(w, "%ds",
integer.RoundToInt32(age.Round(time.Second).Seconds()))
case age.Minutes() <= 90:
return fmt.Fprintf(w, "%dm",
integer.RoundToInt32(age.Round(time.Minute).Minutes()))
default:
return fmt.Fprintf(w, "%dh",
integer.RoundToInt32(age.Round(time.Hour).Hours()))
}
},
},
// message defines a column that outputs the message from a
// ResourceStatus, or if there is a non-nil error, output the text
// from the error instead.
"message": {
ColumnName: "message",
ColumnHeader: "MESSAGE",
ColumnWidth: 40,
PrintResourceFunc: func(w io.Writer, width int, r Resource) (i int, err error) {
rs := r.ResourceStatus()
if rs == nil {
return 0, nil
}
var message string
if rs.Error != nil {
message = rs.Error.Error()
} else {
message = rs.Message
}
if len(message) > width {
message = message[:width]
}
return fmt.Fprint(w, message)
},
},
}
)

View File

@ -0,0 +1,239 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package table
import (
"bytes"
"fmt"
"testing"
"time"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
pe "sigs.k8s.io/cli-utils/pkg/kstatus/polling/event"
"sigs.k8s.io/cli-utils/pkg/kstatus/status"
"sigs.k8s.io/cli-utils/pkg/object"
)
func TestColumnDefs(t *testing.T) {
testCases := map[string]struct {
columnName string
resource Resource
columnWidth int
expectedOutput string
}{
"namespace": {
columnName: "namespace",
resource: &fakeResource{
resourceStatus: &pe.ResourceStatus{
Identifier: object.ObjMetadata{
Namespace: "Foo",
},
},
},
columnWidth: 10,
expectedOutput: "Foo",
},
"namespace trimmed": {
columnName: "namespace",
resource: &fakeResource{
resourceStatus: &pe.ResourceStatus{
Identifier: object.ObjMetadata{
Namespace: "ICanHearTheHeartBeatingAsOne",
},
},
},
columnWidth: 10,
expectedOutput: "ICanHearTh",
},
"resource": {
columnName: "resource",
resource: &fakeResource{
resourceStatus: &pe.ResourceStatus{
Identifier: object.ObjMetadata{
Name: "YoLaTengo",
GroupKind: schema.GroupKind{
Kind: "RoleBinding",
},
},
},
},
columnWidth: 40,
expectedOutput: "RoleBinding/YoLaTengo",
},
"resource trimmed": {
columnName: "resource",
resource: &fakeResource{
resourceStatus: &pe.ResourceStatus{
Identifier: object.ObjMetadata{
Name: "SlantedAndEnchanted",
GroupKind: schema.GroupKind{
Kind: "Pavement",
},
},
},
},
columnWidth: 25,
expectedOutput: "Pavement/SlantedAndEnchan",
},
"status with color": {
columnName: "status",
resource: &fakeResource{
resourceStatus: &pe.ResourceStatus{
Status: status.CurrentStatus,
},
},
columnWidth: 10,
expectedOutput: "\x1b[32mCurrent\x1b[0m",
},
"status trimmed": {
columnName: "status",
resource: &fakeResource{
resourceStatus: &pe.ResourceStatus{
Status: status.NotFoundStatus,
},
},
columnWidth: 5,
expectedOutput: "NotFo",
},
"conditions with color": {
columnName: "conditions",
resource: &fakeResource{
resourceStatus: &pe.ResourceStatus{
Resource: mustResourceWithConditions([]condition{
{
Type: "Ready",
Status: v1.ConditionUnknown,
},
}),
},
},
columnWidth: 10,
expectedOutput: "\x1b[33mReady\x1b[0m",
},
"conditions trimmed": {
columnName: "conditions",
resource: &fakeResource{
resourceStatus: &pe.ResourceStatus{
Resource: mustResourceWithConditions([]condition{
{
Type: "Ready",
Status: v1.ConditionTrue,
},
{
Type: "Reconciling",
Status: v1.ConditionFalse,
},
}),
},
},
columnWidth: 10,
expectedOutput: "\x1b[32mReady\x1b[0m,\x1b[31mReco\x1b[0m",
},
"age not found": {
columnName: "age",
resource: &fakeResource{
resourceStatus: &pe.ResourceStatus{
Resource: &unstructured.Unstructured{},
},
},
columnWidth: 10,
expectedOutput: "-",
},
"age": {
columnName: "age",
resource: &fakeResource{
resourceStatus: &pe.ResourceStatus{
Resource: mustResourceWithCreationTimestamp(45 * time.Minute),
},
},
columnWidth: 10,
expectedOutput: "45m",
},
"message without error": {
columnName: "message",
resource: &fakeResource{
resourceStatus: &pe.ResourceStatus{
Message: "this is a test",
},
},
columnWidth: 30,
expectedOutput: "this is a test",
},
"message from error": {
columnName: "message",
resource: &fakeResource{
resourceStatus: &pe.ResourceStatus{
Message: "this is a test",
Error: fmt.Errorf("something went wrong somewhere"),
},
},
columnWidth: 50,
expectedOutput: "something went wrong somewhere",
},
"message trimmed": {
columnName: "message",
resource: &fakeResource{
resourceStatus: &pe.ResourceStatus{
Message: "this is a test",
},
},
columnWidth: 6,
expectedOutput: "this i",
},
}
for tn, tc := range testCases {
t.Run(tn, func(t *testing.T) {
columnDef := MustColumn(tc.columnName)
var buf bytes.Buffer
_, err := columnDef.PrintResource(&buf, tc.columnWidth, tc.resource)
if err != nil {
t.Error(err)
}
if want, got := tc.expectedOutput, buf.String(); want != got {
t.Errorf("expected %q, but got %q", want, got)
}
})
}
}
type condition struct {
Type string
Status v1.ConditionStatus
}
func mustResourceWithConditions(conditions []condition) *unstructured.Unstructured {
u := &unstructured.Unstructured{
Object: make(map[string]interface{}),
}
var conditionsSlice []interface{}
for _, c := range conditions {
cond := make(map[string]interface{})
cond["type"] = c.Type
cond["status"] = string(c.Status)
conditionsSlice = append(conditionsSlice, cond)
}
err := unstructured.SetNestedSlice(u.Object, conditionsSlice,
"status", "conditions")
if err != nil {
panic(err)
}
return u
}
func mustResourceWithCreationTimestamp(age time.Duration) *unstructured.Unstructured {
u := &unstructured.Unstructured{
Object: make(map[string]interface{}),
}
creationTime := time.Now().Add(-age)
u.SetCreationTimestamp(metav1.Time{
Time: creationTime,
})
return u
}