Repository: sqlfluff/sqlfluff

Issue:
Enable quiet mode/no-verbose in CLI for use in pre-commit hook
There seems to be only an option to increase the level of verbosity when using SQLFluff [CLI](https://docs.sqlfluff.com/en/stable/cli.html), not to limit it further.

It would be great to have an option to further limit the amount of prints when running `sqlfluff fix`, especially in combination with deployment using a pre-commit hook. For example, only print the return status and the number of fixes applied, similar to how it is when using `black` in a pre-commit hook:
![image](https://user-images.githubusercontent.com/10177212/140480676-dc98d00b-4383-44f2-bb90-3301a6eedec2.png)

This hides the potentially long list of fixes that are being applied to the SQL files, which can get quite verbose.


Discussion:

Patch:
diff --git a/src/sqlfluff/cli/commands.py b/src/sqlfluff/cli/commands.py
--- a/src/sqlfluff/cli/commands.py
+++ b/src/sqlfluff/cli/commands.py
@@ -44,6 +44,7 @@
     dialect_selector,
     dialect_readout,
 )
+from sqlfluff.core.linter import LintingResult
 from sqlfluff.core.config import progress_bar_configuration
 
 from sqlfluff.core.enums import FormatType, Color
@@ -691,12 +692,16 @@ def lint(
         sys.exit(EXIT_SUCCESS)
 
 
-def do_fixes(lnt, result, formatter=None, **kwargs):
+def do_fixes(
+    result: LintingResult, formatter: Optional[OutputStreamFormatter] = None, **kwargs
+):
     """Actually do the fixes."""
-    click.echo("Persisting Changes...")
+    if formatter and formatter.verbosity >= 0:
+        click.echo("Persisting Changes...")
     res = result.persist_changes(formatter=formatter, **kwargs)
     if all(res.values()):
-        click.echo("Done. Please check your files to confirm.")
+        if formatter and formatter.verbosity >= 0:
+            click.echo("Done. Please check your files to confirm.")
         return True
     # If some failed then return false
     click.echo(
@@ -708,7 +713,7 @@ def do_fixes(lnt, result, formatter=None, **kwargs):
     return False  # pragma: no cover
 
 
-def _stdin_fix(linter, formatter, fix_even_unparsable):
+def _stdin_fix(linter: Linter, formatter, fix_even_unparsable):
     """Handle fixing from stdin."""
     exit_code = EXIT_SUCCESS
     stdin = sys.stdin.read()
@@ -751,7 +756,7 @@ def _stdin_fix(linter, formatter, fix_even_unparsable):
 
 
 def _paths_fix(
-    linter,
+    linter: Linter,
     formatter,
     paths,
     processes,
@@ -765,11 +770,12 @@ def _paths_fix(
 ):
     """Handle fixing from paths."""
     # Lint the paths (not with the fix argument at this stage), outputting as we go.
-    click.echo("==== finding fixable violations ====")
+    if formatter.verbosity >= 0:
+        click.echo("==== finding fixable violations ====")
     exit_code = EXIT_SUCCESS
 
     with PathAndUserErrorHandler(formatter):
-        result = linter.lint_paths(
+        result: LintingResult = linter.lint_paths(
             paths,
             fix=True,
             ignore_non_existent_files=False,
@@ -781,20 +787,18 @@ def _paths_fix(
 
     # NB: We filter to linting violations here, because they're
     # the only ones which can be potentially fixed.
-    if result.num_violations(types=SQLLintError, fixable=True) > 0:
-        click.echo("==== fixing violations ====")
-        click.echo(
-            f"{result.num_violations(types=SQLLintError, fixable=True)} fixable "
-            "linting violations found"
-        )
+    num_fixable = result.num_violations(types=SQLLintError, fixable=True)
+    if num_fixable > 0:
+        if formatter.verbosity >= 0:
+            click.echo("==== fixing violations ====")
+        click.echo(f"{num_fixable} " "fixable linting violations found")
         if force:
-            if warn_force:
+            if warn_force and formatter.verbosity >= 0:
                 click.echo(
                     f"{formatter.colorize('FORCE MODE', Color.red)}: "
                     "Attempting fixes..."
                 )
             success = do_fixes(
-                linter,
                 result,
                 formatter,
                 types=SQLLintError,
@@ -809,9 +813,9 @@ def _paths_fix(
             c = click.getchar().lower()
             click.echo("...")
             if c in ("y", "\r", "\n"):
-                click.echo("Attempting fixes...")
+                if formatter.verbosity >= 0:
+                    click.echo("Attempting fixes...")
                 success = do_fixes(
-                    linter,
                     result,
                     formatter,
                     types=SQLLintError,
@@ -829,8 +833,9 @@ def _paths_fix(
                 click.echo("Aborting...")
                 exit_code = EXIT_FAIL
     else:
-        click.echo("==== no fixable linting violations found ====")
-        formatter.completion_message()
+        if formatter.verbosity >= 0:
+            click.echo("==== no fixable linting violations found ====")
+            formatter.completion_message()
 
     error_types = [
         (
@@ -841,7 +846,7 @@ def _paths_fix(
     ]
     for num_violations_kwargs, message_format, error_level in error_types:
         num_violations = result.num_violations(**num_violations_kwargs)
-        if num_violations > 0:
+        if num_violations > 0 and formatter.verbosity >= 0:
             click.echo(message_format.format(num_violations))
             exit_code = max(exit_code, error_level)
 
@@ -880,10 +885,20 @@ def _paths_fix(
     "--force",
     is_flag=True,
     help=(
-        "skip the confirmation prompt and go straight to applying "
+        "Skip the confirmation prompt and go straight to applying "
         "fixes. **Use this with caution.**"
     ),
 )
+@click.option(
+    "-q",
+    "--quiet",
+    is_flag=True,
+    help=(
+        "Reduces the amount of output to stdout to a minimal level. "
+        "This is effectively the opposite of -v. NOTE: It will only "
+        "take effect if -f/--force is also set."
+    ),
+)
 @click.option(
     "-x",
     "--fixed-suffix",
@@ -913,6 +928,7 @@ def fix(
     force: bool,
     paths: Tuple[str],
     bench: bool = False,
+    quiet: bool = False,
     fixed_suffix: str = "",
     logger: Optional[logging.Logger] = None,
     processes: Optional[int] = None,
@@ -932,6 +948,13 @@ def fix(
     """
     # some quick checks
     fixing_stdin = ("-",) == paths
+    if quiet:
+        if kwargs["verbose"]:
+            click.echo(
+                "ERROR: The --quiet flag can only be used if --verbose is not set.",
+            )
+            sys.exit(EXIT_ERROR)
+        kwargs["verbose"] = -1
 
     config = get_config(
         extra_config_path, ignore_local_config, require_dialect=False, **kwargs
diff --git a/src/sqlfluff/cli/formatters.py b/src/sqlfluff/cli/formatters.py
--- a/src/sqlfluff/cli/formatters.py
+++ b/src/sqlfluff/cli/formatters.py
@@ -94,7 +94,7 @@ def __init__(
     ):
         self._output_stream = output_stream
         self.plain_output = self.should_produce_plain_output(nocolor)
-        self._verbosity = verbosity
+        self.verbosity = verbosity
         self._filter_empty = filter_empty
         self.output_line_length = output_line_length
 
@@ -116,13 +116,13 @@ def _format_config(self, linter: Linter) -> str:
         """Format the config of a `Linter`."""
         text_buffer = StringIO()
         # Only show version information if verbosity is high enough
-        if self._verbosity > 0:
+        if self.verbosity > 0:
             text_buffer.write("==== sqlfluff ====\n")
             config_content = [
                 ("sqlfluff", get_package_version()),
                 ("python", get_python_version()),
                 ("implementation", get_python_implementation()),
-                ("verbosity", self._verbosity),
+                ("verbosity", self.verbosity),
             ]
             if linter.dialect:
                 config_content.append(("dialect", linter.dialect.name))
@@ -138,7 +138,7 @@ def _format_config(self, linter: Linter) -> str:
                         col_width=41,
                     )
                 )
-            if self._verbosity > 1:
+            if self.verbosity > 1:
                 text_buffer.write("\n== Raw Config:\n")
                 text_buffer.write(self.format_config_vals(linter.config.iter_vals()))
         return text_buffer.getvalue()
@@ -150,7 +150,7 @@ def dispatch_config(self, linter: Linter) -> None:
     def dispatch_persist_filename(self, filename, result):
         """Dispatch filenames during a persist operation."""
         # Only show the skip records at higher levels of verbosity
-        if self._verbosity >= 2 or result != "SKIP":
+        if self.verbosity >= 2 or result != "SKIP":
             self._dispatch(self.format_filename(filename=filename, success=result))
 
     def _format_path(self, path: str) -> str:
@@ -159,14 +159,14 @@ def _format_path(self, path: str) -> str:
 
     def dispatch_path(self, path: str) -> None:
         """Dispatch paths for display."""
-        if self._verbosity > 0:
+        if self.verbosity > 0:
             self._dispatch(self._format_path(path))
 
     def dispatch_template_header(
         self, fname: str, linter_config: FluffConfig, file_config: FluffConfig
     ) -> None:
         """Dispatch the header displayed before templating."""
-        if self._verbosity > 1:
+        if self.verbosity > 1:
             self._dispatch(self.format_filename(filename=fname, success="TEMPLATING"))
             # This is where we output config diffs if they exist.
             if file_config:
@@ -182,12 +182,12 @@ def dispatch_template_header(
 
     def dispatch_parse_header(self, fname: str) -> None:
         """Dispatch the header displayed before parsing."""
-        if self._verbosity > 1:
+        if self.verbosity > 1:
             self._dispatch(self.format_filename(filename=fname, success="PARSING"))
 
     def dispatch_lint_header(self, fname: str, rules: List[str]) -> None:
         """Dispatch the header displayed before linting."""
-        if self._verbosity > 1:
+        if self.verbosity > 1:
             self._dispatch(
                 self.format_filename(
                     filename=fname, success=f"LINTING ({', '.join(rules)})"
@@ -202,7 +202,7 @@ def dispatch_compilation_header(self, templater, message):
 
     def dispatch_processing_header(self, processes: int) -> None:
         """Dispatch the header displayed before linting."""
-        if self._verbosity > 0:
+        if self.verbosity > 0:
             self._dispatch(  # pragma: no cover
                 f"{self.colorize('effective configured processes: ', Color.lightgrey)} "
                 f"{processes}"
@@ -228,7 +228,7 @@ def _format_file_violations(
         show = fails + warns > 0
 
         # Only print the filename if it's either a failure or verbosity > 1
-        if self._verbosity > 0 or show:
+        if self.verbosity > 0 or show:
             text_buffer.write(self.format_filename(fname, success=fails == 0))
             text_buffer.write("\n")
 
@@ -253,6 +253,8 @@ def dispatch_file_violations(
         self, fname: str, linted_file: LintedFile, only_fixable: bool
     ) -> None:
         """Dispatch any violations found in a file."""
+        if self.verbosity < 0:
+            return
         s = self._format_file_violations(
             fname,
             linted_file.get_violations(
@@ -392,10 +394,13 @@ def format_filename(
         if isinstance(success, str):
             status_string = success
         else:
-            status_string = self.colorize(
-                success_text if success else "FAIL",
-                Color.green if success else Color.red,
-            )
+            status_string = success_text if success else "FAIL"
+
+        if status_string in ("PASS", "FIXED", success_text):
+            status_string = self.colorize(status_string, Color.green)
+        elif status_string in ("FAIL", "ERROR"):
+            status_string = self.colorize(status_string, Color.red)
+
         return f"== [{self.colorize(filename, Color.lightgrey)}] {status_string}"
 
     def format_violation(
diff --git a/src/sqlfluff/core/linter/linted_dir.py b/src/sqlfluff/core/linter/linted_dir.py
--- a/src/sqlfluff/core/linter/linted_dir.py
+++ b/src/sqlfluff/core/linter/linted_dir.py
@@ -117,7 +117,11 @@ def persist_changes(
         for file in self.files:
             if file.num_violations(fixable=True, **kwargs) > 0:
                 buffer[file.path] = file.persist_tree(suffix=fixed_file_suffix)
-                result = buffer[file.path]
+                result: Union[bool, str]
+                if buffer[file.path] is True:
+                    result = "FIXED"
+                else:  # pragma: no cover
+                    result = buffer[file.path]
             else:  # pragma: no cover TODO?
                 buffer[file.path] = True
                 result = "SKIP"


Test patch:
diff --git a/test/cli/commands_test.py b/test/cli/commands_test.py
--- a/test/cli/commands_test.py
+++ b/test/cli/commands_test.py
@@ -557,6 +557,18 @@ def test__cli__command_lint_parse(command):
             ),
             1,
         ),
+        # Test that setting --quiet with --verbose raises an error.
+        (
+            (
+                fix,
+                [
+                    "--quiet",
+                    "--verbose",
+                    "test/fixtures/cli/fail_many.sql",
+                ],
+            ),
+            2,
+        ),
     ],
 )
 def test__cli__command_lint_parse_with_retcode(command, ret_code):
@@ -1891,7 +1903,7 @@ def test_cli_fix_disabled_progress_bar_deprecated_option(
 
 
 def test__cli__fix_multiple_errors_no_show_errors():
-    """Basic checking of lint functionality."""
+    """Test the fix output."""
     result = invoke_assert_code(
         ret_code=1,
         args=[
@@ -1910,8 +1922,57 @@ def test__cli__fix_multiple_errors_no_show_errors():
     assert result.output.replace("\\", "/").startswith(multiple_expected_output)
 
 
+def test__cli__fix_multiple_errors_quiet_force():
+    """Test the fix --quiet option with --force."""
+    result = invoke_assert_code(
+        ret_code=0,
+        args=[
+            fix,
+            [
+                "--disable-progress-bar",
+                "test/fixtures/linter/multiple_sql_errors.sql",
+                "--force",
+                "--quiet",
+                "-x",
+                "_fix",
+            ],
+        ],
+    )
+    normalised_output = result.output.replace("\\", "/")
+    assert normalised_output.startswith(
+        """1 fixable linting violations found
+== [test/fixtures/linter/multiple_sql_errors.sql] FIXED"""
+    )
+
+
+def test__cli__fix_multiple_errors_quiet_no_force():
+    """Test the fix --quiet option without --force."""
+    result = invoke_assert_code(
+        ret_code=0,
+        args=[
+            fix,
+            [
+                "--disable-progress-bar",
+                "test/fixtures/linter/multiple_sql_errors.sql",
+                "--quiet",
+                "-x",
+                "_fix",
+            ],
+            # Test with the confirmation step.
+            "y",
+        ],
+    )
+    normalised_output = result.output.replace("\\", "/")
+    assert normalised_output.startswith(
+        """1 fixable linting violations found
+Are you sure you wish to attempt to fix these? [Y/n] ...
+== [test/fixtures/linter/multiple_sql_errors.sql] FIXED
+All Finished"""
+    )
+
+
 def test__cli__fix_multiple_errors_show_errors():
-    """Basic checking of lint functionality."""
+    """Test the fix --show-lint-violations option."""
     result = invoke_assert_code(
         ret_code=1,
         args=[