diff --git a/CHANGES.md b/CHANGES.md index 6ff46b2..fcf7e1e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,7 +2,8 @@ ## Version 0.1.0 (in development) -- ... +- XRLint CLI now outputs single results immediately to console, + instead only after all results have been collected. ## Early development snapshots diff --git a/docs/todo.md b/docs/todo.md index 83cad0e..6ebcd19 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -11,14 +11,15 @@ ## Desired - project logo +- if configuration for given FILE is empty, + report an error, see TODO in CLI main tests +- rename `xrlint.cli.CliEngine` into `xrlint.cli.XRLint` + (with similar API as the `ESLint` class) and export it + from `xrlint.all`. Value of `FILES` should be passed to + `verify_datasets()` methods. - use `RuleMeta.docs_url` in formatters to create links - implement xarray backend for xcube 'levels' format so can validate them too -- CLI should output result for file immediately, - not only after all results have been collected -- rename `xrlint.cli.CliEngine` into `xrlint.cli.XRLint` - (with similar API as the `ESLint` class) and export it - from `xrlint.all` - add some more tests so we reach 99% coverage - support rule op args/kwargs schema validation - support CLI option `--print-config FILE`, see ESLint diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index b43ca5a..1e24732 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -11,6 +11,14 @@ from xrlint.version import version from .helpers import text_file +no_match_config_yaml = """ +- ignores: ['**/*.nc', '**/*.zarr'] + rules: + dataset-title-attr: error +""" + +no_match_config_yaml = "[]" + # noinspection PyTypeChecker class CliMainTest(TestCase): @@ -179,6 +187,14 @@ def test_files_with_format_option(self): self.assertIn('"results": [\n', result.output) self.assertEqual(0, result.exit_code) + def test_file_does_not_match(self): + with text_file(DEFAULT_CONFIG_FILE_YAML, no_match_config_yaml): + runner = CliRunner() + result = runner.invoke(main, ["test.zarr"]) + # TODO: make this assertion work + # self.assertIn("No configuration matches this file.", result.output) + self.assertEqual(1, result.exit_code) + def test_files_with_invalid_format_option(self): runner = CliRunner() result = runner.invoke(main, ["-f", "foo"] + self.files) diff --git a/tests/formatters/test_markdown.py b/tests/formatters/test_markdown.py index 607644a..5efd8f8 100644 --- a/tests/formatters/test_markdown.py +++ b/tests/formatters/test_markdown.py @@ -2,28 +2,17 @@ import pytest -from xrlint.config import Config from xrlint.formatter import FormatterContext from xrlint.formatters.markdown import Markdown -from xrlint.result import Message -from xrlint.result import Result +from .helpers import get_test_results class MarkdownTest(TestCase): + # noinspection PyMethodMayBeStatic def test_markdown(self): formatter = Markdown() with pytest.raises(NotImplementedError): formatter.format( context=FormatterContext(), - results=[ - Result.new( - Config(), - file_path="test.nc", - messages=[ - Message(message="what", rule_id="rule-1", severity=2), - Message(message="is", fatal=True), - Message(message="happening?", rule_id="rule-2", severity=1), - ], - ) - ], + results=get_test_results(), ) diff --git a/tests/formatters/test_simple.py b/tests/formatters/test_simple.py index 5aeea22..d67a7f8 100644 --- a/tests/formatters/test_simple.py +++ b/tests/formatters/test_simple.py @@ -21,7 +21,7 @@ class SimpleTest(TestCase): ] def test_no_color(self): - formatter = Simple(color_enabled=False) + formatter = Simple(styled=False) text = formatter.format( context=FormatterContext(), results=self.results, @@ -30,7 +30,7 @@ def test_no_color(self): self.assertNotIn("\033]", text) def test_color(self): - formatter = Simple(color_enabled=True) + formatter = Simple(styled=True) text = formatter.format( context=FormatterContext(), results=self.results, diff --git a/xrlint/cli/engine.py b/xrlint/cli/engine.py index 8d3c4a0..d281d33 100644 --- a/xrlint/cli/engine.py +++ b/xrlint/cli/engine.py @@ -1,5 +1,6 @@ import os -from collections.abc import Generator +from collections.abc import Iterable +from typing import Iterator import click import fsspec @@ -39,7 +40,7 @@ def __init__( rule_specs: tuple[str, ...] = (), output_format: str = DEFAULT_OUTPUT_FORMAT, output_path: str | None = None, - color_enabled: bool = True, + styled: bool = True, files: tuple[str, ...] = (), ): self.no_default_config = no_default_config @@ -48,7 +49,7 @@ def __init__( self.rule_specs = rule_specs self.output_format = output_format self.output_path = output_path - self.color_enabled = color_enabled + self.styled = styled self.files = files def load_config(self) -> ConfigList: @@ -74,6 +75,7 @@ def load_config(self) -> ConfigList: for config_path in DEFAULT_CONFIG_FILES: try: config_list = read_config_list(config_path) + break except FileNotFoundError: pass except ConfigError as e: @@ -92,16 +94,15 @@ def load_config(self) -> ConfigList: return ConfigList.from_value(configs) - def verify_datasets(self, config_list: ConfigList) -> list[Result]: + def verify_datasets(self, config_list: ConfigList) -> Iterator[Result]: global_filter = config_list.get_global_filter(default=DEFAULT_GLOBAL_FILTER) - results: list[Result] = [] + linter = Linter() for file_path, is_dir in get_files(self.files, global_filter): config = config_list.compute_config(file_path) if config is not None: - linter = Linter(config=config) - result = linter.verify_dataset(file_path) + yield linter.verify_dataset(file_path, config=config) else: - result = Result.new( + yield Result.new( config=config, file_path=file_path, messages=[ @@ -111,11 +112,8 @@ def verify_datasets(self, config_list: ConfigList) -> list[Result]: ) ], ) - results.append(result) - return results - - def format_results(self, results: list[Result]) -> str: + def format_results(self, results: Iterable[Result]) -> str: output_format = ( self.output_format if self.output_format else DEFAULT_OUTPUT_FORMAT ) @@ -130,7 +128,10 @@ def format_results(self, results: list[Result]) -> str: # TODO: pass and validate format-specific args/kwargs # against formatter.meta.schema if output_format == "simple": - formatter_kwargs = {"color_enabled": self.color_enabled} + formatter_kwargs = { + "styled": self.styled and self.output_path is None, + "output": self.output_path is None, + } else: formatter_kwargs = {} # noinspection PyArgumentList @@ -138,11 +139,12 @@ def format_results(self, results: list[Result]) -> str: return formatter_op.format(FormatterContext(False), results) def write_report(self, report: str): - if not self.output_path: - print(report) - else: + if self.output_path: with fsspec.open(self.output_path, mode="w") as f: f.write(report) + elif self.output_format != "simple": + # The simple formatters outputs incrementally to console + print(report) @classmethod def init_config_file(cls): @@ -156,7 +158,7 @@ def init_config_file(cls): def get_files( file_paths: tuple[str, ...], global_filter: FileFilter -) -> Generator[tuple[str, bool | None]]: +) -> Iterator[tuple[str, bool | None]]: for file_path in file_paths: _fs, root = fsspec.url_to_fs(file_path) fs: fsspec.AbstractFileSystem = _fs diff --git a/xrlint/cli/main.py b/xrlint/cli/main.py index 32b4810..e35ff15 100644 --- a/xrlint/cli/main.py +++ b/xrlint/cli/main.py @@ -2,6 +2,8 @@ import click +from xrlint.cli.stats import Stats + # Warning: do not import heavy stuff here, it can # slow down commands like "xrlint --help" otherwise. from xrlint.version import version @@ -129,16 +131,18 @@ def main( files=files, output_format=output_format, output_path=output_file, - color_enabled=color_enabled, + styled=color_enabled, ) + stats = Stats() config_list = cli_engine.load_config() results = cli_engine.verify_datasets(config_list) + results = stats.collect(results) report = cli_engine.format_results(results) cli_engine.write_report(report) - error_status = sum(r.error_count for r in results) > 0 - max_warn_status = sum(r.warning_count for r in results) > max_warnings + error_status = stats.error_count > 0 + max_warn_status = stats.warning_count > max_warnings if max_warn_status and not error_status: click.echo("maximum number of warnings exceeded.") if max_warn_status or error_status: diff --git a/xrlint/cli/stats.py b/xrlint/cli/stats.py new file mode 100644 index 0000000..0472d8e --- /dev/null +++ b/xrlint/cli/stats.py @@ -0,0 +1,19 @@ +from collections.abc import Iterable +from dataclasses import dataclass + +from xrlint.result import Result + + +@dataclass() +class Stats: + """Utility to collect simple statistics from results.""" + + error_count: int = 0 + warning_count: int = 0 + + def collect(self, results: Iterable[Result]) -> Iterable[Result]: + """Collect statistics from `results`.""" + for result in results: + self.error_count += result.error_count + self.warning_count += result.warning_count + yield result diff --git a/xrlint/formatter.py b/xrlint/formatter.py index 3321f6b..4052ceb 100644 --- a/xrlint/formatter.py +++ b/xrlint/formatter.py @@ -1,5 +1,5 @@ from abc import abstractmethod, ABC -from collections.abc import Mapping +from collections.abc import Mapping, Iterable from dataclasses import dataclass from typing import Any, Callable, Type @@ -22,13 +22,13 @@ class FormatterOp(ABC): def format( self, context: FormatterContext, - results: list[Result], + results: Iterable[Result], ) -> str: """Format the given results. Args: context: formatting context - results: the results to format + results: an iterable of results to format Returns: A text representing the results in a given format """ diff --git a/xrlint/formatters/html.py b/xrlint/formatters/html.py index 669f7fb..02110d1 100644 --- a/xrlint/formatters/html.py +++ b/xrlint/formatters/html.py @@ -1,3 +1,5 @@ +from collections.abc import Iterable + from xrlint.formatter import FormatterOp, FormatterContext from xrlint.formatters import registry from xrlint.result import Result, get_rules_meta_for_results @@ -22,35 +24,37 @@ def __init__(self, with_meta: bool = False): def format( self, context: FormatterContext, - results: list[Result], + results: Iterable[Result], ) -> str: - text = [] - n = len(results) - - text.append('
\n') - text.append("

Results

\n") - for i, r in enumerate(results): - text.append('
\n') - text.append(r.to_html()) - text.append("
\n") - if i < n - 1: - text.append("
\n") - text.append("
\n") + results = list(results) # get them all + + text_parts = [ + '
\n', + "

Results

\n", + ] + + for i, result in enumerate(results): + if i > 0: + text_parts.append("
\n") + text_parts.append('
\n') + text_parts.append(result.to_html()) + text_parts.append("
\n") + text_parts.append("
\n") if self.with_meta: rules_meta = get_rules_meta_for_results(results) - text.append('
\n') - text.append("

Rules

\n") + text_parts.append('
\n') + text_parts.append("

Rules

\n") for rm in rules_meta.values(): - text.append( + text_parts.append( f"

Rule {rm.name}, version {rm.version}

\n" ) if rm.description: - text.append(f"

{rm.description}

\n") + text_parts.append(f"

{rm.description}

\n") if rm.docs_url: - text.append( + text_parts.append( f'

Rule documentation

\n' ) - text.append("
\n") + text_parts.append("
\n") - return "".join(text) + return "".join(text_parts) diff --git a/xrlint/formatters/json.py b/xrlint/formatters/json.py index fc66df7..ebd5bc9 100644 --- a/xrlint/formatters/json.py +++ b/xrlint/formatters/json.py @@ -1,4 +1,5 @@ import json +from collections.abc import Iterable from xrlint.formatter import FormatterOp, FormatterContext from xrlint.result import Result, get_rules_meta_for_results @@ -27,8 +28,10 @@ def __init__(self, indent: int = 2, with_meta: bool = False): def format( self, context: FormatterContext, - results: list[Result], + results: Iterable[Result], ) -> str: + results = list(results) # get them all + omitted_props = {"config"} results_json = { "results": [ diff --git a/xrlint/formatters/markdown.py b/xrlint/formatters/markdown.py index 71c570e..f1a7a46 100644 --- a/xrlint/formatters/markdown.py +++ b/xrlint/formatters/markdown.py @@ -1,3 +1,5 @@ +from collections.abc import Iterable + from xrlint.formatter import FormatterOp, FormatterContext from xrlint.formatters import registry from xrlint.result import Result @@ -9,7 +11,7 @@ class Markdown(FormatterOp): def format( self, context: FormatterContext, - results: list[Result], + results: Iterable[Result], ) -> str: # TODO: implement "markdown" format raise NotImplementedError() diff --git a/xrlint/formatters/simple.py b/xrlint/formatters/simple.py index 24e8323..7c59461 100644 --- a/xrlint/formatters/simple.py +++ b/xrlint/formatters/simple.py @@ -1,3 +1,5 @@ +from collections.abc import Iterable + from xrlint.constants import SEVERITY_CODE_TO_NAME from xrlint.formatter import FormatterOp, FormatterContext from xrlint.formatters import registry @@ -6,64 +8,101 @@ from tabulate import tabulate +from xrlint.util.schema import schema + SEVERITY_CODE_TO_COLOR = {2: "red", 1: "blue", 0: "green", None: ""} RULE_REF_URL = "https://bcdev.github.io/xrlint/rule-ref/" -@registry.define_formatter("simple", version="1.0.0") +@registry.define_formatter( + "simple", + version="1.0.0", + schema=schema( + "object", + properties=dict( + styled=schema("boolean", default=True), + output=schema("boolean", default=True), + ), + ), +) class Simple(FormatterOp): + """Simple output formatter. + Produces either ANSI-styled (default) or plain text reports. + It incrementally outputs results to console (stdout) by default. + """ - def __init__(self, color_enabled: bool = False): - self.color_enabled = color_enabled + def __init__(self, styled: bool = True, output: bool = True): + self.styled = styled + self.output = output def format( self, context: FormatterContext, - results: list[Result], + results: Iterable[Result], ) -> str: - text = [] + text_parts = [] + error_count = 0 warning_count = 0 - for r in results: - file_text = r.file_path - if self.color_enabled: - file_text = format_styled(file_text, s="underline") - if not r.messages: - text.append(f"\n{file_text} - ok\n") - else: - text.append(f"\n{file_text}:\n") - r_data = [] - for m in r.messages: - node_text = m.node_path or "" - severity_text = SEVERITY_CODE_TO_NAME.get(m.severity, "?") - message_text = m.message or "" - rule_text = m.rule_id or "" - if self.color_enabled: - if node_text: - node_text = format_styled(node_text, s="dim") - if severity_text: - fg = SEVERITY_CODE_TO_COLOR.get(m.severity, "") - severity_text = format_styled( - severity_text, s="bold", fg=fg - ) - if rule_text: - # TODO: get actual URL from metadata of the rule's plugin - href = f"{RULE_REF_URL}#{rule_text}" - rule_text = format_styled(m.rule_id, fg="blue", href=href) - r_data.append( - [ - node_text, - severity_text, - message_text, - rule_text, - ] - ) - text.append(tabulate(r_data, headers=(), tablefmt="plain")) - text.append("\n") - error_count += r.error_count - warning_count += r.warning_count + for result in results: + result_text = self.format_result(result) + if self.output: + print(result_text, flush=True, end="") + text_parts.append(result_text) + error_count += result.error_count + warning_count += result.warning_count + + summary_text = self.format_summary(error_count, warning_count) + if self.output: + print(summary_text, flush=True, end="") + text_parts.append(summary_text) + + return "".join(text_parts) + + def format_result( + self, + result: Result, + ) -> str: + file_path_text = result.file_path + if self.styled: + file_path_text = format_styled(file_path_text, s="underline") + if not result.messages: + return f"\n{file_path_text} - ok\n" + + result_parts = [f"\n{file_path_text}:\n"] + result_data = [] + for message in result.messages: + node_text = message.node_path or "" + severity_text = SEVERITY_CODE_TO_NAME.get(message.severity, "?") + message_text = message.message or "" + rule_text = message.rule_id or "" + if self.styled: + if node_text: + node_text = format_styled(node_text, s="dim") + if severity_text: + fg = SEVERITY_CODE_TO_COLOR.get(message.severity, "") + severity_text = format_styled(severity_text, s="bold", fg=fg) + if rule_text: + # TODO: get actual URL from metadata of the rule's plugin + href = f"{RULE_REF_URL}#{rule_text}" + rule_text = format_styled(message.rule_id, fg="blue", href=href) + result_data.append( + [ + node_text, + severity_text, + message_text, + rule_text, + ] + ) + + result_parts.append(tabulate(result_data, headers=(), tablefmt="plain")) + result_parts.append("\n") + return "".join(result_parts) + + def format_summary(self, error_count, warning_count) -> str: + summary_parts = [] problems_text = format_problems(error_count, warning_count) - if self.color_enabled: + if self.styled: if error_count: problems_text = format_styled( problems_text, fg=SEVERITY_CODE_TO_COLOR[2] @@ -72,7 +111,7 @@ def format( problems_text = format_styled( problems_text, fg=SEVERITY_CODE_TO_COLOR[1] ) - text.append("\n") - text.append(problems_text) - text.append("\n") - return "".join(text) + summary_parts.append("\n") + summary_parts.append(problems_text) + summary_parts.append("\n\n") + return "".join(summary_parts)