diff --git a/CHANGES.md b/CHANGES.md index eb73348..2ea732d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,17 +1,19 @@ # XRLint Change History -## Version 0.2.0 (in development) +## Version 0.2.0 (14.01.2025) -- Make all docstrings comply to google-style - Rule description is now your `RuleOp`'s docstring if `description` is not explicitly provided. - Supporting _virtual plugins_: plugins provided by Python dictionaries with rules defined by the `RuleOp` classes. - Added more configuration examples in the `examples` folder. -- Introduced utilities `ValueConstructible` and +- All `xcube` rules now have references into the + xcube dataset specification. +- Introduced mixin classes `ValueConstructible` and derived `MappingConstructible` which greatly simplify - flexible instantiation of configuration objects and their - children from Python and JSON/YAML values. + flexible instantiation of XRLint's configuration objects + and their children from Python and JSON/YAML values. +- Made all docstrings comply to google-style. ## Version 0.1.0 (09.01.2025) diff --git a/docs/rule-ref.md b/docs/rule-ref.md index 06e4f35..6169345 100644 --- a/docs/rule-ref.md +++ b/docs/rule-ref.md @@ -37,12 +37,14 @@ Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert: ### :material-bug: `any-spatial-data-var` A datacube should have spatial data variables. +[More information.](https://xcube.readthedocs.io/en/latest/cubespec.html#data-model-and-format) Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: ### :material-bug: `cube-dims-order` Order of dimensions in spatio-temporal datacube variables should be [time, ..., y, x]. +[More information.](https://xcube.readthedocs.io/en/latest/cubespec.html#data-model-and-format) Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: @@ -56,24 +58,28 @@ Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert: ### :material-lightbulb: `grid-mapping-naming` Grid mapping variables should be called 'spatial_ref' or 'crs' for compatibility with rioxarray and other packages. +[More information.](https://xcube.readthedocs.io/en/latest/cubespec.html#spatial-reference) Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert: ### :material-bug: `increasing-time` Time coordinate labels should be monotonically increasing. +[More information.](https://xcube.readthedocs.io/en/latest/cubespec.html#temporal-reference) Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: ### :material-bug: `lat-lon-naming` Latitude and longitude coordinates and dimensions should be called 'lat' and 'lon'. +[More information.](https://xcube.readthedocs.io/en/latest/cubespec.html#spatial-reference) Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: ### :material-bug: `single-grid-mapping` A single grid mapping shall be used for all spatial data variables of a datacube. +[More information.](https://xcube.readthedocs.io/en/latest/cubespec.html#spatial-reference) Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: diff --git a/docs/todo.md b/docs/todo.md index 2c4599e..da1854c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -11,11 +11,10 @@ ## Desired - project logo -- if configuration for given FILE is empty, - report an error, see TODO in CLI main tests -- use `RuleMeta.docs_url` in formatters to create links -- implement xarray backend for xcube 'levels' format - so can validate them too +- support validating xcube 'levels' format. Options: + - implement xarray backend so we can open them using `xr.open_dataset` + with `opener_options: {"engine": "xc-levels"}`. + - implement a `xrlint.processor.Processor` for that purpose. - add some more tests so we reach 99% coverage - support rule op args/kwargs schema validation - Support `RuleTest.expected`, it is currently unused diff --git a/xrlint/formatter.py b/xrlint/formatter.py index aed79df..87bce66 100644 --- a/xrlint/formatter.py +++ b/xrlint/formatter.py @@ -41,17 +41,26 @@ def format( """ -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True) class FormatterMeta: """Formatter metadata.""" name: str """Formatter name.""" + version: str = "0.0.0" """Formatter version.""" + schema: dict[str, Any] | list[dict[str, Any]] | bool | None = None """Formatter options schema.""" + ref: str | None = None + """Formatter reference. + Specifies the location from where the formatter can be + dynamically imported. + Must have the form ":", if given. + """ + @dataclass(frozen=True, kw_only=True) class Formatter: @@ -69,6 +78,7 @@ class FormatterRegistry(Mapping[str, Formatter]): def __init__(self): self._registrations = {} + # TODO: fix this code duplication in define_rule() def define_formatter( self, name: str | None = None, diff --git a/xrlint/plugin.py b/xrlint/plugin.py index 986bd1a..2e5b87b 100644 --- a/xrlint/plugin.py +++ b/xrlint/plugin.py @@ -19,9 +19,10 @@ class PluginMeta(MappingConstructible, JsonSerializable): """Plugin version.""" ref: str | None = None - """Plugin module reference. - Specifies the location from where the plugin can be loaded. - Must have the form ":". + """Plugin reference. + Specifies the location from where the plugin can be + dynamically imported. + Must have the form ":", if given. """ @classmethod diff --git a/xrlint/plugins/xcube/rules/any_spatial_data_var.py b/xrlint/plugins/xcube/rules/any_spatial_data_var.py index 17cae2e..2d52e5b 100644 --- a/xrlint/plugins/xcube/rules/any_spatial_data_var.py +++ b/xrlint/plugins/xcube/rules/any_spatial_data_var.py @@ -9,6 +9,9 @@ version="1.0.0", type="problem", description="A datacube should have spatial data variables.", + docs_url=( + "https://xcube.readthedocs.io/en/latest/cubespec.html#data-model-and-format" + ), ) class AnySpatialDataVar(RuleOp): def dataset(self, ctx: RuleContext, node: DatasetNode): diff --git a/xrlint/plugins/xcube/rules/cube_dims_order.py b/xrlint/plugins/xcube/rules/cube_dims_order.py index a9824b3..0f33300 100644 --- a/xrlint/plugins/xcube/rules/cube_dims_order.py +++ b/xrlint/plugins/xcube/rules/cube_dims_order.py @@ -12,6 +12,9 @@ f"Order of dimensions in spatio-temporal datacube variables" f" should be [{T_NAME}, ..., {Y_NAME}, {X_NAME}]." ), + docs_url=( + "https://xcube.readthedocs.io/en/latest/cubespec.html#data-model-and-format" + ), ) class CubeDimsOrder(RuleOp): def data_array(self, ctx: RuleContext, node: DataArrayNode): diff --git a/xrlint/plugins/xcube/rules/grid_mapping_naming.py b/xrlint/plugins/xcube/rules/grid_mapping_naming.py index 0e036d1..80a611e 100644 --- a/xrlint/plugins/xcube/rules/grid_mapping_naming.py +++ b/xrlint/plugins/xcube/rules/grid_mapping_naming.py @@ -12,6 +12,7 @@ f"Grid mapping variables should be called {GM_NAMES_TEXT}" f" for compatibility with rioxarray and other packages." ), + docs_url="https://xcube.readthedocs.io/en/latest/cubespec.html#spatial-reference", ) class GridMappingNaming(RuleOp): def dataset(self, ctx: RuleContext, node: DatasetNode): diff --git a/xrlint/plugins/xcube/rules/increasing_time.py b/xrlint/plugins/xcube/rules/increasing_time.py index f2daea4..0280d31 100644 --- a/xrlint/plugins/xcube/rules/increasing_time.py +++ b/xrlint/plugins/xcube/rules/increasing_time.py @@ -14,6 +14,9 @@ version="1.0.0", type="problem", description="Time coordinate labels should be monotonically increasing.", + docs_url=( + "https://xcube.readthedocs.io/en/latest/cubespec.html#temporal-reference" + ), ) class IncreasingTime(RuleOp): def data_array(self, ctx: RuleContext, node: DataArrayNode): diff --git a/xrlint/plugins/xcube/rules/lat_lon_naming.py b/xrlint/plugins/xcube/rules/lat_lon_naming.py index 18d0107..add58de 100644 --- a/xrlint/plugins/xcube/rules/lat_lon_naming.py +++ b/xrlint/plugins/xcube/rules/lat_lon_naming.py @@ -15,6 +15,7 @@ f"Latitude and longitude coordinates and dimensions" f" should be called {LAT_NAME !r} and {LON_NAME !r}." ), + docs_url="https://xcube.readthedocs.io/en/latest/cubespec.html#spatial-reference", ) class LatLonNaming(RuleOp): def dataset(self, ctx: RuleContext, node: DatasetNode): diff --git a/xrlint/plugins/xcube/rules/single_grid_mapping.py b/xrlint/plugins/xcube/rules/single_grid_mapping.py index 6d0a946..ccef80d 100644 --- a/xrlint/plugins/xcube/rules/single_grid_mapping.py +++ b/xrlint/plugins/xcube/rules/single_grid_mapping.py @@ -13,6 +13,7 @@ "A single grid mapping shall be used for all" " spatial data variables of a datacube." ), + docs_url="https://xcube.readthedocs.io/en/latest/cubespec.html#spatial-reference", ) class SingleGridMapping(RuleOp): def dataset(self, ctx: RuleContext, node: DatasetNode): diff --git a/xrlint/processor.py b/xrlint/processor.py index 0727c1b..b209075 100644 --- a/xrlint/processor.py +++ b/xrlint/processor.py @@ -48,7 +48,7 @@ def postprocess( """ -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True) class ProcessorMeta(MappingConstructible): """Processor metadata.""" @@ -59,9 +59,10 @@ class ProcessorMeta(MappingConstructible): """Processor version.""" ref: str | None = None - """Processor module reference. - Specifies the location from where the processor can be loaded. - Must have the form ":". + """Processor reference. + Specifies the location from where the processor can be + dynamically imported. + Must have the form ":", if given. """ @classmethod @@ -86,29 +87,30 @@ class Processor(MappingConstructible): # """`True` if this processor supports auto-fixing of datasets.""" @classmethod - def _from_class( - cls, value: Type[ProcessorOp], name: str | None = None - ) -> "Processor": - # TODO: see code duplication in Rule._from_class() - try: - # Note, the value.meta attribute is set by - # the define_rule - # noinspection PyUnresolvedReferences - return Processor(meta=value.meta, op_class=value) - except AttributeError: - raise ValueError( - f"missing processor metadata, apply define_processor()" - f" to class {value.__name__}" - ) + def _from_type(cls, value: Type[ProcessorOp], value_name: str) -> "Processor": + # TODO: no test covers Processor._from_type + if issubclass(value, ProcessorOp): + # TODO: fix code duplication in Rule._from_class() + try: + # Note, the value.meta attribute is set by + # the define_rule + # noinspection PyUnresolvedReferences + return Processor(meta=value.meta, op_class=value) + except AttributeError: + raise ValueError( + f"missing processor metadata, apply define_processor()" + f" to class {value.__name__}" + ) + return super()._from_type(value, value_name) @classmethod - def _from_str(cls, value: str, name: str | None = None) -> "Processor": + def _from_str(cls, value: str, value_name: str) -> "Processor": processor, processor_ref = import_value( value, "export_processor", factory=Processor.from_value, - expected_type=type, ) + # noinspection PyUnresolvedReferences processor.meta.ref = processor_ref return processor @@ -117,7 +119,7 @@ def _get_value_type_name(cls) -> str: return "str | dict | Processor | Type[ProcessorOp]" -# TODO: see code duplication in define_rule() +# TODO: fix this code duplication in define_rule() def define_processor( name: str | None = None, version: str = "0.0.0", diff --git a/xrlint/rule.py b/xrlint/rule.py index 96a3f83..519b9be 100644 --- a/xrlint/rule.py +++ b/xrlint/rule.py @@ -23,27 +23,28 @@ class RuleContext(ABC): - """The context passed to a [xrlint.rule.RuleOp][] instance. + """The context passed to a [RuleOp][xrlint.rule.RuleOp] instance. - You should never create instances of this class yourself. - Instances of this interface are passed to the `RuleOp`'s - methods. + Instances of this interface are passed to the validation + methods of your `RuleOp`. + There should be no reason to create instances of this class + yourself. """ @property @abstractmethod - def settings(self) -> dict[str, Any]: - """Configuration settings.""" + def file_path(self) -> str: + """The current dataset's file path.""" @property @abstractmethod - def dataset(self) -> xr.Dataset: - """Get the current dataset.""" + def settings(self) -> dict[str, Any]: + """Applicable subset of settings from configuration `settings`.""" @property @abstractmethod - def file_path(self) -> str: - """Get the current dataset's file path.""" + def dataset(self) -> xr.Dataset: + """The current dataset.""" @abstractmethod def report( @@ -184,7 +185,11 @@ class RuleMeta(MappingConstructible, JsonSerializable): """ ref: str | None = None - """Reference to the origin.""" + """Rule reference. + Specifies the location from where the rule can be + dynamically imported. + Must have the form ":", if given. + """ @classmethod def _get_value_type_name(cls) -> str: @@ -220,7 +225,7 @@ class that implements the rule's logic. """ @classmethod - def _from_str(cls, value: str, name: str) -> "Rule": + def _from_str(cls, value: str, value_name: str) -> "Rule": rule, rule_ref = import_value(value, "export_rule", factory=Rule.from_value) rule.meta.ref = rule_ref return rule @@ -307,13 +312,13 @@ def _from_int(cls, value: int, name: str) -> "RuleConfig": return RuleConfig(cls._convert_severity(value)) @classmethod - def _from_str(cls, value: str, name: str) -> "RuleConfig": + def _from_str(cls, value: str, value_name: str) -> "RuleConfig": return RuleConfig(cls._convert_severity(value)) @classmethod - def _from_sequence(cls, value: Sequence, name: str) -> "RuleConfig": + def _from_sequence(cls, value: Sequence, value_name: str) -> "RuleConfig": if not value: - raise ValueError() + raise ValueError(f"{value_name} must not be empty") severity = cls._convert_severity(value[0]) options = value[1:] if not options: diff --git a/xrlint/util/codec.py b/xrlint/util/codec.py index 38116f1..193e780 100644 --- a/xrlint/util/codec.py +++ b/xrlint/util/codec.py @@ -76,8 +76,8 @@ def from_value(cls, value: Any, value_name: str | None = None) -> T: return cls._from_mapping(value, value_name) if isinstance(value, Sequence): return cls._from_sequence(value, value_name) - if isinstance(value, type): - if isclass(value) and issubclass(value, cls): + if isclass(value): + if issubclass(value, cls): return cls._from_class(value, value_name) else: return cls._from_type(value, value_name) diff --git a/xrlint/version.py b/xrlint/version.py index b7fb2ae..181e9f0 100644 --- a/xrlint/version.py +++ b/xrlint/version.py @@ -1 +1 @@ -version = "0.2.0.dev0" +version = "0.2.0"