From c284ae0e084c763758fb2f6393c919cf50d21a85 Mon Sep 17 00:00:00 2001 From: Matt Leader Date: Tue, 16 Jul 2024 09:53:35 -0400 Subject: [PATCH 1/2] Make Namespace Behavior Public (#199) * refactor Signed-off-by: Matthew F Leader * add function description * add fn to exectuable workflow iface --------- Signed-off-by: Matthew F Leader --- internal/util/stringers.go | 17 ++++++++++++++ workflow/executor.go | 43 +++++++++++++++++++--------------- workflow/executor_unit_test.go | 6 ++--- workflow/workflow.go | 7 ++++++ 4 files changed, 51 insertions(+), 22 deletions(-) create mode 100644 internal/util/stringers.go diff --git a/internal/util/stringers.go b/internal/util/stringers.go new file mode 100644 index 00000000..2cc2590a --- /dev/null +++ b/internal/util/stringers.go @@ -0,0 +1,17 @@ +package util + +import "go.flow.arcalot.io/pluginsdk/schema" + +// BuildNamespaceString creates a human-readable string representation of the +// namespace scoped objects. +func BuildNamespaceString(allNamespaces map[string]map[string]*schema.ObjectSchema) string { + availableObjects := "" + for namespace, objects := range allNamespaces { + availableObjects += "\n\t" + namespace + ":" + for objectID := range objects { + availableObjects += " " + objectID + } + } + availableObjects += "\n" + return availableObjects +} diff --git a/workflow/executor.go b/workflow/executor.go index 9c1c8816..b45578ea 100644 --- a/workflow/executor.go +++ b/workflow/executor.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "go.flow.arcalot.io/engine/internal/util" "reflect" "strings" @@ -71,6 +72,11 @@ type ExecutableWorkflow interface { // OutputSchema returns the schema for the possible outputs of this workflow. OutputSchema() map[string]*schema.StepOutputSchema + + // Namespaces returns a namespaced collection of objects for the inputs + // and outputs of each stage in the step's lifecycles. + // It maps namespace id (path) to object id to object schema. + Namespaces() map[string]map[string]*schema.ObjectSchema } type executor struct { @@ -121,7 +127,7 @@ func (e *executor) Prepare(workflow *Workflow, workflowContext map[string][]byte } // Apply step lifecycle objects to the input scope - err = applyLifecycleScopes(stepLifecycles, typedInput) + err = applyLifecycleNamespaces(stepLifecycles, typedInput) if err != nil { return nil, err } @@ -313,23 +319,30 @@ func (e *executor) processSteps( return runnableSteps, stepOutputProperties, stepLifecycles, stepRunData, nil } -func applyLifecycleScopes( - stepLifecycles map[string]step.Lifecycle[step.LifecycleStageWithSchema], - typedInput schema.Scope, -) error { +// BuildNamespaces creates a namespaced collection of objects for the inputs +// and outputs of each stage in the step's lifecycles. +// It maps namespace id (path) to object id to object schema. +func BuildNamespaces(stepLifecycles map[string]step.Lifecycle[step.LifecycleStageWithSchema]) map[string]map[string]*schema.ObjectSchema { allNamespaces := make(map[string]map[string]*schema.ObjectSchema) for workflowStepID, stepLifecycle := range stepLifecycles { for _, stage := range stepLifecycle.Stages { prefix := "$.steps." + workflowStepID + "." + stage.ID + "." // Apply inputs // Example with stage "starting": $.steps.wait_step.starting.inputs. - addInputNamespacedScopes(allNamespaces, stage, prefix+"inputs.") + addInputNamespaces(allNamespaces, stage, prefix+"inputs.") // Apply outputs // Example with stage "outputs": $.steps.wait_step.outputs.outputs. - addOutputNamespacedScopes(allNamespaces, stage, prefix+"outputs.") + addOutputNamespaces(allNamespaces, stage, prefix+"outputs.") } } - return applyAllNamespaces(allNamespaces, typedInput) + return allNamespaces +} + +func applyLifecycleNamespaces( + stepLifecycles map[string]step.Lifecycle[step.LifecycleStageWithSchema], + typedInput schema.Scope, +) error { + return applyAllNamespaces(BuildNamespaces(stepLifecycles), typedInput) } // applyAllNamespaces applies all namespaces to the given scope. @@ -344,28 +357,20 @@ func applyAllNamespaces(allNamespaces map[string]map[string]*schema.ObjectSchema return nil // Success } // Now on the error path. Provide useful debug info. - availableObjects := "" - for namespace, objects := range allNamespaces { - availableObjects += "\n\t" + namespace + ":" - for objectID := range objects { - availableObjects += " " + objectID - } - } - availableObjects += "\n" // Since this is a multi-line error message, ending with a newline is clearer. return fmt.Errorf( "error validating references for workflow input (%w)\nAvailable namespaces and objects:%s", err, - availableObjects, + util.BuildNamespaceString(allNamespaces), ) } -func addOutputNamespacedScopes(allNamespaces map[string]map[string]*schema.ObjectSchema, stage step.LifecycleStageWithSchema, prefix string) { +func addOutputNamespaces(allNamespaces map[string]map[string]*schema.ObjectSchema, stage step.LifecycleStageWithSchema, prefix string) { for outputID, outputSchema := range stage.Outputs { addScopesWithReferences(allNamespaces, outputSchema.Schema(), prefix+outputID) } } -func addInputNamespacedScopes(allNamespaces map[string]map[string]*schema.ObjectSchema, stage step.LifecycleStageWithSchema, prefix string) { +func addInputNamespaces(allNamespaces map[string]map[string]*schema.ObjectSchema, stage step.LifecycleStageWithSchema, prefix string) { for inputID, inputSchemaProperty := range stage.InputSchema { var inputSchemaType = inputSchemaProperty.Type() // Extract item values from lists (like for ForEach) diff --git a/workflow/executor_unit_test.go b/workflow/executor_unit_test.go index 09ee97fb..e5bb80a3 100644 --- a/workflow/executor_unit_test.go +++ b/workflow/executor_unit_test.go @@ -61,7 +61,7 @@ var testLifecycleStage = step.LifecycleStageWithSchema{ func TestAddOutputNamespacedScopes(t *testing.T) { allNamespaces := make(map[string]map[string]*schema.ObjectSchema) namespacePrefix := "TEST_PREFIX_" - addOutputNamespacedScopes(allNamespaces, testLifecycleStage, namespacePrefix) + addOutputNamespaces(allNamespaces, testLifecycleStage, namespacePrefix) expectedNamespace := namespacePrefix + "outputC" assert.Equals(t, len(allNamespaces), 1) assert.MapContainsKey(t, expectedNamespace, allNamespaces) @@ -76,7 +76,7 @@ func TestAddInputNamespacedScopes(t *testing.T) { "listA": "testObjectB", } - addInputNamespacedScopes(allNamespaces, testLifecycleStage, namespacePrefix) + addInputNamespaces(allNamespaces, testLifecycleStage, namespacePrefix) assert.Equals(t, len(allNamespaces), 2) for expectedInput, expectedObject := range expectedInputs { expectedNamespace := namespacePrefix + expectedInput @@ -459,7 +459,7 @@ func TestApplyLifecycleScopes_Basic(t *testing.T) { }, ), ) - assert.NoError(t, applyLifecycleScopes(stepLifecycles, testScope)) + assert.NoError(t, applyLifecycleNamespaces(stepLifecycles, testScope)) assert.NoError(t, testScope.ValidateReferences()) assert.NotNil(t, ref1Schema.GetObject()) assert.NotNil(t, ref2Schema.GetObject()) diff --git a/workflow/workflow.go b/workflow/workflow.go index 0c2ee7e5..96e689d7 100644 --- a/workflow/workflow.go +++ b/workflow/workflow.go @@ -55,6 +55,13 @@ func (e *executableWorkflow) DAG() dgraph.DirectedGraph[*DAGItem] { return e.dag } +// Namespaces returns a namespaced collection of objects for the inputs +// and outputs of each stage in the step's lifecycles. +// It maps namespace id (path) to object id to object schema. +func (e *executableWorkflow) Namespaces() map[string]map[string]*schema.ObjectSchema { + return BuildNamespaces(e.lifecycles) +} + // Execute runs the workflow with the specified input. You can use the context variable to abort the workflow execution // (e.g. when the user presses Ctrl+C). func (e *executableWorkflow) Execute(ctx context.Context, serializedInput any) (outputID string, outputData any, err error) { //nolint:gocognit From 43c7889e1cb4aa61c31f23d17021053c8dbfdad6 Mon Sep 17 00:00:00 2001 From: Matt Leader Date: Tue, 23 Jul 2024 14:56:41 -0400 Subject: [PATCH 2/2] CLI Show Workflow Namespaces Option (#200) * add get namespaces option * use tab writer to aligned table * add print namespace obj fn * add unnest long and column swap fns Signed-off-by: Matthew F Leader --------- --- cmd/arcaflow/main.go | 35 +++--- engine.go | 5 + internal/tablefmt/tablefmt.go | 53 +++++++++ internal/tablefmt/tablefmt_test.go | 122 +++++++++++++++++++++ internal/tableprinter/tableprinter.go | 43 ++++++++ internal/tableprinter/tableprinter_test.go | 26 +++++ workflow/workflow.go | 16 +++ workflow/workflow_test.go | 29 +++++ 8 files changed, 316 insertions(+), 13 deletions(-) create mode 100644 internal/tablefmt/tablefmt.go create mode 100644 internal/tablefmt/tablefmt_test.go create mode 100644 internal/tableprinter/tableprinter.go create mode 100644 internal/tableprinter/tableprinter_test.go diff --git a/cmd/arcaflow/main.go b/cmd/arcaflow/main.go index 7ec17213..7fe8c962 100644 --- a/cmd/arcaflow/main.go +++ b/cmd/arcaflow/main.go @@ -5,15 +5,15 @@ import ( "context" "flag" "fmt" - "os" - "os/signal" - "path/filepath" - "go.arcalot.io/log/v2" "go.flow.arcalot.io/engine" "go.flow.arcalot.io/engine/config" "go.flow.arcalot.io/engine/loadfile" + "go.flow.arcalot.io/engine/workflow" "gopkg.in/yaml.v3" + "os" + "os/signal" + "path/filepath" ) // These variables are filled using ldflags during the build process with Goreleaser. @@ -58,19 +58,22 @@ func main() { dir := "." workflowFile := "workflow.yaml" printVersion := false + getNamespaces := false const ( - versionUsage = "Print Arcaflow Engine version and exit." - configUsage = "The path to the Arcaflow configuration file to load, if any." - inputUsage = "The path to the workflow input file to load, if any." - contextUsage = "The path to the workflow directory to run from." - workflowUsage = "The path to the workflow file to load." + versionUsage = "Print Arcaflow Engine version and exit." + configUsage = "The path to the Arcaflow configuration file to load, if any." + inputUsage = "The path to the workflow input file to load, if any." + contextUsage = "The path to the workflow directory to run from." + workflowUsage = "The path to the workflow file to load." + getNamespacesUsage = "Show the namespaces available to this workflow." ) flag.BoolVar(&printVersion, "version", printVersion, versionUsage) flag.StringVar(&configFile, "config", configFile, configUsage) flag.StringVar(&input, "input", input, inputUsage) flag.StringVar(&dir, "context", dir, contextUsage) flag.StringVar(&workflowFile, "workflow", workflowFile, workflowUsage) + flag.BoolVar(&getNamespaces, "get-namespaces", getNamespaces, getNamespacesUsage) flag.Usage = func() { w := flag.CommandLine.Output() @@ -183,10 +186,10 @@ func main() { } } - os.Exit(runWorkflow(flow, fileCtx, RequiredFileKeyWorkflow, logger, inputData)) + os.Exit(runWorkflow(flow, fileCtx, RequiredFileKeyWorkflow, logger, inputData, getNamespaces)) } -func runWorkflow(flow engine.WorkflowEngine, fileCtx loadfile.FileCache, workflowFile string, logger log.Logger, inputData []byte) int { +func runWorkflow(flow engine.WorkflowEngine, fileCtx loadfile.FileCache, workflowFile string, logger log.Logger, inputData []byte, getNamespaces bool) int { ctx, cancel := context.WithCancel(context.Background()) ctrlC := make(chan os.Signal, 4) // We expect up to three ctrl-C inputs. Plus one extra to buffer in case. signal.Notify(ctrlC, os.Interrupt) @@ -197,13 +200,18 @@ func runWorkflow(flow engine.WorkflowEngine, fileCtx loadfile.FileCache, workflo cancel() }() - workflow, err := flow.Parse(fileCtx, workflowFile) + workFlowObj, err := flow.Parse(fileCtx, workflowFile) if err != nil { logger.Errorf("Invalid workflow (%v)", err) return ExitCodeInvalidData } - outputID, outputData, outputError, err := workflow.Run(ctx, inputData) + if getNamespaces { + workflow.PrintObjectNamespaceTable(os.Stdout, workFlowObj.Namespaces(), logger) + return ExitCodeOK + } + + outputID, outputData, outputError, err := workFlowObj.Run(ctx, inputData) if err != nil { logger.Errorf("Workflow execution failed (%v)", err) return ExitCodeWorkflowFailed @@ -222,6 +230,7 @@ func runWorkflow(flow engine.WorkflowEngine, fileCtx loadfile.FileCache, workflo if outputError { return ExitCodeWorkflowErrorOutput } + return ExitCodeOK } diff --git a/engine.go b/engine.go index 5cefe488..1ddf0bcf 100644 --- a/engine.go +++ b/engine.go @@ -56,6 +56,7 @@ type Workflow interface { InputSchema() schema.Scope // Outputs returns the list of possible outputs and their schema for the workflow. Outputs() map[string]schema.StepOutput + Namespaces() map[string]map[string]*schema.ObjectSchema } type workflowEngine struct { @@ -234,3 +235,7 @@ func (e engineWorkflow) Outputs() map[string]schema.StepOutput { } return outputs } + +func (e engineWorkflow) Namespaces() map[string]map[string]*schema.ObjectSchema { + return e.workflow.Namespaces() +} diff --git a/internal/tablefmt/tablefmt.go b/internal/tablefmt/tablefmt.go new file mode 100644 index 00000000..97d37723 --- /dev/null +++ b/internal/tablefmt/tablefmt.go @@ -0,0 +1,53 @@ +// Package tablefmt provides functions to create tabular data where +// 1. Each variable is a column; each column is a variable. +// 2. Each observation is a row; each row is an observation. +// 3. Each value is a cell; each cell is a single value. +// +// Its behavior is inspired by the R package tidyr. +// https://tidyr.tidyverse.org/index.html +package tablefmt + +import "sort" + +// UnnestLongerSorted turns each element of a list-group +// into a row. Each key in the map represents a group and +// each group is associated with a list of values. +func UnnestLongerSorted(twoColDf map[string][]string) [][]string { + df := [][]string{} + groupNames := []string{} + for name := range twoColDf { + groupNames = append(groupNames, name) + } + sort.Strings(groupNames) + for _, name := range groupNames { + groupRows := twoColDf[name] + sort.Strings(groupRows) + for _, rowValue := range groupRows { + df = append(df, []string{name, rowValue}) + } + } + return df +} + +// SwapColumns swaps the row values between the first and second column, if +// the row has a length of two. +func SwapColumns(df [][]string) [][]string { + for k := range df { + if len(df[k]) == 2 { + df[k][0], df[k][1] = df[k][1], df[k][0] + } + } + return df +} + +// ExtractGroupedLists transforms a map of maps into a map of strings. The keys +// in the nested map become a list of values. +func ExtractGroupedLists[T any](data map[string]map[string]T) map[string][]string { + groupLists := map[string][]string{} + for namespace, objects := range data { + for objName := range objects { + groupLists[namespace] = append(groupLists[namespace], objName) + } + } + return groupLists +} diff --git a/internal/tablefmt/tablefmt_test.go b/internal/tablefmt/tablefmt_test.go new file mode 100644 index 00000000..9ac2d02a --- /dev/null +++ b/internal/tablefmt/tablefmt_test.go @@ -0,0 +1,122 @@ +package tablefmt_test + +import ( + "go.arcalot.io/assert" + "go.flow.arcalot.io/engine/internal/tablefmt" + "sort" + "testing" +) + +func TestUnnestLongerSortedHappy(t *testing.T) { + astromech := []string{"q7", "bb", "r2", "r4"} + protocol := []string{"3po", "000", "chatty"} + battle := []string{"b1", "ig"} + probe := []string{"cobra"} + astromechGroup := "astromech" + protocolGroup := "protocol" + battleGroup := "battle" + probeGroup := "probe" + astromechSorted := make([]string, len(astromech)) + protocolSorted := make([]string, len(protocol)) + battleSorted := make([]string, len(battle)) + probeSorted := make([]string, len(probe)) + sort.Strings(astromechSorted) + sort.Strings(battleSorted) + sort.Strings(probeSorted) + sort.Strings(protocolSorted) + expOut := [][]string{ + {"astromech", "bb"}, + {"astromech", "q7"}, + {"astromech", "r2"}, + {"astromech", "r4"}, + {"battle", "b1"}, + {"battle", "ig"}, + {"probe", "cobra"}, + {"protocol", "000"}, + {"protocol", "3po"}, + {"protocol", "chatty"}, + } + input := map[string][]string{ + protocolGroup: protocol, + astromechGroup: astromech, + battleGroup: battle, + probeGroup: probe, + } + assert.Equals(t, tablefmt.UnnestLongerSorted(input), expOut) +} + +func TestUnnestLongerSortedEmpty(t *testing.T) { + input := map[string][]string{} + assert.Equals(t, tablefmt.UnnestLongerSorted(input), [][]string{}) +} + +func TestUnnestLongerSortedEmptyGroup(t *testing.T) { + input := map[string][]string{ + "G": {"g"}, + "D": {}, + } + expOut := [][]string{ + {"G", "g"}, + } + assert.Equals(t, tablefmt.UnnestLongerSorted(input), expOut) +} + +func TestSwapColumnsHappy(t *testing.T) { + testData := map[string]struct { + input [][]string + expected [][]string + }{ + "happy": { + input: [][]string{{"fruit", "tomato"}, + {"fruit", "cucumber"}, + {"veggie", "spinach"}, + {"veggie", "carrot"}, + }, + expected: [][]string{ + {"tomato", "fruit"}, + {"cucumber", "fruit"}, + {"spinach", "veggie"}, + {"carrot", "veggie"}, + }, + }, + "row_with_zero_len": { + input: [][]string{ + {"a", "b"}, + {}, + {"a", "c"}, + }, + expected: [][]string{ + {"b", "a"}, + {}, + {"c", "a"}, + }, + }, + "row_with_one_len": { + input: [][]string{ + {"a", "b"}, + {"z"}, + {"a", "c"}, + }, + expected: [][]string{ + {"b", "a"}, + {"z"}, + {"c", "a"}, + }, + }, + "row_with_three_len": { + input: [][]string{ + {"a", "b"}, + {"x", "y", "z"}, + {"a", "c"}, + }, + expected: [][]string{ + {"b", "a"}, + {"x", "y", "z"}, + {"c", "a"}, + }, + }, + } + for _, tc := range testData { + assert.Equals(t, tablefmt.SwapColumns(tc.input), tc.expected) + } +} diff --git a/internal/tableprinter/tableprinter.go b/internal/tableprinter/tableprinter.go new file mode 100644 index 00000000..6d158996 --- /dev/null +++ b/internal/tableprinter/tableprinter.go @@ -0,0 +1,43 @@ +// Package tableprinter provides behavior to write tabular data to a given +// destination. +package tableprinter + +import ( + "fmt" + "io" + "strings" + "text/tabwriter" +) + +const ( + tabwriterMinWidth = 6 + tabwriterWidth = 4 + tabwriterPadding = 3 + tabwriterPadChar = ' ' + tabwriterFlags = tabwriter.FilterHTML +) + +// newTabWriter returns a tabwriter that transforms tabbed columns into aligned +// text. +func newTabWriter(output io.Writer) *tabwriter.Writer { + return tabwriter.NewWriter(output, tabwriterMinWidth, tabwriterWidth, tabwriterPadding, tabwriterPadChar, tabwriterFlags) +} + +// PrintTwoColumnTable uses a list of two item records (rows) to write a two +// column table with headers to a given output destination. +func PrintTwoColumnTable(output io.Writer, headers []string, rows [][]string) { + w := newTabWriter(output) + + // column headers are at the top, so they are written first + for _, col := range headers { + _, _ = fmt.Fprint(w, strings.ToUpper(col), "\t") + } + _, _ = fmt.Fprintln(w) + + // rows form the body of the table + for _, row := range rows { + _, _ = fmt.Fprintln(w, row[0], "\t", row[1]) + } + + _ = w.Flush() +} diff --git a/internal/tableprinter/tableprinter_test.go b/internal/tableprinter/tableprinter_test.go new file mode 100644 index 00000000..0e1ea3be --- /dev/null +++ b/internal/tableprinter/tableprinter_test.go @@ -0,0 +1,26 @@ +package tableprinter_test + +import ( + "bytes" + "go.arcalot.io/assert" + "go.flow.arcalot.io/engine/internal/tableprinter" + "testing" +) + +const basicTwoColTable = `FUNCTION MODEL +a 1 +b 2 +c 3 +` + +func TestPrintTwoColumnTable(t *testing.T) { + buf := bytes.NewBuffer(nil) + headers := []string{"function", "model"} + rows := [][]string{ + {"a", "1"}, + {"b", "2"}, + {"c", "3"}, + } + tableprinter.PrintTwoColumnTable(buf, headers, rows) + assert.Equals(t, buf.String(), basicTwoColTable) +} diff --git a/workflow/workflow.go b/workflow/workflow.go index 96e689d7..7fb0bef7 100644 --- a/workflow/workflow.go +++ b/workflow/workflow.go @@ -4,6 +4,9 @@ package workflow import ( "context" "fmt" + "go.flow.arcalot.io/engine/internal/tablefmt" + "go.flow.arcalot.io/engine/internal/tableprinter" + "io" "reflect" "sync" "time" @@ -568,3 +571,16 @@ func (s stageChangeHandler) OnStepComplete( ) { s.onStepComplete(step, previousStage, previousStageOutputID, previousStageOutput, wg) } + +// PrintObjectNamespaceTable constructs and writes a tidy table of workflow +// Objects and their namespaces to the given output destination. +func PrintObjectNamespaceTable(output io.Writer, allNamespaces map[string]map[string]*schema.ObjectSchema, logger log.Logger) { + if len(allNamespaces) == 0 { + logger.Warningf("No namespaces found in workflow") + return + } + groupLists := tablefmt.ExtractGroupedLists[*schema.ObjectSchema](allNamespaces) + df := tablefmt.UnnestLongerSorted(groupLists) + df = tablefmt.SwapColumns(df) + tableprinter.PrintTwoColumnTable(output, []string{"object", "namespace"}, df) +} diff --git a/workflow/workflow_test.go b/workflow/workflow_test.go index dc8bb471..8c9f1527 100644 --- a/workflow/workflow_test.go +++ b/workflow/workflow_test.go @@ -1,6 +1,7 @@ package workflow_test import ( + "bytes" "context" "errors" "fmt" @@ -1381,3 +1382,31 @@ func createTestExecutableWorkflow(t *testing.T, workflowStr string, workflowCtx wf := lang.Must2(workflow.NewYAMLConverter(stepRegistry).FromYAML([]byte(workflowStr))) return executor.Prepare(wf, workflowCtx) } + +const printNamespaceResponseOutput = `OBJECT NAMESPACE +Crashed $.steps.long_wait.crashed.outputs.error +DeployError $.steps.long_wait.deploy_failed.outputs.error +DisabledMessageOutput $.steps.long_wait.disabled.outputs.output +EnabledOutput $.steps.long_wait.enabling.outputs.resolved +output $.steps.long_wait.outputs.outputs.cancelled_early +output $.steps.long_wait.outputs.outputs.success +output $.steps.long_wait.outputs.outputs.terminated_early +wait-input $.steps.long_wait.starting.inputs.input +StartedOutput $.steps.long_wait.starting.outputs.started +` + +func TestPrintObjectNamespaceTable(t *testing.T) { + preparedWorkflow := assert.NoErrorR[workflow.ExecutableWorkflow](t)( + getTestImplPreparedWorkflow(t, fiveSecWaitWorkflowDefinition), + ) + buf := bytes.NewBuffer(nil) + workflow.PrintObjectNamespaceTable(buf, preparedWorkflow.Namespaces(), nil) + assert.Equals(t, buf.String(), printNamespaceResponseOutput) +} + +func TestPrintObjectNamespaceTable_EmptyNamespace(t *testing.T) { + logger := log.NewLogger(log.LevelDebug, log.NewTestWriter(t)) + buf := bytes.NewBuffer(nil) + workflow.PrintObjectNamespaceTable(buf, map[string]map[string]*schema.ObjectSchema{}, logger) + assert.Equals(t, buf.String(), ``) +}