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
6 changes: 5 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

## Version 0.5.0 (in development)

### Changes
### Adjustments and Enhancements

- Added HTML styling for both CLI output (`--format html`) and rendering
of `Result` objects in Jupyter notebooks.

- Rule `no-empty-chunks` has taken off the `"recommended"` settings
as there is no easy/efficient way to tell whether a dataset has
Expand Down Expand Up @@ -41,6 +44,7 @@
### Other changes

- Added more tests so we finally reached 100% coverage.
- New `PluginMeta.docs_url` property.

## Version 0.4.1 (from 2025-01-31)

Expand Down
16 changes: 10 additions & 6 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,30 @@

## Required

- populate `core` plugin by more rules, see CF site and `cf-check` tool
- populate `xcube` plugin by more rules
- support zarr >= 3 which we do not only because test
`tests/plugins/xcube/processors/test_mldataset.py` fails
(see code TODO)
- validate `RuleConfig.args/kwargs` against `RuleMeta.schema`
(see code TODO)
- enhance docs
- complete configuration page
- provide guide page
- use mkdocstrings ref syntax in docstrings
- provide configuration examples (use as tests?)
- add `docs_url` to all existing rules
- rule ref should cover rule parameters

## Desired

- project logo
- add `core` rule checks recommended use of fill value
- add `xcube` rule that helps to identify chunking issues
- apply rule op args/kwargs validation schema
- provide core rule that checks for configurable list of std attributes
- measure time it takes to open a dataset and pass time into rule context
so we can write a configurable rule that checks the opening time
- allow outputting suggestions, if any, that are emitted by some rules
- enhance styling of `Result` representation in Jupyter notebooks
(check if we can expand/collapse messages with suggestions)
- add CLI option
- expand/collapse messages with suggestions in Jupyter notebooks

## Nice to have

Expand Down Expand Up @@ -69,4 +74,3 @@ types that can read the data from a file path.
- call the root element `accept(validator)` that validates the
root element `validate.root()` and starts traversal of
child elements.

314 changes: 225 additions & 89 deletions notebooks/xrlint-cli.ipynb

Large diffs are not rendered by default.

382 changes: 293 additions & 89 deletions notebooks/xrlint-linter.ipynb

Large diffs are not rendered by default.

13 changes: 10 additions & 3 deletions tests/cli/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,17 +194,24 @@ def test_files_but_config_file_missing(self):
self.assertIn("Error: file not found: pippo.py", result.output)
self.assertEqual(1, result.exit_code)

def test_files_with_format_option(self):
def test_files_with_format_json(self):
with text_file(DEFAULT_CONFIG_FILE_YAML, self.ok_config_yaml):
result = self.xrlint("-f", "json", *self.files)
self.assertIn('"results": [\n', result.output)
self.assertEqual(0, result.exit_code)

def test_files_with_format_html(self):
with text_file(DEFAULT_CONFIG_FILE_YAML, self.ok_config_yaml):
result = self.xrlint("-f", "html", *self.files)
self.assertIn("<h3>Results</h3>", 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):
result = self.xrlint("test.zarr")
# TODO: make this assertion work
# self.assertIn("No configuration matches this file.", result.output)
self.assertIn(
"No configuration given or matches 'test.zarr'.", result.output
)
self.assertEqual(1, result.exit_code)

def test_print_config_option(self):
Expand Down
14 changes: 12 additions & 2 deletions tests/formatters/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,18 @@ class Rule2(RuleOp):
config_object=config_obj,
file_path="test.nc",
messages=[
Message(message="message-1", rule_id="test/rule-1", severity=2),
Message(message="message-2", rule_id="test/rule-2", severity=1),
Message(
message="message-1",
rule_id="test/rule-1",
severity=2,
node_path="dataset",
),
Message(
message="message-2",
rule_id="test/rule-2",
severity=1,
node_path="dataset",
),
Message(message="message-3", fatal=True),
],
),
Expand Down
8 changes: 5 additions & 3 deletions tests/formatters/test_html.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from unittest import TestCase

from xrlint.formatters.html import Html
from xrlint.formatters.html import Html, HtmlText

from .helpers import get_context, get_test_results

Expand All @@ -13,7 +13,8 @@ def test_html(self):
context=get_context(),
results=results,
)
self.assertIsInstance(text, str)
self.assertIsInstance(text, HtmlText)
self.assertIs(text, text._repr_html_())
self.assertIn("</p>", text)

def test_html_with_meta(self):
Expand All @@ -23,5 +24,6 @@ def test_html_with_meta(self):
context=get_context(),
results=results,
)
self.assertIsInstance(text, str)
self.assertIsInstance(text, HtmlText)
self.assertIs(text, text._repr_html_())
self.assertIn("</p>", text)
2 changes: 1 addition & 1 deletion tests/test_linter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from xrlint.config import Config, ConfigObject
from xrlint.constants import CORE_PLUGIN_NAME, NODE_ROOT_NAME
from xrlint.linter import Linter, new_linter
from xrlint.node import AttrNode, AttrsNode, VariableNode, DatasetNode
from xrlint.node import AttrNode, AttrsNode, DatasetNode, VariableNode
from xrlint.plugin import new_plugin
from xrlint.processor import ProcessorOp
from xrlint.result import Message, Result
Expand Down
3 changes: 2 additions & 1 deletion tests/test_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ def test_repr_html(self):
)
html = result._repr_html_()
self.assertIsInstance(html, str)
self.assertEqual('<p role="file">test.zarr - ok</p>\n', html)
self.assertIn("ok", html)
self.assertIn("<div", html)

result = Result.new(
config_object=ConfigObject(),
Expand Down
2 changes: 1 addition & 1 deletion xrlint/_linter/apply.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from xrlint.node import AttrNode, AttrsNode, VariableNode, DatasetNode
from xrlint.node import AttrNode, AttrsNode, DatasetNode, VariableNode
from xrlint.rule import RuleConfig, RuleExit, RuleOp

from ..constants import NODE_ROOT_NAME
Expand Down
2 changes: 1 addition & 1 deletion xrlint/all.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
FormatterRegistry,
)
from xrlint.linter import Linter, new_linter
from xrlint.node import AttrNode, AttrsNode, VariableNode, DatasetNode, Node
from xrlint.node import AttrNode, AttrsNode, DatasetNode, Node, VariableNode
from xrlint.plugin import Plugin, PluginMeta, new_plugin
from xrlint.processor import Processor, ProcessorMeta, ProcessorOp, define_processor
from xrlint.result import (
Expand Down
2 changes: 1 addition & 1 deletion xrlint/cli/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ def format_results(self, results: Iterable[Result]) -> str:
f" The available formats are"
f" {', '.join(repr(k) for k in formatters.keys())}."
)
# TODO: pass and validate format-specific args/kwargs
# Here we could pass and validate format-specific args/kwargs
# against formatter.meta.schema
if output_format == "simple":
formatter_kwargs = {
Expand Down
11 changes: 7 additions & 4 deletions xrlint/constants.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
from typing import Final

CORE_PLUGIN_NAME: Final = "__core__"
CORE_DOCS_URL = "https://bcdev.github.io/xrlint/rule-ref"

NODE_ROOT_NAME: Final = "dataset"
MISSING_DATASET_FILE_PATH: Final = "<dataset>"

SEVERITY_ERROR: Final = 2
SEVERITY_WARN: Final = 1
SEVERITY_OFF: Final = 0
Expand All @@ -11,12 +17,9 @@
}
SEVERITY_CODE_TO_NAME: Final = {v: k for k, v in SEVERITY_NAME_TO_CODE.items()}
SEVERITY_CODE_TO_CODE: Final = {v: v for v in SEVERITY_NAME_TO_CODE.values()}
SEVERITY_CODE_TO_COLOR = {2: "red", 1: "blue", 0: "green", None: ""}

SEVERITY_ENUM: Final[dict[int | str, int]] = (
SEVERITY_NAME_TO_CODE | SEVERITY_CODE_TO_CODE
)
SEVERITY_ENUM_TEXT: Final = ", ".join(f"{k!r}" for k in SEVERITY_ENUM.keys())

MISSING_DATASET_FILE_PATH: Final = "<dataset>"
NODE_ROOT_NAME: Final = "dataset"
CORE_PLUGIN_NAME: Final = "__core__"
123 changes: 102 additions & 21 deletions xrlint/formatters/html.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import html
from collections.abc import Iterable

from xrlint.constants import (
SEVERITY_CODE_TO_COLOR,
SEVERITY_CODE_TO_NAME,
)
from xrlint.formatter import FormatterContext, FormatterOp
from xrlint.formatters import registry
from xrlint.result import Result, get_rules_meta_for_results
from xrlint.result import Message, Result, get_rules_meta_for_results
from xrlint.util.formatting import format_problems
from xrlint.util.schema import schema


Expand All @@ -27,33 +33,108 @@ def format(
) -> str:
results = list(results) # get them all

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

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")
for result in results:
lines.extend(format_result(result))
lines.append("</div>")

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

return HtmlText("\n".join(lines))


def format_result(result: Result) -> list[str]:
lines = ['<div style="padding-bottom: 5px">']
escaped_path = _format_file_path(result.file_path)
if not result.messages:
lines.append(
f"<p>{escaped_path} - "
f'<span style="font-weight:bold;color:green">ok</span>'
f"</p>"
)
else:
lines.append(
f"<p>{escaped_path} - "
f"<span>{format_problems(result.error_count, result.warning_count)}</span>"
f"</p>"
)
lines.append("<hr/>")
table_data = []
for m in result.messages:
table_data.append(
[
_format_node_path(m),
_format_severity(m),
_format_message(m),
_format_rule_id(m, result),
]
)
lines.extend(_format_result_data(table_data))
lines.append("</div>")
return lines


def _format_file_path(file_path: str) -> str:
return f'<span style="font-family:monospace;font-weight:bold">{html.escape(file_path)}</span>'


def _format_node_path(m: Message) -> str:
if not m.node_path:
return ""
return f'<span style="font-family:monospace;font-size:0.7em">{m.node_path}</span>'


def _format_message(m: Message) -> str:
return m.message


def _format_rule_id(m: Message, r: Result) -> str:
if not m.rule_id:
return ""
docs_url = r.get_docs_url_for_rule(m.rule_id)
if docs_url:
return f'<a href="{docs_url}">{m.rule_id}</a>'
return m.rule_id


def _format_severity(m: Message) -> str:
if not m.severity:
return ""
name = SEVERITY_CODE_TO_NAME.get(m.severity)
color = SEVERITY_CODE_TO_COLOR.get(m.severity)
return f'<span style="color:{color}">{name}</span>'


def _format_result_data(data: list[list[str]]) -> list[str]:
lines = ["<table>"]
for row in data:
lines.append(" <tr>")
lines.extend(f' <td style="text-align:left">{value}</td>' for value in row)
lines.append(" </tr>")
lines.append("</table>")
return lines


class HtmlText(str):
"""Allow displaying `text` as HTML in Jupyter notebooks."""

return "".join(text_parts)
def _repr_html_(self: str) -> str:
"""Represent HTML text as HTML."""
return self
Loading