Skip to content

Commit 3e4eb25

Browse files
committed
feat(tools/looker): Enable Run Lookml Tests tool for Looker
1 parent d135891 commit 3e4eb25

5 files changed

Lines changed: 332 additions & 1 deletion

File tree

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ import (
160160
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerqueryurl"
161161
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerrundashboard"
162162
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerrunlook"
163+
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerrunlookmltests"
163164
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerupdateprojectfile"
164165
_ "github.com/googleapis/genai-toolbox/internal/tools/mindsdb/mindsdbexecutesql"
165166
_ "github.com/googleapis/genai-toolbox/internal/tools/mindsdb/mindsdbsql"

cmd/root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2353,7 +2353,7 @@ func TestPrebuiltTools(t *testing.T) {
23532353
wantToolset: server.ToolsetConfigs{
23542354
"looker_tools": tools.ToolsetConfig{
23552355
Name: "looker_tools",
2356-
ToolNames: []string{"get_models", "get_explores", "get_dimensions", "get_measures", "get_filters", "get_parameters", "query", "query_sql", "query_url", "get_looks", "run_look", "make_look", "get_dashboards", "run_dashboard", "make_dashboard", "add_dashboard_element", "add_dashboard_filter", "generate_embed_url", "health_pulse", "health_analyze", "health_vacuum", "dev_mode", "get_projects", "get_project_files", "get_project_file", "create_project_file", "update_project_file", "delete_project_file", "get_connections", "get_connection_schemas", "get_connection_databases", "get_connection_tables", "get_connection_table_columns"},
2356+
ToolNames: []string{"get_models", "get_explores", "get_dimensions", "get_measures", "get_filters", "get_parameters", "query", "query_sql", "query_url", "get_looks", "run_look", "make_look", "get_dashboards", "run_dashboard", "make_dashboard", "add_dashboard_element", "add_dashboard_filter", "generate_embed_url", "health_pulse", "health_analyze", "health_vacuum", "dev_mode", "get_projects", "get_project_files", "get_project_file", "create_project_file", "update_project_file", "delete_project_file", "get_connections", "get_connection_schemas", "get_connection_databases", "get_connection_tables", "get_connection_table_columns", "run_lookml_tests"},
23572357
},
23582358
},
23592359
},

internal/prebuiltconfigs/tools/looker.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,29 @@ tools:
10411041
A JSON array of objects, where each object represents a column and contains details
10421042
such as `table_name`, `column_name`, `data_type`, and `is_nullable`.
10431043
1044+
run_lookml_tests:
1045+
kind: looker-run-lookml-tests
1046+
source: looker-source
1047+
description: |
1048+
This tool runs LookML tests in the project, filtered by file, test, and/or model. These filters work in conjunction (logical AND).
1049+
1050+
Prerequisite: The Looker session must be in Development Mode. Use `dev_mode: true` first.
1051+
1052+
Parameters:
1053+
- project_id (required): The unique ID of the project to run LookML tests for.
1054+
- file_id (optional): The ID of the file to run tests for. This must be the complete file path from the project root (e.g., `models/my_model.model.lkml` or `views/my_view.view.lkml`).
1055+
- test (optional): The name of the test to run.
1056+
- model (optional): The name of the model to run tests for.
1057+
1058+
Output:
1059+
A JSON array containing the results of the executed tests, where each object includes:
1060+
- model_name: Name of the model tested.
1061+
- test_name: Name of the test.
1062+
- assertions_count: Total number of assertions in the test.
1063+
- assertions_failed: Number of assertions that failed.
1064+
- success: Boolean indicating if the test passed.
1065+
- errors: Array of error objects (if any), containing details like `message`, `file_path`, `line_number`, and `severity`.
1066+
- warnings: Array of warning messages (if any).
10441067
10451068
toolsets:
10461069
looker_tools:
@@ -1077,3 +1100,4 @@ toolsets:
10771100
- get_connection_databases
10781101
- get_connection_tables
10791102
- get_connection_table_columns
1103+
- run_lookml_tests
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
package lookerrunlookmltests
15+
16+
import (
17+
"context"
18+
"fmt"
19+
20+
yaml "github.com/goccy/go-yaml"
21+
"github.com/googleapis/genai-toolbox/internal/embeddingmodels"
22+
"github.com/googleapis/genai-toolbox/internal/sources"
23+
"github.com/googleapis/genai-toolbox/internal/tools"
24+
"github.com/googleapis/genai-toolbox/internal/util/parameters"
25+
26+
"github.com/looker-open-source/sdk-codegen/go/rtl"
27+
v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4"
28+
)
29+
30+
const resourceType string = "looker-run-lookml-tests"
31+
32+
func init() {
33+
if !tools.Register(resourceType, newConfig) {
34+
panic(fmt.Sprintf("tool type %q already registered", resourceType))
35+
}
36+
}
37+
38+
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
39+
actual := Config{Name: name}
40+
if err := decoder.DecodeContext(ctx, &actual); err != nil {
41+
return nil, err
42+
}
43+
return actual, nil
44+
}
45+
46+
type compatibleSource interface {
47+
UseClientAuthorization() bool
48+
GetAuthTokenHeaderName() string
49+
LookerApiSettings() *rtl.ApiSettings
50+
GetLookerSDK(string) (*v4.LookerSDK, error)
51+
}
52+
53+
type Config struct {
54+
Name string `yaml:"name" validate:"required"`
55+
Type string `yaml:"type" validate:"required"`
56+
Source string `yaml:"source" validate:"required"`
57+
Description string `yaml:"description" validate:"required"`
58+
AuthRequired []string `yaml:"authRequired"`
59+
Annotations *tools.ToolAnnotations `yaml:"annotations,omitempty"`
60+
}
61+
62+
// validate interface
63+
var _ tools.ToolConfig = Config{}
64+
65+
func (cfg Config) ToolConfigType() string {
66+
return resourceType
67+
}
68+
69+
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
70+
projectIdParameter := parameters.NewStringParameter("project_id", "The id of the project to run LookML tests for.")
71+
fileIdParameter := parameters.NewStringParameterWithRequired("file_id", "Optional id of the file to run tests for.", false)
72+
testParameter := parameters.NewStringParameterWithRequired("test", "Optional name of the test to run.", false)
73+
modelParameter := parameters.NewStringParameterWithRequired("model", "Optional name of the model to run tests for.", false)
74+
params := parameters.Parameters{projectIdParameter, fileIdParameter, testParameter, modelParameter}
75+
76+
annotations := cfg.Annotations
77+
if annotations == nil {
78+
readOnlyHint := true
79+
annotations = &tools.ToolAnnotations{
80+
ReadOnlyHint: &readOnlyHint,
81+
}
82+
}
83+
84+
mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, params, annotations)
85+
86+
// finish tool setup
87+
return Tool{
88+
Config: cfg,
89+
Parameters: params,
90+
manifest: tools.Manifest{
91+
Description: cfg.Description,
92+
Parameters: params.Manifest(),
93+
AuthRequired: cfg.AuthRequired,
94+
},
95+
mcpManifest: mcpManifest,
96+
}, nil
97+
}
98+
99+
// validate interface
100+
var _ tools.Tool = Tool{}
101+
102+
type Tool struct {
103+
Config
104+
Parameters parameters.Parameters `yaml:"parameters"`
105+
manifest tools.Manifest
106+
mcpManifest tools.McpManifest
107+
}
108+
109+
func (t Tool) ToConfig() tools.ToolConfig {
110+
return t.Config
111+
}
112+
113+
func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, params parameters.ParamValues, accessToken tools.AccessToken) (any, error) {
114+
source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type)
115+
if err != nil {
116+
return nil, err
117+
}
118+
119+
sdk, err := source.GetLookerSDK(string(accessToken))
120+
if err != nil {
121+
return nil, fmt.Errorf("error getting sdk: %w", err)
122+
}
123+
124+
mapParams := params.AsMap()
125+
projectId, ok := mapParams["project_id"].(string)
126+
if !ok {
127+
return nil, fmt.Errorf("'project_id' must be a string, got %T", mapParams["project_id"])
128+
}
129+
130+
var fileId *string
131+
if val, ok := mapParams["file_id"].(string); ok && val != "" {
132+
fileId = &val
133+
}
134+
135+
var test *string
136+
if val, ok := mapParams["test"].(string); ok && val != "" {
137+
test = &val
138+
}
139+
140+
var model *string
141+
if val, ok := mapParams["model"].(string); ok && val != "" {
142+
model = &val
143+
}
144+
145+
req := v4.RequestRunLookmlTest{
146+
ProjectId: projectId,
147+
FileId: fileId,
148+
Test: test,
149+
Model: model,
150+
}
151+
152+
resp, err := sdk.RunLookmlTest(req, source.LookerApiSettings())
153+
if err != nil {
154+
return nil, fmt.Errorf("error running lookml tests: %w", err)
155+
}
156+
157+
// Filter out pointer fields for better JSON marshaling in basic map if needed,
158+
// but the SDK struct usually has JSON tags.
159+
// Returning directly as it should marshal correctly.
160+
return resp, nil
161+
}
162+
163+
func (t Tool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) {
164+
return parameters.EmbedParams(ctx, t.Parameters, paramValues, embeddingModelsMap, nil)
165+
}
166+
167+
func (t Tool) Manifest() tools.Manifest {
168+
return t.manifest
169+
}
170+
171+
func (t Tool) McpManifest() tools.McpManifest {
172+
return t.mcpManifest
173+
}
174+
175+
func (t Tool) RequiresClientAuthorization(resourceMgr tools.SourceProvider) (bool, error) {
176+
source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type)
177+
if err != nil {
178+
return false, err
179+
}
180+
return source.UseClientAuthorization(), nil
181+
}
182+
183+
func (t Tool) Authorized(verifiedAuthServices []string) bool {
184+
return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices)
185+
}
186+
187+
func (t Tool) GetAuthTokenHeaderName(resourceMgr tools.SourceProvider) (string, error) {
188+
source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type)
189+
if err != nil {
190+
return "", err
191+
}
192+
return source.GetAuthTokenHeaderName(), nil
193+
}
194+
195+
func (t Tool) GetParameters() parameters.Parameters {
196+
return t.Parameters
197+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package lookerrunlookmltests_test
16+
17+
import (
18+
"strings"
19+
"testing"
20+
21+
"github.com/google/go-cmp/cmp"
22+
"github.com/googleapis/genai-toolbox/internal/server"
23+
"github.com/googleapis/genai-toolbox/internal/testutils"
24+
lkr "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerrunlookmltests"
25+
)
26+
27+
func TestParseFromYamlLookerRunLookmlTests(t *testing.T) {
28+
ctx, err := testutils.ContextWithNewLogger()
29+
if err != nil {
30+
t.Fatalf("unexpected error: %s", err)
31+
}
32+
tcs := []struct {
33+
desc string
34+
in string
35+
want server.ToolConfigs
36+
}{
37+
{
38+
desc: "basic example",
39+
in: `
40+
kind: tools
41+
name: example_tool
42+
type: looker-run-lookml-tests
43+
source: my-instance
44+
description: some description
45+
`,
46+
want: server.ToolConfigs{
47+
"example_tool": lkr.Config{
48+
Name: "example_tool",
49+
Type: "looker-run-lookml-tests",
50+
Source: "my-instance",
51+
Description: "some description",
52+
AuthRequired: []string{},
53+
},
54+
},
55+
},
56+
}
57+
for _, tc := range tcs {
58+
t.Run(tc.desc, func(t *testing.T) {
59+
// Parse contents
60+
_, _, _, got, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in))
61+
if err != nil {
62+
t.Fatalf("unable to unmarshal: %s", err)
63+
}
64+
if diff := cmp.Diff(tc.want, got); diff != "" {
65+
t.Fatalf("incorrect parse: diff %v", diff)
66+
}
67+
})
68+
}
69+
70+
}
71+
72+
func TestFailParseFromYamlLookerRunLookmlTests(t *testing.T) {
73+
ctx, err := testutils.ContextWithNewLogger()
74+
if err != nil {
75+
t.Fatalf("unexpected error: %s", err)
76+
}
77+
tcs := []struct {
78+
desc string
79+
in string
80+
err string
81+
}{
82+
{
83+
desc: "Invalid method",
84+
in: `
85+
kind: tools
86+
name: example_tool
87+
type: looker-run-lookml-tests
88+
source: my-instance
89+
method: GOT
90+
description: some description
91+
`,
92+
err: "error unmarshaling tools: unable to parse tool \"example_tool\" as type \"looker-run-lookml-tests\": [3:1] unknown field \"method\"",
93+
},
94+
}
95+
for _, tc := range tcs {
96+
t.Run(tc.desc, func(t *testing.T) {
97+
// Parse contents
98+
_, _, _, _, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in))
99+
if err == nil {
100+
t.Fatalf("expect parsing to fail")
101+
}
102+
errStr := err.Error()
103+
if !strings.Contains(errStr, tc.err) {
104+
t.Fatalf("unexpected error string: got %q, want substring %q", errStr, tc.err)
105+
}
106+
})
107+
}
108+
109+
}

0 commit comments

Comments
 (0)