Skip to content

Commit 8845c39

Browse files
authored
Merge pull request #12 from bcdev/forman-simple_format_outputs_immediately
"simple" formatter outputs immediately
2 parents 2ccfc58 + 8079df5 commit 8845c39

File tree

13 files changed

+195
-115
lines changed

13 files changed

+195
-115
lines changed

CHANGES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
## Version 0.1.0 (in development)
44

5-
- ...
5+
- XRLint CLI now outputs single results immediately to console,
6+
instead only after all results have been collected.
67

78

89
## Early development snapshots

docs/todo.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@
1111
## Desired
1212

1313
- project logo
14+
- if configuration for given FILE is empty,
15+
report an error, see TODO in CLI main tests
16+
- rename `xrlint.cli.CliEngine` into `xrlint.cli.XRLint`
17+
(with similar API as the `ESLint` class) and export it
18+
from `xrlint.all`. Value of `FILES` should be passed to
19+
`verify_datasets()` methods.
1420
- use `RuleMeta.docs_url` in formatters to create links
1521
- implement xarray backend for xcube 'levels' format
1622
so can validate them too
17-
- CLI should output result for file immediately,
18-
not only after all results have been collected
19-
- rename `xrlint.cli.CliEngine` into `xrlint.cli.XRLint`
20-
(with similar API as the `ESLint` class) and export it
21-
from `xrlint.all`
2223
- add some more tests so we reach 99% coverage
2324
- support rule op args/kwargs schema validation
2425
- support CLI option `--print-config FILE`, see ESLint

tests/cli/test_main.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@
1111
from xrlint.version import version
1212
from .helpers import text_file
1313

14+
no_match_config_yaml = """
15+
- ignores: ['**/*.nc', '**/*.zarr']
16+
rules:
17+
dataset-title-attr: error
18+
"""
19+
20+
no_match_config_yaml = "[]"
21+
1422

1523
# noinspection PyTypeChecker
1624
class CliMainTest(TestCase):
@@ -179,6 +187,14 @@ def test_files_with_format_option(self):
179187
self.assertIn('"results": [\n', result.output)
180188
self.assertEqual(0, result.exit_code)
181189

190+
def test_file_does_not_match(self):
191+
with text_file(DEFAULT_CONFIG_FILE_YAML, no_match_config_yaml):
192+
runner = CliRunner()
193+
result = runner.invoke(main, ["test.zarr"])
194+
# TODO: make this assertion work
195+
# self.assertIn("No configuration matches this file.", result.output)
196+
self.assertEqual(1, result.exit_code)
197+
182198
def test_files_with_invalid_format_option(self):
183199
runner = CliRunner()
184200
result = runner.invoke(main, ["-f", "foo"] + self.files)

tests/formatters/test_markdown.py

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,17 @@
22

33
import pytest
44

5-
from xrlint.config import Config
65
from xrlint.formatter import FormatterContext
76
from xrlint.formatters.markdown import Markdown
8-
from xrlint.result import Message
9-
from xrlint.result import Result
7+
from .helpers import get_test_results
108

119

1210
class MarkdownTest(TestCase):
11+
# noinspection PyMethodMayBeStatic
1312
def test_markdown(self):
1413
formatter = Markdown()
1514
with pytest.raises(NotImplementedError):
1615
formatter.format(
1716
context=FormatterContext(),
18-
results=[
19-
Result.new(
20-
Config(),
21-
file_path="test.nc",
22-
messages=[
23-
Message(message="what", rule_id="rule-1", severity=2),
24-
Message(message="is", fatal=True),
25-
Message(message="happening?", rule_id="rule-2", severity=1),
26-
],
27-
)
28-
],
17+
results=get_test_results(),
2918
)

tests/formatters/test_simple.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class SimpleTest(TestCase):
2121
]
2222

2323
def test_no_color(self):
24-
formatter = Simple(color_enabled=False)
24+
formatter = Simple(styled=False)
2525
text = formatter.format(
2626
context=FormatterContext(),
2727
results=self.results,
@@ -30,7 +30,7 @@ def test_no_color(self):
3030
self.assertNotIn("\033]", text)
3131

3232
def test_color(self):
33-
formatter = Simple(color_enabled=True)
33+
formatter = Simple(styled=True)
3434
text = formatter.format(
3535
context=FormatterContext(),
3636
results=self.results,

xrlint/cli/engine.py

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
2-
from collections.abc import Generator
2+
from collections.abc import Iterable
3+
from typing import Iterator
34

45
import click
56
import fsspec
@@ -39,7 +40,7 @@ def __init__(
3940
rule_specs: tuple[str, ...] = (),
4041
output_format: str = DEFAULT_OUTPUT_FORMAT,
4142
output_path: str | None = None,
42-
color_enabled: bool = True,
43+
styled: bool = True,
4344
files: tuple[str, ...] = (),
4445
):
4546
self.no_default_config = no_default_config
@@ -48,7 +49,7 @@ def __init__(
4849
self.rule_specs = rule_specs
4950
self.output_format = output_format
5051
self.output_path = output_path
51-
self.color_enabled = color_enabled
52+
self.styled = styled
5253
self.files = files
5354

5455
def load_config(self) -> ConfigList:
@@ -74,6 +75,7 @@ def load_config(self) -> ConfigList:
7475
for config_path in DEFAULT_CONFIG_FILES:
7576
try:
7677
config_list = read_config_list(config_path)
78+
break
7779
except FileNotFoundError:
7880
pass
7981
except ConfigError as e:
@@ -92,16 +94,15 @@ def load_config(self) -> ConfigList:
9294

9395
return ConfigList.from_value(configs)
9496

95-
def verify_datasets(self, config_list: ConfigList) -> list[Result]:
97+
def verify_datasets(self, config_list: ConfigList) -> Iterator[Result]:
9698
global_filter = config_list.get_global_filter(default=DEFAULT_GLOBAL_FILTER)
97-
results: list[Result] = []
99+
linter = Linter()
98100
for file_path, is_dir in get_files(self.files, global_filter):
99101
config = config_list.compute_config(file_path)
100102
if config is not None:
101-
linter = Linter(config=config)
102-
result = linter.verify_dataset(file_path)
103+
yield linter.verify_dataset(file_path, config=config)
103104
else:
104-
result = Result.new(
105+
yield Result.new(
105106
config=config,
106107
file_path=file_path,
107108
messages=[
@@ -111,11 +112,8 @@ def verify_datasets(self, config_list: ConfigList) -> list[Result]:
111112
)
112113
],
113114
)
114-
results.append(result)
115115

116-
return results
117-
118-
def format_results(self, results: list[Result]) -> str:
116+
def format_results(self, results: Iterable[Result]) -> str:
119117
output_format = (
120118
self.output_format if self.output_format else DEFAULT_OUTPUT_FORMAT
121119
)
@@ -130,19 +128,23 @@ def format_results(self, results: list[Result]) -> str:
130128
# TODO: pass and validate format-specific args/kwargs
131129
# against formatter.meta.schema
132130
if output_format == "simple":
133-
formatter_kwargs = {"color_enabled": self.color_enabled}
131+
formatter_kwargs = {
132+
"styled": self.styled and self.output_path is None,
133+
"output": self.output_path is None,
134+
}
134135
else:
135136
formatter_kwargs = {}
136137
# noinspection PyArgumentList
137138
formatter_op = formatter.op_class(**formatter_kwargs)
138139
return formatter_op.format(FormatterContext(False), results)
139140

140141
def write_report(self, report: str):
141-
if not self.output_path:
142-
print(report)
143-
else:
142+
if self.output_path:
144143
with fsspec.open(self.output_path, mode="w") as f:
145144
f.write(report)
145+
elif self.output_format != "simple":
146+
# The simple formatters outputs incrementally to console
147+
print(report)
146148

147149
@classmethod
148150
def init_config_file(cls):
@@ -156,7 +158,7 @@ def init_config_file(cls):
156158

157159
def get_files(
158160
file_paths: tuple[str, ...], global_filter: FileFilter
159-
) -> Generator[tuple[str, bool | None]]:
161+
) -> Iterator[tuple[str, bool | None]]:
160162
for file_path in file_paths:
161163
_fs, root = fsspec.url_to_fs(file_path)
162164
fs: fsspec.AbstractFileSystem = _fs

xrlint/cli/main.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import click
44

5+
from xrlint.cli.stats import Stats
6+
57
# Warning: do not import heavy stuff here, it can
68
# slow down commands like "xrlint --help" otherwise.
79
from xrlint.version import version
@@ -129,16 +131,18 @@ def main(
129131
files=files,
130132
output_format=output_format,
131133
output_path=output_file,
132-
color_enabled=color_enabled,
134+
styled=color_enabled,
133135
)
136+
stats = Stats()
134137

135138
config_list = cli_engine.load_config()
136139
results = cli_engine.verify_datasets(config_list)
140+
results = stats.collect(results)
137141
report = cli_engine.format_results(results)
138142
cli_engine.write_report(report)
139143

140-
error_status = sum(r.error_count for r in results) > 0
141-
max_warn_status = sum(r.warning_count for r in results) > max_warnings
144+
error_status = stats.error_count > 0
145+
max_warn_status = stats.warning_count > max_warnings
142146
if max_warn_status and not error_status:
143147
click.echo("maximum number of warnings exceeded.")
144148
if max_warn_status or error_status:

xrlint/cli/stats.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from collections.abc import Iterable
2+
from dataclasses import dataclass
3+
4+
from xrlint.result import Result
5+
6+
7+
@dataclass()
8+
class Stats:
9+
"""Utility to collect simple statistics from results."""
10+
11+
error_count: int = 0
12+
warning_count: int = 0
13+
14+
def collect(self, results: Iterable[Result]) -> Iterable[Result]:
15+
"""Collect statistics from `results`."""
16+
for result in results:
17+
self.error_count += result.error_count
18+
self.warning_count += result.warning_count
19+
yield result

xrlint/formatter.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from abc import abstractmethod, ABC
2-
from collections.abc import Mapping
2+
from collections.abc import Mapping, Iterable
33
from dataclasses import dataclass
44
from typing import Any, Callable, Type
55

@@ -22,13 +22,13 @@ class FormatterOp(ABC):
2222
def format(
2323
self,
2424
context: FormatterContext,
25-
results: list[Result],
25+
results: Iterable[Result],
2626
) -> str:
2727
"""Format the given results.
2828
2929
Args:
3030
context: formatting context
31-
results: the results to format
31+
results: an iterable of results to format
3232
Returns:
3333
A text representing the results in a given format
3434
"""

xrlint/formatters/html.py

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from collections.abc import Iterable
2+
13
from xrlint.formatter import FormatterOp, FormatterContext
24
from xrlint.formatters import registry
35
from xrlint.result import Result, get_rules_meta_for_results
@@ -22,35 +24,37 @@ def __init__(self, with_meta: bool = False):
2224
def format(
2325
self,
2426
context: FormatterContext,
25-
results: list[Result],
27+
results: Iterable[Result],
2628
) -> str:
27-
text = []
28-
n = len(results)
29-
30-
text.append('<div role="results">\n')
31-
text.append("<h3>Results</h3>\n")
32-
for i, r in enumerate(results):
33-
text.append('<div role="result">\n')
34-
text.append(r.to_html())
35-
text.append("</div>\n")
36-
if i < n - 1:
37-
text.append("<hr/>\n")
38-
text.append("</div>\n")
29+
results = list(results) # get them all
30+
31+
text_parts = [
32+
'<div role="results">\n',
33+
"<h3>Results</h3>\n",
34+
]
35+
36+
for i, result in enumerate(results):
37+
if i > 0:
38+
text_parts.append("<hr/>\n")
39+
text_parts.append('<div role="result">\n')
40+
text_parts.append(result.to_html())
41+
text_parts.append("</div>\n")
42+
text_parts.append("</div>\n")
3943

4044
if self.with_meta:
4145
rules_meta = get_rules_meta_for_results(results)
42-
text.append('<div role="rules_meta">\n')
43-
text.append("<h3>Rules</h3>\n")
46+
text_parts.append('<div role="rules_meta">\n')
47+
text_parts.append("<h3>Rules</h3>\n")
4448
for rm in rules_meta.values():
45-
text.append(
49+
text_parts.append(
4650
f"<p>Rule <strong>{rm.name}</strong>, version {rm.version}</p>\n"
4751
)
4852
if rm.description:
49-
text.append(f"<p>{rm.description}</p>\n")
53+
text_parts.append(f"<p>{rm.description}</p>\n")
5054
if rm.docs_url:
51-
text.append(
55+
text_parts.append(
5256
f'<p><a href="{rm.docs_url}">Rule documentation</a></p>\n'
5357
)
54-
text.append("</div>\n")
58+
text_parts.append("</div>\n")
5559

56-
return "".join(text)
60+
return "".join(text_parts)

0 commit comments

Comments
 (0)