Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions tests/cli/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 3 additions & 14 deletions tests/formatters/test_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
4 changes: 2 additions & 2 deletions tests/formatters/test_simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
36 changes: 19 additions & 17 deletions xrlint/cli/engine.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
from collections.abc import Generator
from collections.abc import Iterable
from typing import Iterator

import click
import fsspec
Expand Down Expand Up @@ -39,7 +40,7 @@
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
Expand All @@ -48,7 +49,7 @@
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:
Expand All @@ -74,6 +75,7 @@
for config_path in DEFAULT_CONFIG_FILES:
try:
config_list = read_config_list(config_path)
break
except FileNotFoundError:
pass
except ConfigError as e:
Expand All @@ -92,16 +94,15 @@

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(

Check warning on line 105 in xrlint/cli/engine.py

View check run for this annotation

Codecov / codecov/patch

xrlint/cli/engine.py#L105

Added line #L105 was not covered by tests
config=config,
file_path=file_path,
messages=[
Expand All @@ -111,11 +112,8 @@
)
],
)
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
)
Expand All @@ -130,19 +128,23 @@
# 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
formatter_op = formatter.op_class(**formatter_kwargs)
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):
Expand All @@ -156,7 +158,7 @@

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
Expand Down
10 changes: 7 additions & 3 deletions xrlint/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions xrlint/cli/stats.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions xrlint/formatter.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
"""
Expand Down
44 changes: 24 additions & 20 deletions xrlint/formatters/html.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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('<div role="results">\n')
text.append("<h3>Results</h3>\n")
for i, r in enumerate(results):
text.append('<div role="result">\n')
text.append(r.to_html())
text.append("</div>\n")
if i < n - 1:
text.append("<hr/>\n")
text.append("</div>\n")
results = list(results) # get them all

text_parts = [
'<div role="results">\n',
"<h3>Results</h3>\n",
]

for i, result in enumerate(results):
if i > 0:
text_parts.append("<hr/>\n")
text_parts.append('<div role="result">\n')
text_parts.append(result.to_html())
text_parts.append("</div>\n")
text_parts.append("</div>\n")

if self.with_meta:
rules_meta = get_rules_meta_for_results(results)
text.append('<div role="rules_meta">\n')
text.append("<h3>Rules</h3>\n")
text_parts.append('<div role="rules_meta">\n')
text_parts.append("<h3>Rules</h3>\n")
for rm in rules_meta.values():
text.append(
text_parts.append(
f"<p>Rule <strong>{rm.name}</strong>, version {rm.version}</p>\n"
)
if rm.description:
text.append(f"<p>{rm.description}</p>\n")
text_parts.append(f"<p>{rm.description}</p>\n")
if rm.docs_url:
text.append(
text_parts.append(
f'<p><a href="{rm.docs_url}">Rule documentation</a></p>\n'
)
text.append("</div>\n")
text_parts.append("</div>\n")

return "".join(text)
return "".join(text_parts)
5 changes: 4 additions & 1 deletion xrlint/formatters/json.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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": [
Expand Down
4 changes: 3 additions & 1 deletion xrlint/formatters/markdown.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
Loading
Loading