diff --git a/spectacles/cli.py b/spectacles/cli.py index 8b657b64..b37f36fc 100644 --- a/spectacles/cli.py +++ b/spectacles/cli.py @@ -194,6 +194,7 @@ def main(): args.project, args.branch, args.explores, + args.exclude, args.base_url, args.client_id, args.client_secret, @@ -371,6 +372,15 @@ def _build_sql_subparser( The '*' wildcard selects all models or explores. For instance,\ 'model_name/*' would select all explores in the 'model_name' model.", ) + subparser.add_argument( + "--exclude", + nargs="+", + default=[], + help="Specify the explores spectacles should exclude when testing. \ + List of selector strings in 'model_name/explore_name' format. \ + The '*' wildcard excludes all models or explores. For instance,\ + 'model_name/*' would select all explores in the 'model_name' model.", + ) subparser.add_argument( "--mode", choices=["batch", "single", "hybrid"], @@ -464,6 +474,7 @@ def run_sql( project, branch, explores, + exclude, base_url, client_id, client_secret, diff --git a/spectacles/client.py b/spectacles/client.py index 7eb4f492..ee130138 100644 --- a/spectacles/client.py +++ b/spectacles/client.py @@ -370,6 +370,7 @@ def create_query_task(self, query_id: int) -> str: logger.debug("Starting query %d", query_id) body = {"query_id": query_id, "result_format": "json_detail"} url = utils.compose_url(self.api_url, path=["query_tasks"]) + response = self.session.post( url=url, json=body, params={"cache": "false"}, timeout=TIMEOUT_SEC ) diff --git a/spectacles/runner.py b/spectacles/runner.py index 95468ef8..d75a0936 100644 --- a/spectacles/runner.py +++ b/spectacles/runner.py @@ -42,10 +42,14 @@ def __init__( @log_duration def validate_sql( - self, selectors: List[str], mode: str = "batch", concurrency: int = 10 + self, + selectors: List[str], + exclusions: List[str], + mode: str = "batch", + concurrency: int = 10, ) -> Project: sql_validator = SqlValidator(self.client, self.project, concurrency) - sql_validator.build_project(selectors) + sql_validator.build_project(selectors, exclusions) project = sql_validator.validate(mode) return project diff --git a/spectacles/validators.py b/spectacles/validators.py index 9978e76b..5d3a2d5f 100644 --- a/spectacles/validators.py +++ b/spectacles/validators.py @@ -163,7 +163,7 @@ def _select(self, choices: Sequence[str], select_from: Sequence) -> Sequence: ) return [each for each in select_from if each.name in unique_choices] - def build_project(self, selectors: List[str]) -> None: + def build_project(self, selectors: List[str], exclusions: List[str]) -> None: """Creates an object representation of the project's LookML. Args: @@ -173,6 +173,7 @@ def build_project(self, selectors: List[str]) -> None: """ selection = self.parse_selectors(selectors) + exclusion = self.parse_selectors(exclusions) logger.info( f"Building LookML project hierarchy for project {self.project.name}" ) @@ -194,9 +195,25 @@ def build_project(self, selectors: List[str]) -> None: selected_models = self._select( choices=tuple(selection.keys()), select_from=project_models ) + excluded_models = self._select( + choices=tuple(exclusion.keys()), select_from=project_models + ) - for model in selected_models: + excluded_explores = {} + for model in excluded_models: # Expand wildcard operator to include all specified or discovered explores + excluded_explore_names = exclusion[model.name] + if "*" in excluded_explore_names: + excluded_explore_names.remove("*") + excluded_explore_names.update( + set(explore.name for explore in model.explores) + ) + + excluded_explores[model.name] = self._select( + choices=tuple(excluded_explore_names), select_from=model.explores + ) + + for model in selected_models: selected_explore_names = selection[model.name] if "*" in selected_explore_names: selected_explore_names.remove("*") @@ -207,6 +224,12 @@ def build_project(self, selectors: List[str]) -> None: selected_explores = self._select( choices=tuple(selected_explore_names), select_from=model.explores ) + if model.name in excluded_explores: + selected_explores = [ + explore + for explore in selected_explores + if explore not in excluded_explores[model.name] + ] for explore in selected_explores: dimensions_json = self.client.get_lookml_dimensions( @@ -220,7 +243,9 @@ def build_project(self, selectors: List[str]) -> None: model.explores = selected_explores - self.project.models = selected_models + self.project.models = [ + model for model in selected_models if len(model.explores) > 0 + ] def validate(self, mode: str = "batch") -> Project: """Queries selected explores and returns the project tree with errors.""" diff --git a/tests/resources/response_models.json b/tests/resources/response_models.json index d8cda894..a81df5e2 100644 --- a/tests/resources/response_models.json +++ b/tests/resources/response_models.json @@ -28,6 +28,14 @@ }, { "explores": [ + { + "description": null, + "label": "Test Explore One", + "hidden": false, + "group_label": "Test Explore One", + "name": "test_explore_one", + "can": {} + }, { "description": null, "label": "Test Explore Two", diff --git a/tests/test_sql_validator.py b/tests/test_sql_validator.py index 1cb2aecf..0e248165 100644 --- a/tests/test_sql_validator.py +++ b/tests/test_sql_validator.py @@ -55,7 +55,10 @@ def project(): ), ] explores_model_one = [Explore("test_explore_one", dimensions)] - explores_model_two = [Explore("test_explore_two", dimensions)] + explores_model_two = [ + Explore("test_explore_one", dimensions), + Explore("test_explore_two", dimensions), + ] models = [ Model("test_model_one", "test_project", explores_model_one), Model("test_model.two", "test_project", explores_model_two), @@ -92,7 +95,106 @@ def test_parse_selectors_bad_format_raises_error(): def test_build_project(mock_get_models, mock_get_dimensions, project, validator): mock_get_models.return_value = load("response_models.json") mock_get_dimensions.return_value = load("response_dimensions.json") - validator.build_project(selectors=["*/*"]) + validator.build_project(selectors=["*/*"], exclusions=[]) + assert validator.project == project + + +@patch("spectacles.client.LookerClient.get_lookml_dimensions") +@patch("spectacles.client.LookerClient.get_lookml_models") +def test_build_project_all_models_excluded( + mock_get_models, mock_get_dimensions, project, validator +): + mock_get_models.return_value = load("response_models.json") + mock_get_dimensions.return_value = load("response_dimensions.json") + validator.build_project( + selectors=["*/*"], exclusions=["test_model_one/*", "test_model.two/*"] + ) + project.models = [] + assert validator.project == project + + +@patch("spectacles.client.LookerClient.get_lookml_dimensions") +@patch("spectacles.client.LookerClient.get_lookml_models") +def test_build_project_one_model_excluded( + mock_get_models, mock_get_dimensions, project, validator +): + mock_get_models.return_value = load("response_models.json") + mock_get_dimensions.return_value = load("response_dimensions.json") + validator.build_project(selectors=["*/*"], exclusions=["test_model_one/*"]) + project.models = [ + model for model in project.models if model.name != "test_model_one" + ] + assert validator.project == project + + +@patch("spectacles.client.LookerClient.get_lookml_dimensions") +@patch("spectacles.client.LookerClient.get_lookml_models") +def test_build_project_one_model_selected( + mock_get_models, mock_get_dimensions, project, validator +): + mock_get_models.return_value = load("response_models.json") + mock_get_dimensions.return_value = load("response_dimensions.json") + validator.build_project(selectors=["test_model.two/*"], exclusions=[]) + project.models = [ + model for model in project.models if model.name == "test_model.two" + ] + assert validator.project == project + + +@patch("spectacles.client.LookerClient.get_lookml_dimensions") +@patch("spectacles.client.LookerClient.get_lookml_models") +def test_build_project_one_explore_excluded( + mock_get_models, mock_get_dimensions, project, validator +): + mock_get_models.return_value = load("response_models.json") + mock_get_dimensions.return_value = load("response_dimensions.json") + validator.build_project( + selectors=["*/*"], exclusions=["test_model_one/test_explore_one"] + ) + project.models = [ + model for model in project.models if model.name != "test_model_one" + ] + assert validator.project == project + + +@patch("spectacles.client.LookerClient.get_lookml_dimensions") +@patch("spectacles.client.LookerClient.get_lookml_models") +def test_build_project_one_explore_selected( + mock_get_models, mock_get_dimensions, project, validator +): + mock_get_models.return_value = load("response_models.json") + mock_get_dimensions.return_value = load("response_dimensions.json") + validator.build_project( + selectors=["test_model.two/test_explore_two"], exclusions=[] + ) + project.models = [ + model for model in project.models if model.name == "test_model.two" + ] + project.models[0].explores = [ + explore + for explore in project.models[0].explores + if explore.name == "test_explore_two" + ] + assert validator.project == project + + +@patch("spectacles.client.LookerClient.get_lookml_dimensions") +@patch("spectacles.client.LookerClient.get_lookml_models") +def test_build_project_one_ambiguous_explore_excluded( + mock_get_models, mock_get_dimensions, project, validator +): + mock_get_models.return_value = load("response_models.json") + mock_get_dimensions.return_value = load("response_dimensions.json") + validator.build_project( + selectors=["*/*"], exclusions=["test_model.two/test_explore_one"] + ) + for model in project.models: + if model.name == "test_model.two": + model.explores = [ + explore + for explore in model.explores + if explore.name != "test_explore_one" + ] assert validator.project == project @@ -203,11 +305,11 @@ def test_handle_running_query(validator): def test_count_explores(validator, project): validator.project = project - assert validator._count_explores() == 2 + assert validator._count_explores() == 3 explore = validator.project.models[0].explores[0] validator.project.models[0].explores.extend([explore, explore]) - assert validator._count_explores() == 4 + assert validator._count_explores() == 5 def test_extract_error_details_error_dict(validator):