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

Add content validator #236

Merged
merged 33 commits into from
Jun 23, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
1298b6b
Initial dev for a content validator
joshtemple May 22, 2020
a770372
Make the workspace a dynamic property based on the branch name
joshtemple Jun 16, 2020
8d4cfc1
Add an incremental mode to the content validator
joshtemple Jun 16, 2020
4a9fbcc
Deduplicate errors returned
joshtemple Jun 16, 2020
30fedb0
Make error message more accurate
joshtemple Jun 16, 2020
23548ff
Add ability to exclude personal folder content
joshtemple Jun 16, 2020
248b005
Only return errors from the specified project
joshtemple Jun 16, 2020
92a277d
Add content validator to log duration text
joshtemple Jun 17, 2020
6377c7e
Add more descriptive logging message
joshtemple Jun 17, 2020
7095b59
Move explore counting to the Project
joshtemple Jun 17, 2020
d0ec9ed
Convert LookML error attribute to a list
joshtemple Jun 17, 2020
5967012
Remove queried logic from explores
joshtemple Jun 17, 2020
db43db8
Print validation results in CLI not validator
joshtemple Jun 17, 2020
3268559
Move printing to runner
joshtemple Jun 17, 2020
8e90a0f
Move log_duration to CLI
joshtemple Jun 17, 2020
0d4e3e0
Reset temp branches after restoring
joshtemple Jun 17, 2020
f7f241e
Dim help message
joshtemple Jun 17, 2020
f49527d
Overhaul content validator to use get_results
joshtemple Jun 17, 2020
a7f4a9a
Remove duplicate errors
joshtemple Jun 17, 2020
b5d55d9
Modify header message
joshtemple Jun 17, 2020
a591e34
Fix queried attribute and error references
joshtemple Jun 18, 2020
514a170
Fix failing tests
joshtemple Jun 18, 2020
b401630
Improve active branch error message
joshtemple Jun 18, 2020
cdb07af
Correct error messages around .error
joshtemple Jun 18, 2020
db1b391
Move validators into their own package and modules
joshtemple Jun 20, 2020
5a58a9a
Link to content validation docs
joshtemple Jun 20, 2020
702e27c
Add tests for the content validator
joshtemple Jun 20, 2020
39c96d1
Test case with missing selectors
joshtemple Jun 20, 2020
1d52909
Test and refactor incremental results
joshtemple Jun 20, 2020
07a80ab
Add basic expectations for error printing
joshtemple Jun 20, 2020
77075a4
Add more tests for CLI and LookML objects
joshtemple Jun 20, 2020
bad39d2
Add type hint for Explore
joshtemple Jun 22, 2020
07d71dd
Log content without an identified content type at WARN level
joshtemple Jun 22, 2020
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@

You can run the following validators as subcommands (e.g. `spectacles sql`):

- [**SQL** validation](https://docs.spectacles.dev/tutorials/validators#the-sql-validator) - tests for database errors or invalid SQL
- [**Assert** validation](https://docs.spectacles.dev/tutorials/validators#the-assert-validator) - runs [LookML/data tests](https://docs.looker.com/reference/model-params/test)
- **Content** validation _(coming soon)_
- [**SQL** validation](https://docs.spectacles.dev/tutorials/validators#the-sql-validator) - tests the `sql` field of each dimension for database errors
- [**Assert** validation](https://docs.spectacles.dev/tutorials/validators#the-assert-validator) - runs [Looker data tests](https://docs.looker.com/reference/model-params/test)
- [**Content** validation](https://docs.spectacles.dev/tutorials/validators#the-content-validator) - tests for errors in Looks and Dashboards
- **Linting** _(coming soon)_

## Documentation
Expand Down
115 changes: 112 additions & 3 deletions spectacles/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from spectacles.logger import GLOBAL_LOGGER as logger, set_file_handler
import spectacles.printer as printer
import spectacles.tracking as tracking
from spectacles.utils import log_duration


class ConfigFileAction(argparse.Action):
Expand Down Expand Up @@ -255,6 +256,23 @@ def main():
args.import_projects,
args.commit_ref,
)
elif args.command == "content":
run_content(
args.project,
args.branch,
args.explores,
args.exclude,
args.base_url,
args.client_id,
args.client_secret,
args.port,
args.api_version,
args.remote_reset,
args.import_projects,
args.commit_ref,
args.incremental,
args.exclude_personal,
)

if not args.do_not_track:
tracking.track_invocation_end(
Expand All @@ -281,6 +299,7 @@ def create_parser() -> argparse.ArgumentParser:
_build_connect_subparser(subparser_action, base_subparser)
_build_sql_subparser(subparser_action, base_subparser)
_build_assert_subparser(subparser_action, base_subparser)
_build_content_subparser(subparser_action, base_subparser)
return parser


Expand Down Expand Up @@ -517,13 +536,93 @@ def _build_assert_subparser(
_build_validator_subparser(subparser_action, subparser)


def _build_content_subparser(
subparser_action: argparse._SubParsersAction,
base_subparser: argparse.ArgumentParser,
) -> None:
subparser = subparser_action.add_parser(
"content", parents=[base_subparser], help="Run Looker content validation."
)

subparser.add_argument(
"--incremental",
action="store_true",
help="Only display errors which are not present on the master branch.",
)

subparser.add_argument(
"--exclude-personal",
action="store_true",
help="Exclude errors found in content in personal folders.",
)

_build_validator_subparser(subparser_action, subparser)


def run_connect(
base_url: str, client_id: str, client_secret: str, port: int, api_version: float
) -> None:
"""Tests the connection and credentials for the Looker API."""
LookerClient(base_url, client_id, client_secret, port, api_version)


@log_duration
def run_content(
project,
branch,
explores,
excludes,
base_url,
client_id,
client_secret,
port,
api_version,
remote_reset,
import_projects,
commit_ref,
incremental,
exclude_personal,
) -> None:
runner = Runner(
base_url,
project,
branch,
client_id,
client_secret,
port,
api_version,
remote_reset,
import_projects,
commit_ref,
)
results = runner.validate_content(explores, excludes, incremental, exclude_personal)

for test in sorted(results["tested"], key=lambda x: (x["model"], x["explore"])):
message = f"{test['model']}.{test['explore']}"
printer.print_validation_result(passed=test["passed"], source=message)

errors = sorted(
results["errors"],
key=lambda x: (x["model"], x["explore"], x["metadata"]["field_name"]),
)
if errors:
for error in errors:
printer.print_content_error(
error["model"],
error["explore"],
error["message"],
error["metadata"]["content_type"],
error["metadata"]["space"],
error["metadata"]["title"],
error["metadata"]["url"],
)
logger.info("")
raise GenericValidationError
else:
logger.info("")


@log_duration
def run_assert(
project,
branch,
Expand Down Expand Up @@ -571,6 +670,7 @@ def run_assert(
logger.info("")


@log_duration
def run_sql(
log_dir,
project,
Expand Down Expand Up @@ -608,6 +708,11 @@ def iter_errors(lookml: List) -> Iterable:
yield item

results = runner.validate_sql(explores, exclude, mode, concurrency)

for test in sorted(results["tested"], key=lambda x: (x["model"], x["explore"])):
message = f"{test['model']}.{test['explore']}"
printer.print_validation_result(passed=test["passed"], source=message)

errors = sorted(
results["errors"],
key=lambda x: (x["model"], x["explore"], x["metadata"].get("dimension")),
Expand All @@ -626,9 +731,13 @@ def iter_errors(lookml: List) -> Iterable:
)
if mode == "batch":
logger.info(
f"\n\nTo determine the exact dimensions responsible for {'this error' if len(errors) == 1 else 'these errors'}, "
f"you can re-run \nSpectacles in single-dimension mode, with `--mode single`.\n\n"
"You can also run this original validation with `--mode hybrid` to do this automatically."
printer.dim(
"\n\nTo determine the exact dimensions responsible for "
f"{'this error' if len(errors) == 1 else 'these errors'}, "
"you can re-run \nSpectacles in single-dimension mode, "
"with `--mode single`.\n\nYou can also run this original "
"validation with `--mode hybrid` to do this automatically."
)
)

logger.info("")
Expand Down
40 changes: 39 additions & 1 deletion spectacles/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ def get_active_branch(self, project: str) -> JsonDict:
status=response.status_code,
detail=(
f"Unable to get active branch for project '{project}'. "
"Please try again."
"Please check that the project exists and try again."
),
response=response,
)
Expand Down Expand Up @@ -718,3 +718,41 @@ def cancel_query_task(self, query_task_id: str):

# No raise_for_status() here because Looker API seems to give a 404
# if you try to cancel a finished query which can happen as part of cleanup

def content_validation(self) -> JsonDict:
logger.debug(f"Validating all content in Looker")
url = utils.compose_url(self.api_url, path=["content_validation"])
response = self.get(url=url, timeout=TIMEOUT_SEC)

try:
response.raise_for_status()
except requests.exceptions.HTTPError:
raise LookerApiError(
name="unable-to-validate-content",
title="Couldn't validate Looks and Dashboards.",
status=response.status_code,
detail=("Failed to run the content validator. Please try again."),
response=response,
)

result = response.json()
return result

def all_folders(self, project: str) -> List[JsonDict]:
logger.debug("Getting information about all folders")
url = utils.compose_url(self.api_url, path=["folders"])
response = self.get(url=url, timeout=TIMEOUT_SEC)

try:
response.raise_for_status()
except requests.exceptions.HTTPError:
raise LookerApiError(
name="unable-to-get-folders",
title="Couldn't obtain project folders.",
status=response.status_code,
detail=(f"Failed to get all folders for project '{project}'."),
response=response,
)

result = response.json()
return result
24 changes: 24 additions & 0 deletions spectacles/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,27 @@ def __init__(
super().__init__(
model=model, explore=explore, test=None, message=message, metadata=metadata
)


class ContentError(ValidationError):
def __init__(
self,
model: str,
explore: str,
message: str,
field_name: str,
content_type: str,
title: str,
space: str,
url: str,
):
metadata = {
"field_name": field_name,
"content_type": content_type,
"title": title,
"space": space,
"url": url,
}
super().__init__(
model=model, explore=explore, test=None, message=message, metadata=metadata
)
Loading