diff --git a/CHANGES.md b/CHANGES.md index 7a90756..de888e2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,19 @@ # XRLint Change History +## Version 0.5.0 (in development) + +- Introduced type aliases `ConfigLike` and `ConfigObjectLike`. +- Renamed multiple components for improved clarity and consistency: + - Renamed `Config` into `ConfigObject` + - Renamed `ConfigList.configs` into `config_objects` + - Renamed `ConfigList` into `Config` + - Renamed `ConfigList.compute_config()` into `compute_config_object()` + - Renamed `Result.config` into `config_object` + - Renamed `XRLint.load_config_list()` into `init_config()` + - Renamed `XRLint.verify_datasets()` into `verify_files()` +- Added class method `from_config()` to `ConfigList`. +- Removed function `xrlint.config.merge_configs` as it was no longer used. + ## Version 0.4.1 (from 2025-01-31) ### Changes diff --git a/docs/api.md b/docs/api.md index 40ddcf2..c9da60e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,7 +16,7 @@ This chapter provides a plain reference for the XRLint Python API. plugin metadata represented by [PluginMeta][xrlint.plugin.PluginMeta]. - The `config` module provides classes that represent configuration information and provide related functionality: - [Config][xrlint.config.Config] and [ConfigList][xrlint.config.ConfigList]. + [Config][xrlint.config.Config] and [ConfigObject][xrlint.config.ConfigObject]. - The `rule` module provides rule related classes and functions: [Rule][xrlint.rule.Rule] comprising rule metadata, [RuleMeta][xrlint.rule.RuleMeta], the rule validation operations in @@ -60,7 +60,11 @@ Note: ::: xrlint.config.Config -::: xrlint.config.ConfigList +::: xrlint.config.ConfigObject + +::: xrlint.config.ConfigLike + +::: xrlint.config.ConfigObjectLike ::: xrlint.rule.define_rule diff --git a/examples/check_s3_bucket.py b/examples/check_s3_bucket.py new file mode 100644 index 0000000..ac8488c --- /dev/null +++ b/examples/check_s3_bucket.py @@ -0,0 +1,8 @@ +import xrlint.all as xrl + +URL = "s3://xcube-test/" + +xrlint = xrl.XRLint(no_config_lookup=True) +xrlint.init_config("recommended") +results = xrlint.verify_files([URL]) +print(xrlint.format_results(results)) diff --git a/mkruleref.py b/mkruleref.py index 6e2d396..2050bed 100644 --- a/mkruleref.py +++ b/mkruleref.py @@ -62,7 +62,7 @@ def write_plugin_rules(stream, plugin: Plugin): stream.write("\n\n") -def get_plugin_rule_configs(plugin): +def get_plugin_rule_configs(plugin: Plugin) -> dict[str, dict[str, RuleConfig]]: configs = plugin.configs config_rules: dict[str, dict[str, RuleConfig]] = {} for config_name, config_list in configs.items(): diff --git a/tests/_linter/test_rulectx.py b/tests/_linter/test_rulectx.py index 92bbc1a..634a37f 100644 --- a/tests/_linter/test_rulectx.py +++ b/tests/_linter/test_rulectx.py @@ -4,24 +4,24 @@ # noinspection PyProtectedMember from xrlint._linter.rulectx import RuleContextImpl -from xrlint.config import Config +from xrlint.config import ConfigObject from xrlint.constants import NODE_ROOT_NAME from xrlint.result import Message, Suggestion class RuleContextImplTest(TestCase): def test_defaults(self): - config = Config() + config_obj = ConfigObject() dataset = xr.Dataset() - context = RuleContextImpl(config, dataset, "./ds.zarr", None) - self.assertIs(config, context.config) + context = RuleContextImpl(config_obj, dataset, "./ds.zarr", None) + self.assertIs(config_obj, context.config) self.assertIs(dataset, context.dataset) self.assertEqual({}, context.settings) self.assertEqual("./ds.zarr", context.file_path) self.assertEqual(None, context.file_index) def test_report(self): - context = RuleContextImpl(Config(), xr.Dataset(), "./ds.zarr", None) + context = RuleContextImpl(ConfigObject(), xr.Dataset(), "./ds.zarr", None) with context.use_state(rule_id="no-xxx"): context.report( "What the heck do you mean?", diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index 6bd49a5..58db008 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -5,8 +5,8 @@ import pytest -from xrlint.cli.config import ConfigError, read_config_list -from xrlint.config import Config, ConfigList +from xrlint.cli.config import ConfigError, read_config +from xrlint.config import Config, ConfigObject from xrlint.rule import RuleConfig from .helpers import text_file @@ -58,24 +58,24 @@ def new_config_py(self): def test_read_config_yaml(self): with text_file("config.yaml", yaml_text) as config_path: - config = read_config_list(config_path) + config = read_config(config_path) self.assert_config_ok(config, "yaml-test") def test_read_config_json(self): with text_file("config.json", json_text) as config_path: - config = read_config_list(config_path) + config = read_config(config_path) self.assert_config_ok(config, "json-test") def test_read_config_py(self): with text_file(self.new_config_py(), py_text) as config_path: - config = read_config_list(config_path) + config = read_config(config_path) self.assert_config_ok(config, "py-test") def assert_config_ok(self, config: Any, name: str): self.assertEqual( - ConfigList( + Config( [ - Config( + ConfigObject( name=name, rules={ "rule-1": RuleConfig(2), @@ -94,7 +94,7 @@ def test_read_config_invalid_arg(self): match="configuration file must be of type str|Path|PathLike, but got None", ): # noinspection PyTypeChecker - read_config_list(None) + read_config(None) def test_read_config_json_with_format_error(self): with text_file("config.json", "{") as config_path: @@ -106,7 +106,7 @@ def test_read_config_json_with_format_error(self): " line 1 column 2 \\(char 1\\)" ), ): - read_config_list(config_path) + read_config(config_path) def test_read_config_yaml_with_format_error(self): with text_file("config.yaml", "}") as config_path: @@ -114,26 +114,26 @@ def test_read_config_yaml_with_format_error(self): ConfigError, match="config.yaml: while parsing a block node", ): - read_config_list(config_path) + read_config(config_path) def test_read_config_yaml_with_type_error(self): with text_file("config.yaml", "97") as config_path: with pytest.raises( ConfigError, match=( - r"config\.yaml\: config_list must be of" - r" type ConfigList \| list\[Config \| dict \| str\]," + r"config\.yaml\: config must be of type" + r" Config \| ConfigObjectLike \| str \| Sequence\[ConfigObjectLike \| str\]," r" but got int" ), ): - read_config_list(config_path) + read_config(config_path) def test_read_config_with_unknown_format(self): with pytest.raises( ConfigError, match="config.toml: unsupported configuration file format", ): - read_config_list("config.toml") + read_config("config.toml") def test_read_config_py_no_export(self): py_code = "x = 42\n" @@ -145,7 +145,7 @@ def test_read_config_py_no_export(self): " not found in module 'config_1002'" ), ): - read_config_list(config_path) + read_config(config_path) def test_read_config_py_with_value_error(self): py_code = "def export_config():\n raise ValueError('value is useless!')\n" @@ -154,7 +154,7 @@ def test_read_config_py_with_value_error(self): ValueError, match="value is useless!", ): - read_config_list(config_path) + read_config(config_path) def test_read_config_py_with_os_error(self): py_code = "def export_config():\n raise OSError('where is my hat?')\n" @@ -163,7 +163,7 @@ def test_read_config_py_with_os_error(self): ConfigError, match="where is my hat?", ): - read_config_list(config_path) + read_config(config_path) def test_read_config_py_with_invalid_config_list(self): py_code = "def export_config():\n return 42\n" @@ -171,39 +171,39 @@ def test_read_config_py_with_invalid_config_list(self): with pytest.raises( ConfigError, match=( - r"\.py: return value of export_config\(\):" - r" config_list must be of type" - r" ConfigList \| list\[Config\ | dict \| str\]," + r"\.py: failed converting value of 'config_1003:export_config':" + r" config must be of type" + r" Config \| ConfigObjectLike \| str \| Sequence\[ConfigObjectLike \| str\]," r" but got int" ), ): - read_config_list(config_path) + read_config(config_path) class CliConfigResolveTest(unittest.TestCase): def test_read_config_py(self): self.assert_ok( - read_config_list(Path(__file__).parent / "configs" / "recommended.py") + read_config(Path(__file__).parent / "configs" / "recommended.py") ) def test_read_config_json(self): self.assert_ok( - read_config_list(Path(__file__).parent / "configs" / "recommended.json") + read_config(Path(__file__).parent / "configs" / "recommended.json") ) def test_read_config_yaml(self): self.assert_ok( - read_config_list(Path(__file__).parent / "configs" / "recommended.yaml") + read_config(Path(__file__).parent / "configs" / "recommended.yaml") ) - def assert_ok(self, config_list: ConfigList): - self.assertIsInstance(config_list, ConfigList) - self.assertEqual(7, len(config_list.configs)) - config = config_list.compute_config("test.zarr") + def assert_ok(self, config: Config): self.assertIsInstance(config, Config) - self.assertEqual(None, config.name) - self.assertIsInstance(config.plugins, dict) - self.assertEqual({"xcube"}, set(config.plugins.keys())) - self.assertIsInstance(config.rules, dict) - self.assertIn("coords-for-dims", config.rules) - self.assertIn("xcube/cube-dims-order", config.rules) + self.assertEqual(7, len(config.objects)) + config_obj = config.compute_config_object("test.zarr") + self.assertIsInstance(config_obj, ConfigObject) + self.assertEqual(None, config_obj.name) + self.assertIsInstance(config_obj.plugins, dict) + self.assertEqual({"xcube"}, set(config_obj.plugins.keys())) + self.assertIsInstance(config_obj.rules, dict) + self.assertIn("coords-for-dims", config_obj.rules) + self.assertIn("xcube/cube-dims-order", config_obj.rules) diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 5aab920..1d9871b 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -78,6 +78,24 @@ def test_files_no_config(self): self.assertIn("Warning: no configuration file found.\n", result.output) self.assertEqual(1, result.exit_code) + def test_files_no_config_lookup(self): + with text_file(DEFAULT_CONFIG_FILE_YAML, self.ok_config_yaml): + result = self.xrlint("--no-config-lookup", "--no-color", *self.files) + self.assertEqual( + "\n" + "dataset1.zarr:\n" + "dataset error No rules configured or applicable.\n\n" + "dataset1.nc:\n" + "dataset error No rules configured or applicable.\n\n" + "dataset2.zarr:\n" + "dataset error No rules configured or applicable.\n\n" + "dataset2.nc:\n" + "dataset error No rules configured or applicable.\n\n" + "4 errors\n\n", + result.output, + ) + self.assertEqual(1, result.exit_code) + def test_files_one_rule(self): with text_file(DEFAULT_CONFIG_FILE_YAML, self.ok_config_yaml): result = self.xrlint("--no-color", *self.files) diff --git a/tests/formatters/helpers.py b/tests/formatters/helpers.py index 4e26506..90581dc 100644 --- a/tests/formatters/helpers.py +++ b/tests/formatters/helpers.py @@ -1,4 +1,4 @@ -from xrlint.config import Config +from xrlint.config import ConfigObject from xrlint.formatter import FormatterContext from xrlint.plugin import new_plugin from xrlint.result import Message, Result, ResultStats @@ -38,11 +38,11 @@ class Rule1(RuleOp): class Rule2(RuleOp): pass - config = Config(plugins={"test": plugin}) + config_obj = ConfigObject(plugins={"test": plugin}) return [ Result.new( - config, + config_object=config_obj, file_path="test.nc", messages=[ Message(message="message-1", rule_id="test/rule-1", severity=2), @@ -51,7 +51,7 @@ class Rule2(RuleOp): ], ), Result.new( - config, + config_object=config_obj, file_path="test-2.nc", messages=[ Message(message="message-1", rule_id="test/rule-1", severity=1), diff --git a/tests/formatters/test_simple.py b/tests/formatters/test_simple.py index 05d5187..8bc13eb 100644 --- a/tests/formatters/test_simple.py +++ b/tests/formatters/test_simple.py @@ -1,7 +1,7 @@ from unittest import TestCase from tests.formatters.helpers import get_context -from xrlint.config import Config +from xrlint.config import ConfigObject from xrlint.formatters.simple import Simple from xrlint.result import Message, Result @@ -9,7 +9,7 @@ class SimpleTest(TestCase): errors_and_warnings = [ Result.new( - Config(), + config_object=ConfigObject(), file_path="test1.nc", messages=[ Message(message="what", rule_id="rule-1", severity=2), @@ -21,7 +21,7 @@ class SimpleTest(TestCase): warnings_only = [ Result.new( - Config(), + ConfigObject(), file_path="test2.nc", messages=[ Message(message="what", rule_id="rule-1", severity=1), diff --git a/tests/test_config.py b/tests/test_config.py index 59a2e3f..10fd893 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,7 +4,7 @@ import pytest import xarray as xr -from xrlint.config import Config, ConfigList, get_core_config +from xrlint.config import Config, ConfigObject, get_core_config_object from xrlint.constants import CORE_PLUGIN_NAME from xrlint.plugin import Plugin, new_plugin from xrlint.processor import ProcessorOp, define_processor @@ -14,37 +14,37 @@ # noinspection PyMethodMayBeStatic -class ConfigTest(TestCase): +class ConfigObjectTest(TestCase): def test_class_props(self): - self.assertEqual("config", Config.value_name()) - self.assertEqual("Config | dict | None", Config.value_type_name()) + self.assertEqual("config_obj", ConfigObject.value_name()) + self.assertEqual("ConfigObject | dict | None", ConfigObject.value_type_name()) def test_defaults(self): - config = Config() - self.assertEqual(None, config.name) - self.assertEqual(None, config.files) - self.assertEqual(None, config.ignores) - self.assertEqual(None, config.linter_options) - self.assertEqual(None, config.opener_options) - self.assertEqual(None, config.processor) - self.assertEqual(None, config.plugins) - self.assertEqual(None, config.rules) + config_obj = ConfigObject() + self.assertEqual(None, config_obj.name) + self.assertEqual(None, config_obj.files) + self.assertEqual(None, config_obj.ignores) + self.assertEqual(None, config_obj.linter_options) + self.assertEqual(None, config_obj.opener_options) + self.assertEqual(None, config_obj.processor) + self.assertEqual(None, config_obj.plugins) + self.assertEqual(None, config_obj.rules) def test_get_plugin(self): - config = get_core_config() - plugin = config.get_plugin(CORE_PLUGIN_NAME) + config_obj = get_core_config_object() + plugin = config_obj.get_plugin(CORE_PLUGIN_NAME) self.assertIsInstance(plugin, Plugin) with pytest.raises(ValueError, match="unknown plugin 'xcube'"): - config.get_plugin("xcube") + config_obj.get_plugin("xcube") def test_get_rule(self): - config = get_core_config() - rule = config.get_rule("var-flags") + config_obj = get_core_config_object() + rule = config_obj.get_rule("var-flags") self.assertIsInstance(rule, Rule) with pytest.raises(ValueError, match="unknown rule 'foo'"): - config.get_rule("foo") + config_obj.get_rule("foo") def test_get_processor_op(self): class MyProc(ProcessorOp): @@ -59,35 +59,37 @@ def postprocess( pass processor = define_processor("myproc", op_class=MyProc) - config = Config( + config_obj = ConfigObject( plugins=dict( myplugin=new_plugin("myplugin", processors=dict(myproc=processor)) ) ) - processor_op = config.get_processor_op(MyProc()) + processor_op = config_obj.get_processor_op(MyProc()) self.assertIsInstance(processor_op, MyProc) - processor_op = config.get_processor_op("myplugin/myproc") + processor_op = config_obj.get_processor_op("myplugin/myproc") self.assertIsInstance(processor_op, MyProc) with pytest.raises(ValueError, match="unknown processor 'myplugin/myproc2'"): - config.get_processor_op("myplugin/myproc2") + config_obj.get_processor_op("myplugin/myproc2") def test_from_value_ok(self): - self.assertEqual(Config(), Config.from_value(None)) - self.assertEqual(Config(), Config.from_value({})) - self.assertEqual(Config(), Config.from_value(Config())) - self.assertEqual(Config(name="x"), Config.from_value(Config(name="x"))) + self.assertEqual(ConfigObject(), ConfigObject.from_value(None)) + self.assertEqual(ConfigObject(), ConfigObject.from_value({})) + self.assertEqual(ConfigObject(), ConfigObject.from_value(ConfigObject())) + self.assertEqual( + ConfigObject(name="x"), ConfigObject.from_value(ConfigObject(name="x")) + ) self.assertEqual( - Config( + ConfigObject( name="xXx", files=["**/*.zarr", "**/*.nc"], linter_options={"a": 4}, opener_options={"b": 5}, settings={"c": 6}, ), - Config.from_value( + ConfigObject.from_value( { "name": "xXx", "files": ["**/*.zarr", "**/*.nc"], @@ -98,7 +100,7 @@ def test_from_value_ok(self): ), ) self.assertEqual( - Config( + ConfigObject( rules={ "hello/no-spaces-in-titles": RuleConfig(severity=2), "hello/time-without-tz": RuleConfig(severity=0), @@ -107,7 +109,7 @@ def test_from_value_ok(self): ), }, ), - Config.from_value( + ConfigObject.from_value( { "rules": { "hello/no-spaces-in-titles": 2, @@ -119,7 +121,7 @@ def test_from_value_ok(self): ) def test_to_json(self): - config = Config( + config_obj = ConfigObject( name="xXx", files=["**/*.zarr", "**/*.nc"], linter_options={"a": 4}, @@ -146,128 +148,151 @@ def test_to_json(self): "hello/time-without-tz": 0, }, }, - config.to_json(), + config_obj.to_json(), ) def test_from_value_fails(self): with pytest.raises( TypeError, - match=r"config must be of type Config \| dict \| None, but got int", + match=r"config_obj must be of type ConfigObject \| dict \| None, but got int", ): - Config.from_value(4) + ConfigObject.from_value(4) with pytest.raises( TypeError, - match=r"config must be of type Config \| dict \| None, but got str", + match=r"config_obj must be of type ConfigObject \| dict \| None, but got str", ): - Config.from_value("abc") + ConfigObject.from_value("abc") with pytest.raises( TypeError, - match=r"config must be of type Config \| dict \| None, but got tuple", + match=r"config_obj must be of type ConfigObject \| dict \| None, but got tuple", ): - Config.from_value(()) + ConfigObject.from_value(()) with pytest.raises( TypeError, - match=r" config.linter_options must be of type dict.*, but got list", + match=r" config_obj.linter_options must be of type dict.*, but got list", ): - Config.from_value({"linter_options": [1, 2, 3]}) + ConfigObject.from_value({"linter_options": [1, 2, 3]}) with pytest.raises( TypeError, - match=r" keys of config.settings must be of type str, but got int", + match=r" keys of config_obj.settings must be of type str, but got int", ): - Config.from_value({"settings": {8: 9}}) + ConfigObject.from_value({"settings": {8: 9}}) -class ConfigListTest(TestCase): - def test_from_value_ok(self): - config_list = ConfigList.from_value([]) - self.assertIsInstance(config_list, ConfigList) - self.assertEqual([], config_list.configs) +class ConfigTest(TestCase): + def test_from_config_ok(self): + config = Config.from_config() + self.assertIsInstance(config, Config) + self.assertEqual([], config.objects) + + config = Config.from_config( + {"ignores": ["**/*.levels"]}, + get_core_config_object(), + "recommended", + {"rules": {"no-empty-chunks": 2}}, + ) + self.assertIsInstance(config, Config) + self.assertEqual(4, len(config.objects)) + + config = Config.from_config(config) + self.assertIsInstance(config, Config) + self.assertEqual(4, len(config.objects)) - config_list_2 = ConfigList.from_value(config_list) - self.assertIs(config_list_2, config_list) + config = Config.from_config(config.objects) + self.assertIsInstance(config, Config) + self.assertEqual(4, len(config.objects)) - config_list = ConfigList.from_value([{}]) - self.assertIsInstance(config_list, ConfigList) - self.assertEqual([Config()], config_list.configs) + config = Config.from_config(*config.objects) + self.assertIsInstance(config, Config) + self.assertEqual(4, len(config.objects)) + + def test_from_value_ok(self): + config = Config.from_value([]) + self.assertIsInstance(config, Config) + self.assertEqual([], config.objects) - config_list = ConfigList.from_value({}) - self.assertIsInstance(config_list, ConfigList) - self.assertEqual([Config()], config_list.configs) + config_2 = Config.from_value(config) + self.assertIs(config_2, config) - config = Config.from_value({}) - config_list = ConfigList.from_value(config) - self.assertIsInstance(config_list, ConfigList) - self.assertIs(config, config_list.configs[0]) + config = Config.from_value([{}]) + self.assertIsInstance(config, Config) + self.assertEqual([], config.objects) + + config_object = ConfigObject.from_value({}) + config = Config.from_value(config_object) + self.assertIsInstance(config, Config) + self.assertIs(config_object, config.objects[0]) # noinspection PyMethodMayBeStatic def test_from_value_fail(self): with pytest.raises( TypeError, match=( - r"config_list must be of type" - r" ConfigList \| list\[Config \| dict \| str\], but got int" + r"config must be of type" + r" Config \| ConfigObjectLike \| str \| Sequence\[ConfigObjectLike \| str\]," + r" but got int" ), ): - ConfigList.from_value(264) + Config.from_value(264) def test_compute_config(self): - config_list = ConfigList([Config()]) + config = Config([ConfigObject()]) file_path = "s3://wq-services/datacubes/chl-2.zarr" - self.assertEqual(Config(), config_list.compute_config(file_path)) + self.assertEqual(ConfigObject(), config.compute_config_object(file_path)) - config_list = ConfigList( + config = Config( [ - Config(ignores=["**/*.yaml"], settings={"a": 1, "b": 1}), - Config(files=["**/datacubes/*.zarr"], settings={"b": 2}), - Config(files=["**/*.txt"], settings={"a": 2}), + ConfigObject(ignores=["**/*.yaml"], settings={"a": 1, "b": 1}), + ConfigObject(files=["**/datacubes/*.zarr"], settings={"b": 2}), + ConfigObject(files=["**/*.txt"], settings={"a": 2}), ] ) file_path = "s3://wq-services/datacubes/chl-2.zarr" self.assertEqual( - Config(settings={"a": 1, "b": 2}), - config_list.compute_config(file_path), + ConfigObject(settings={"a": 1, "b": 2}), + config.compute_config_object(file_path), ) # global ignores file_path = "s3://wq-services/datacubes/chl-2.txt" self.assertEqual( - Config(settings={"a": 2, "b": 1}), - config_list.compute_config(file_path), + ConfigObject(settings={"a": 2, "b": 1}), + config.compute_config_object(file_path), ) file_path = "s3://wq-services/datacubes/config.yaml" self.assertEqual( None, - config_list.compute_config(file_path), + config.compute_config_object(file_path), ) def test_split_global_filter(self): - config_list = ConfigList( + config = Config( [ - Config(files=["**/*.hdf"]), # global file - Config(ignores=["**/chl-?.txt"]), # global ignores - Config(ignores=["**/chl-?.*"], settings={"a": 2}), - Config(settings={"a": 1, "b": 1}), - Config(files=["**/datacubes/*.zarr"], settings={"b": 2}), + ConfigObject(files=["**/*.hdf"]), # global file + ConfigObject(ignores=["**/chl-?.txt"]), # global ignores + ConfigObject(ignores=["**/chl-?.*"], settings={"a": 2}), + ConfigObject(settings={"a": 1, "b": 1}), + ConfigObject(files=["**/datacubes/*.zarr"], settings={"b": 2}), ] ) - new_config_list, file_filter = config_list.split_global_filter() + new_config, file_filter = config.split_global_filter() self.assertEqual( FileFilter.from_patterns(["**/*.hdf"], ["**/chl-?.txt"]), file_filter, ) - self.assertEqual(3, len(new_config_list.configs)) + self.assertEqual(3, len(new_config.objects)) - new_config_list, file_filter = config_list.split_global_filter( + new_config, file_filter = config.split_global_filter( default=FileFilter.from_patterns(["**/*.h5"], None) ) self.assertEqual( FileFilter.from_patterns(["**/*.h5", "**/*.hdf"], ["**/chl-?.txt"]), file_filter, ) - self.assertEqual(3, len(new_config_list.configs)) + self.assertEqual(3, len(new_config.objects)) diff --git a/tests/test_examples.py b/tests/test_examples.py index feb684e..1dd2824 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,27 +1,27 @@ import unittest from unittest import TestCase -from xrlint.config import ConfigList +from xrlint.config import Config from xrlint.rule import RuleOp from xrlint.util.importutil import import_value class ExamplesTest(TestCase): def test_plugin_config(self): - config_list, _ = import_value( - "examples.plugin_config", "export_config", factory=ConfigList.from_value + config, _ = import_value( + "examples.plugin_config", "export_config", factory=Config.from_value ) - self.assertIsInstance(config_list, ConfigList) - self.assertEqual(3, len(config_list.configs)) + self.assertIsInstance(config, Config) + self.assertEqual(3, len(config.objects)) def test_virtual_plugin_config(self): - config_list, _ = import_value( + config, _ = import_value( "examples.virtual_plugin_config", "export_config", - factory=ConfigList.from_value, + factory=Config.from_value, ) - self.assertIsInstance(config_list, ConfigList) - self.assertEqual(3, len(config_list.configs)) + self.assertIsInstance(config, Config) + self.assertEqual(3, len(config.objects)) def test_rule_testing(self): from examples.rule_testing import GoodTitle, GoodTitleTest diff --git a/tests/test_linter.py b/tests/test_linter.py index eab2d35..6f63385 100644 --- a/tests/test_linter.py +++ b/tests/test_linter.py @@ -3,7 +3,7 @@ import xarray as xr -from xrlint.config import Config, ConfigList +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, DataArrayNode, DatasetNode @@ -16,38 +16,38 @@ class LinterTest(TestCase): def test_default_config_is_empty(self): linter = Linter() - self.assertEqual(ConfigList(), linter.config) + self.assertEqual(Config(), linter.config) def test_new_linter(self): linter = new_linter() self.assertIsInstance(linter, Linter) - self.assertEqual(1, len(linter.config.configs)) - config = linter.config.configs[0] - self.assertIsInstance(config.plugins, dict) - self.assertEqual({CORE_PLUGIN_NAME}, set(config.plugins.keys())) - self.assertEqual(None, config.rules) + self.assertEqual(1, len(linter.config.objects)) + config_obj = linter.config.objects[0] + self.assertIsInstance(config_obj.plugins, dict) + self.assertEqual({CORE_PLUGIN_NAME}, set(config_obj.plugins.keys())) + self.assertEqual(None, config_obj.rules) def test_new_linter_recommended(self): linter = new_linter("recommended") self.assertIsInstance(linter, Linter) - self.assertEqual(2, len(linter.config.configs)) - config0 = linter.config.configs[0] - config1 = linter.config.configs[1] - self.assertIsInstance(config0.plugins, dict) - self.assertEqual({CORE_PLUGIN_NAME}, set(config0.plugins.keys())) - self.assertIsInstance(config1.rules, dict) - self.assertIn("coords-for-dims", config1.rules) + self.assertEqual(2, len(linter.config.objects)) + config_obj_0 = linter.config.objects[0] + config_obj_1 = linter.config.objects[1] + self.assertIsInstance(config_obj_0.plugins, dict) + self.assertEqual({CORE_PLUGIN_NAME}, set(config_obj_0.plugins.keys())) + self.assertIsInstance(config_obj_1.rules, dict) + self.assertIn("coords-for-dims", config_obj_1.rules) def test_new_linter_all(self): linter = new_linter("all") self.assertIsInstance(linter, Linter) - self.assertEqual(2, len(linter.config.configs)) - config0 = linter.config.configs[0] - config1 = linter.config.configs[1] - self.assertIsInstance(config0.plugins, dict) - self.assertEqual({CORE_PLUGIN_NAME}, set(config0.plugins.keys())) - self.assertIsInstance(config1.rules, dict) - self.assertIn("coords-for-dims", config1.rules) + self.assertEqual(2, len(linter.config.objects)) + config_obj_0 = linter.config.objects[0] + config_obj_1 = linter.config.objects[1] + self.assertIsInstance(config_obj_0.plugins, dict) + self.assertEqual({CORE_PLUGIN_NAME}, set(config_obj_0.plugins.keys())) + self.assertIsInstance(config_obj_1.rules, dict) + self.assertIn("coords-for-dims", config_obj_1.rules) class LinterVerifyConfigTest(TestCase): @@ -55,7 +55,7 @@ def test_config_with_config_list(self): linter = new_linter() result = linter.verify_dataset( xr.Dataset(), - config=ConfigList.from_value([{"rules": {"no-empty-attrs": 2}}]), + config=Config.from_value([{"rules": {"no-empty-attrs": 2}}]), ) self.assert_result_ok(result, "Missing metadata, attributes are empty.") @@ -141,7 +141,7 @@ def postprocess( ) -> list[Message]: return messages[0] + messages[1] - config = Config(plugins={"test": plugin}) + config = ConfigObject(plugins={"test": plugin}) self.linter = Linter(config) super().setUp() @@ -153,7 +153,7 @@ def test_rules_are_ok(self): "data-var-dim-must-have-coord", "dataset-without-data-vars", ], - list(self.linter.config.configs[0].plugins["test"].rules.keys()), + list(self.linter.config.objects[0].plugins["test"].rules.keys()), ) def test_linter_respects_rule_severity_error(self): @@ -162,7 +162,7 @@ def test_linter_respects_rule_severity_error(self): ) self.assertEqual( Result( - result.config, + config_object=result.config_object, file_path="", warning_count=0, error_count=1, @@ -187,7 +187,7 @@ def test_linter_respects_rule_severity_warn(self): ) self.assertEqual( Result( - result.config, + config_object=result.config_object, file_path="", warning_count=1, error_count=0, @@ -212,7 +212,7 @@ def test_linter_respects_rule_severity_off(self): ) self.assertEqual( Result( - result.config, + config_object=result.config_object, file_path="", warning_count=0, error_count=0, @@ -279,7 +279,7 @@ def test_linter_real_life_scenario(self): ) self.assertEqual( Result( - result.config, + config_object=result.config_object, file_path="chl-tsm.zarr", warning_count=1, error_count=3, diff --git a/tests/test_result.py b/tests/test_result.py index 6d553e8..7116d08 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -1,6 +1,6 @@ from unittest import TestCase -from xrlint.config import Config +from xrlint.config import ConfigObject from xrlint.plugin import new_plugin from xrlint.result import ( Message, @@ -25,28 +25,28 @@ class MyRule1(RuleOp): class MyRule2(RuleOp): pass - config = Config(plugins={"test": plugin}) + config_obj = ConfigObject(plugins={"test": plugin}) rules_meta = get_rules_meta_for_results( results=[ Result.new( - config, - "test.zarr", - [Message(message="m 1", rule_id="test/my-rule-1")], + config_object=config_obj, + file_path="test.zarr", + messages=[Message(message="m 1", rule_id="test/my-rule-1")], ), Result.new( - config, - "test.zarr", - [Message(message="m 2", rule_id="test/my-rule-2")], + config_object=config_obj, + file_path="test.zarr", + messages=[Message(message="m 2", rule_id="test/my-rule-2")], ), Result.new( - config, - "test.zarr", - [Message(message="m 3", rule_id="test/my-rule-1")], + config_object=config_obj, + file_path="test.zarr", + messages=[Message(message="m 3", rule_id="test/my-rule-1")], ), Result.new( - config, - "test.zarr", - [Message(message="m 4", rule_id="test/my-rule-2")], + config_object=config_obj, + file_path="test.zarr", + messages=[Message(message="m 4", rule_id="test/my-rule-2")], ), ] ) @@ -63,18 +63,18 @@ class MyRule2(RuleOp): def test_repr_html(self): result = Result.new( - Config(), - "test.zarr", - [], + config_object=ConfigObject(), + file_path="test.zarr", + messages=[], ) html = result._repr_html_() self.assertIsInstance(html, str) self.assertEqual('

test.zarr - ok

\n', html) result = Result.new( - Config(), - "test.zarr", - [Message(message="m 1", rule_id="test/my-rule-1")], + config_object=ConfigObject(), + file_path="test.zarr", + messages=[Message(message="m 1", rule_id="test/my-rule-1")], ) html = result._repr_html_() self.assertIsInstance(html, str) diff --git a/xrlint/_linter/rulectx.py b/xrlint/_linter/rulectx.py index c13e821..e8e14b2 100644 --- a/xrlint/_linter/rulectx.py +++ b/xrlint/_linter/rulectx.py @@ -3,7 +3,7 @@ import xarray as xr -from xrlint.config import Config +from xrlint.config import ConfigObject from xrlint.constants import NODE_ROOT_NAME, SEVERITY_ERROR from xrlint.node import Node from xrlint.result import Message, Suggestion @@ -13,7 +13,7 @@ class RuleContextImpl(RuleContext): def __init__( self, - config: Config, + config: ConfigObject, dataset: xr.Dataset, file_path: str, file_index: int | None, @@ -32,7 +32,7 @@ def __init__( self.node: Node | None = None @property - def config(self) -> Config: + def config(self) -> ConfigObject: return self._config @property diff --git a/xrlint/_linter/verify.py b/xrlint/_linter/verify.py index d0c473c..8b92397 100644 --- a/xrlint/_linter/verify.py +++ b/xrlint/_linter/verify.py @@ -2,7 +2,7 @@ import xarray as xr -from xrlint.config import Config +from xrlint.config import ConfigObject from xrlint.result import Message, Result from ..constants import NODE_ROOT_NAME @@ -10,54 +10,54 @@ from .rulectx import RuleContextImpl -def verify_dataset(config: Config, dataset: Any, file_path: str): - assert isinstance(config, Config) +def verify_dataset(config_obj: ConfigObject, dataset: Any, file_path: str): + assert isinstance(config_obj, ConfigObject) assert dataset is not None assert isinstance(file_path, str) if isinstance(dataset, xr.Dataset): - messages = _verify_dataset(config, dataset, file_path, None) + messages = _verify_dataset(config_obj, dataset, file_path, None) else: - messages = _open_and_verify_dataset(config, dataset, file_path) - return Result.new(config=config, messages=messages, file_path=file_path) + messages = _open_and_verify_dataset(config_obj, dataset, file_path) + return Result.new(config_object=config_obj, messages=messages, file_path=file_path) def _verify_dataset( - config: Config, + config_obj: ConfigObject, dataset: xr.Dataset, file_path: str, file_index: int | None, ) -> list[Message]: - assert isinstance(config, Config) + assert isinstance(config_obj, ConfigObject) assert isinstance(dataset, xr.Dataset) assert isinstance(file_path, str) - if not config.rules: + if not config_obj.rules: return [new_fatal_message("No rules configured or applicable.")] - context = RuleContextImpl(config, dataset, file_path, file_index) - for rule_id, rule_config in config.rules.items(): + context = RuleContextImpl(config_obj, dataset, file_path, file_index) + for rule_id, rule_config in config_obj.rules.items(): with context.use_state(rule_id=rule_id): apply_rule(context, rule_id, rule_config) return context.messages def _open_and_verify_dataset( - config: Config, ds_source: Any, file_path: str + config_obj: ConfigObject, ds_source: Any, file_path: str ) -> list[Message]: - assert isinstance(config, Config) + assert isinstance(config_obj, ConfigObject) assert ds_source is not None assert isinstance(file_path, str) - opener_options = config.opener_options or {} - if config.processor is not None: - processor_op = config.get_processor_op(config.processor) + opener_options = config_obj.opener_options or {} + if config_obj.processor is not None: + processor_op = config_obj.get_processor_op(config_obj.processor) try: ds_path_list = processor_op.preprocess(file_path, opener_options) except (OSError, ValueError, TypeError) as e: return [new_fatal_message(str(e))] return processor_op.postprocess( [ - _verify_dataset(config, ds, path, i) + _verify_dataset(config_obj, ds, path, i) for i, (ds, path) in enumerate(ds_path_list) ], file_path, @@ -68,7 +68,7 @@ def _open_and_verify_dataset( except (OSError, ValueError, TypeError) as e: return [new_fatal_message(str(e))] with dataset: - return _verify_dataset(config, dataset, file_path, None) + return _verify_dataset(config_obj, dataset, file_path, None) def _open_dataset( diff --git a/xrlint/all.py b/xrlint/all.py index 9b96110..0091c8e 100644 --- a/xrlint/all.py +++ b/xrlint/all.py @@ -1,5 +1,5 @@ from xrlint.cli.engine import XRLint -from xrlint.config import Config, ConfigList +from xrlint.config import Config, ConfigLike, ConfigObject, ConfigObjectLike from xrlint.formatter import ( Formatter, FormatterContext, @@ -33,7 +33,9 @@ __all__ = [ "XRLint", "Config", - "ConfigList", + "ConfigLike", + "ConfigObject", + "ConfigObjectLike", "Linter", "new_linter", "EditInfo", diff --git a/xrlint/cli/config.py b/xrlint/cli/config.py index f9cf0b5..9e4ef41 100644 --- a/xrlint/cli/config.py +++ b/xrlint/cli/config.py @@ -5,19 +5,19 @@ import fsspec -from xrlint.config import ConfigList +from xrlint.config import Config from xrlint.util.formatting import format_message_type_of from xrlint.util.importutil import ValueImportError, import_value -def read_config_list(config_path: str | Path | PathLike[str]) -> ConfigList: - """Read configuration list from configuration file. +def read_config(config_path: str | Path | PathLike[str]) -> Config: + """Read configuration from configuration file. Args: config_path: configuration file path. Returns: - A configuration list instance. + A `Config` instance. Raises: TypeError: if `config_path` is not a path-like object @@ -31,19 +31,19 @@ def read_config_list(config_path: str | Path | PathLike[str]) -> ConfigList: ) try: - config_list_like = _read_config_list_like(str(config_path)) + config_like = _read_config_like(str(config_path)) except FileNotFoundError: raise except OSError as e: raise ConfigError(config_path, e) from e try: - return ConfigList.from_value(config_list_like) + return Config.from_value(config_like) except (ValueError, TypeError) as e: raise ConfigError(config_path, e) from e -def _read_config_list_like(config_path: str) -> Any: +def _read_config_like(config_path: str) -> Any: if config_path.endswith(".yml") or config_path.endswith(".yaml"): return _read_config_yaml(config_path) if config_path.endswith(".json"): @@ -88,7 +88,7 @@ def _read_config_python(config_path: str) -> Any: return import_value( module_name, "export_config", - factory=ConfigList.from_value, + factory=Config.from_value, )[0] except ValueImportError as e: raise ConfigError(config_path, e) from e diff --git a/xrlint/cli/engine.py b/xrlint/cli/engine.py index 320e645..88b3431 100644 --- a/xrlint/cli/engine.py +++ b/xrlint/cli/engine.py @@ -4,9 +4,11 @@ import click import fsspec +import fsspec.core +import fsspec.implementations.local import yaml -from xrlint.cli.config import ConfigError, read_config_list +from xrlint.cli.config import ConfigError, read_config from xrlint.cli.constants import ( DEFAULT_CONFIG_FILE_YAML, DEFAULT_CONFIG_FILES, @@ -16,7 +18,7 @@ DEFAULT_OUTPUT_FORMAT, INIT_CONFIG_YAML, ) -from xrlint.config import Config, ConfigList, get_core_config +from xrlint.config import Config, ConfigLike, ConfigObject, get_core_config_object from xrlint.formatter import FormatterContext from xrlint.formatters import export_formatters from xrlint.linter import Linter @@ -56,7 +58,7 @@ def __init__( self.output_styled = output_styled self.max_warnings = max_warnings self._result_stats = ResultStats() - self.config_list = ConfigList() + self.config = Config() @property def max_warnings_exceeded(self) -> bool: @@ -68,12 +70,17 @@ def result_stats(self) -> ResultStats: """Get current result statistics.""" return self._result_stats - def load_config_list(self) -> None: - """Load configuration list. + def init_config(self, *configs: ConfigLike) -> None: + """Initialize configuration. The function will load the configuration list from a specified configuration file, if any. Otherwise, it will search for the default configuration files in the current working directory. + + Args: + *configs: Variable number of configuration-like arguments. + For more information see the + [ConfigLike][xrlint.config.ConfigLike] type alias. """ plugins = {} for plugin_spec in self.plugin_specs: @@ -85,38 +92,39 @@ def load_config_list(self) -> None: rule = yaml.load(rule_spec, Loader=yaml.SafeLoader) rules.update(rule) - config_list = None + config = None if self.config_path: try: - config_list = read_config_list(self.config_path) + config = read_config(self.config_path) except (FileNotFoundError, ConfigError) as e: raise click.ClickException(f"{e}") from e elif not self.no_config_lookup: for config_path in DEFAULT_CONFIG_FILES: try: - config_list = read_config_list(config_path) + config = read_config(config_path) break except FileNotFoundError: pass except ConfigError as e: raise click.ClickException(f"{e}") from e - - if config_list is None: - click.echo("Warning: no configuration file found.") - - core_config = get_core_config() - core_config.plugins.update(plugins) - configs = [core_config] - if config_list is not None: - configs += config_list.configs + if config is None: + click.echo("Warning: no configuration file found.") + + core_config_obj = get_core_config_object() + core_config_obj.plugins.update(plugins) + base_configs = [core_config_obj] + if config is not None: + base_configs += config.objects if rules: - configs += [{"rules": rules}] + base_configs += [{"rules": rules}] - self.config_list = ConfigList.from_value(configs) + self.config = Config.from_config(*base_configs, *configs) + if not self.config.objects: + raise click.ClickException("no configuration provided") - def get_config_for_file(self, file_path: str) -> Config | None: - """Compute configuration for the given file. + def compute_config_for_file(self, file_path: str) -> ConfigObject | None: + """Compute the configuration object for the given file. Args: file_path: A file path or URL. @@ -125,19 +133,19 @@ def get_config_for_file(self, file_path: str) -> Config | None: A configuration object or `None` if no item in the configuration list applies. """ - return self.config_list.compute_config(file_path) + return self.config.compute_config_object(file_path) def print_config_for_file(self, file_path: str) -> None: - """Print computed configuration for the given file. + """Print computed configuration object for the given file. Args: file_path: A file path or URL. """ - config = self.get_config_for_file(file_path) + config = self.compute_config_for_file(file_path) config_json_obj = config.to_json() if config is not None else None click.echo(json.dumps(config_json_obj, indent=2)) - def verify_datasets(self, files: Iterable[str]) -> Iterator[Result]: + def verify_files(self, files: Iterable[str]) -> Iterator[Result]: """Verify given files or directories which may also be given as URLs. The function produces a validation result for each file. @@ -151,7 +159,9 @@ def verify_datasets(self, files: Iterable[str]) -> Iterator[Result]: for file_path, config in self.get_files(files): yield linter.verify_dataset(file_path, config=config) - def get_files(self, file_paths: Iterable[str]) -> Iterator[tuple[str, Config]]: + def get_files( + self, file_paths: Iterable[str] + ) -> Iterator[tuple[str, ConfigObject]]: """Provide an iterator for the list of files or directories and their computed configurations. @@ -165,21 +175,21 @@ def get_files(self, file_paths: Iterable[str]) -> Iterator[tuple[str, Config]]: An iterator of pairs comprising a file or directory path and its computed configuration. """ - config_list, global_filter = self.config_list.split_global_filter( + config, global_filter = self.config.split_global_filter( default=DEFAULT_GLOBAL_FILTER ) - def compute_config(p: str): - return config_list.compute_config(p) if global_filter.accept(p) else None + def compute_config_object(p: str): + return config.compute_config_object(p) if global_filter.accept(p) else None for file_path in file_paths: _fs, root = fsspec.url_to_fs(file_path) fs: fsspec.AbstractFileSystem = _fs is_local = isinstance(fs, fsspec.implementations.local.LocalFileSystem) - config = compute_config(file_path) - if config is not None: - yield file_path, config + config_obj = compute_config_object(file_path) + if config_obj is not None: + yield file_path, config_obj continue if fs.isdir(root): @@ -187,7 +197,7 @@ def compute_config(p: str): for d in list(dirs): d_path = f"{path}/{d}" d_path = d_path if is_local else fs.unstrip_protocol(d_path) - c = compute_config(d_path) + c = compute_config_object(d_path) if c is not None: dirs.remove(d) yield d_path, c @@ -195,7 +205,7 @@ def compute_config(p: str): for f in files: f_path = f"{path}/{f}" f_path = f_path if is_local else fs.unstrip_protocol(f_path) - c = compute_config(f_path) + c = compute_config_object(f_path) if c is not None: yield f_path, c diff --git a/xrlint/cli/main.py b/xrlint/cli/main.py index 7e908f8..b330180 100644 --- a/xrlint/cli/main.py +++ b/xrlint/cli/main.py @@ -112,7 +112,7 @@ def main( """Validate the given dataset FILES. Reads configuration from './xrlint_config.*' if such file - exists and unless '--no_config_lookup' is set or '--config' is + exists and unless '--no-config-lookup' is set or '--config' is provided. It then validates each dataset in FILES against the configuration. The default dataset patters are '**/*.zarr' and '**/.nc'. @@ -146,13 +146,13 @@ def main( ) if inspect_path: - cli_engine.load_config_list() + cli_engine.init_config() cli_engine.print_config_for_file(inspect_path) return if files: - cli_engine.load_config_list() - results = cli_engine.verify_datasets(files) + cli_engine.init_config() + results = cli_engine.verify_files(files) report = cli_engine.format_results(results) cli_engine.write_report(report) diff --git a/xrlint/config.py b/xrlint/config.py index 10f523c..572a15f 100644 --- a/xrlint/config.py +++ b/xrlint/config.py @@ -1,6 +1,7 @@ +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from functools import cached_property -from typing import TYPE_CHECKING, Any, Sequence, Union +from typing import TYPE_CHECKING, Any, TypeAlias, Union from xrlint.constants import CORE_PLUGIN_NAME from xrlint.util.constructible import MappingConstructible, ValueConstructible @@ -15,6 +16,30 @@ from xrlint.rule import Rule, RuleConfig +ConfigObjectLike: TypeAlias = Union[ + "ConfigObject", + Mapping[str, Any], + None, +] +"""Type alias for values that can represent configuration objects. +Can be either a [ConfigObject][xrlint.config.ConfigObject] instance, +or a mapping (e.g. `dict`) with properties defined by `ConfigObject`, +or `None` (empty configuration object). +""" + +ConfigLike: TypeAlias = Union[ + "Config", + ConfigObjectLike, + str, + Sequence[ConfigObjectLike | str], +] +"""Type alias for values that can represent configurations. +Can be either a [Config][xrlint.config.Config] instance, +or a [configuration object like][xrlint.config.ConfigObjectLike] value, +or a named plugin configuration, or a sequence of the latter two. +""" + + def get_core_plugin() -> "Plugin": """Get the fully imported, populated core plugin.""" from xrlint.plugins.core import export_plugin @@ -22,13 +47,13 @@ def get_core_plugin() -> "Plugin": return export_plugin() -def get_core_config() -> "Config": +def get_core_config_object() -> "ConfigObject": """Create a configuration object that includes the core plugin. Returns: A new `Config` object """ - return Config(plugins={CORE_PLUGIN_NAME: get_core_plugin()}) + return ConfigObject(plugins={CORE_PLUGIN_NAME: get_core_plugin()}) def split_config_spec(config_spec: str) -> tuple[str, str]: @@ -42,24 +67,15 @@ def split_config_spec(config_spec: str) -> tuple[str, str]: ) -def merge_configs( - config1: Union["Config", dict[str, Any], None], - config2: Union["Config", dict[str, Any], None], -) -> "Config": - """Merge two configuration objects and return the result.""" - config1 = Config.from_value(config1) - config2 = Config.from_value(config2) - return config1.merge(config2) - - @dataclass(frozen=True, kw_only=True) -class Config(MappingConstructible, JsonSerializable): +class ConfigObject(MappingConstructible, JsonSerializable): """Configuration object. - A configuration object contains all the information XRLint - needs to execute on a set of dataset files. + + Configuration objects are the items managed by a + [configuration][xrlint.config.Config]. You should not use the class constructor directly. - Instead, use the `Config.from_value()` function. + Instead, use the `ConfigObject.from_value()` function. """ name: str | None = None @@ -187,8 +203,8 @@ def get_processor_op( raise ValueError(f"unknown processor {processor_spec!r}") return processor.op_class() - def merge(self, config: "Config", name: str = None) -> "Config": - return Config( + def merge(self, config: "ConfigObject", name: str = None) -> "ConfigObject": + return ConfigObject( name=name, files=self._merge_pattern_lists(self.files, config.files), ignores=self._merge_pattern_lists(self.ignores, config.ignores), @@ -249,8 +265,8 @@ def merge_items(_p1: Plugin, p2: Plugin) -> Plugin: return merge_dicts(plugins1, plugins2, merge_items=merge_items) @classmethod - def _from_none(cls, value_name: str) -> "Config": - return Config() + def _from_none(cls, value_name: str) -> "ConfigObject": + return ConfigObject() @classmethod def forward_refs(cls) -> dict[str, type]: @@ -268,42 +284,43 @@ def forward_refs(cls) -> dict[str, type]: @classmethod def value_name(cls) -> str: - return "config" + return "config_obj" @classmethod def value_type_name(cls) -> str: - return "Config | dict | None" + return "ConfigObject | dict | None" @dataclass(frozen=True) -class ConfigList(ValueConstructible, JsonSerializable): - """A holder for a list of configuration objects of - type [Config][xrlint.config.Config]. +class Config(ValueConstructible, JsonSerializable): + """Represents a XRLint configuration. + A `Config` instance basically manages a sequence of + [configuration objects][xrlint.config.ConfigObject]. You should not use the class constructor directly. - Instead, use the `ConfigList.from_value()` function. + Instead, use the `Config.from_value()` function. """ - configs: list[Config] = field(default_factory=list) - """The list of configuration objects.""" + objects: list[ConfigObject] = field(default_factory=list) + """The configuration objects.""" def split_global_filter( self, default: FileFilter | None = None - ) -> tuple["ConfigList", FileFilter]: + ) -> tuple["Config", FileFilter]: """Get a global file filter for this configuration list.""" global_filter = FileFilter( default.files if default else (), default.ignores if default else (), ) - configs = [] - for c in self.configs: - if c.empty and not c.file_filter.empty: - global_filter = global_filter.merge(c.file_filter) + objects = [] + for co in self.objects: + if co.empty and not co.file_filter.empty: + global_filter = global_filter.merge(co.file_filter) else: - configs.append(c) - return ConfigList(configs=configs), global_filter + objects.append(co) + return Config(objects=objects), global_filter - def compute_config(self, file_path: str) -> Config | None: + def compute_config_object(self, file_path: str) -> ConfigObject | None: """Compute the configuration object for the given file path. Args: @@ -315,71 +332,102 @@ def compute_config(self, file_path: str) -> Config | None: or intentionally ignored by global `ignores`. """ - config = None - for c in self.configs: - if c.file_filter.empty or c.file_filter.accept(file_path): - config = config.merge(c) if config is not None else c + config_obj = None + for co in self.objects: + if co.file_filter.empty or co.file_filter.accept(file_path): + config_obj = config_obj.merge(co) if config_obj is not None else co - if config is None: + if config_obj is None: return None # Note, computed configurations do not have "files" and "ignores" - return Config( - linter_options=config.linter_options, - opener_options=config.opener_options, - processor=config.processor, - plugins=config.plugins, - rules=config.rules, - settings=config.settings, + return ConfigObject( + linter_options=config_obj.linter_options, + opener_options=config_obj.opener_options, + processor=config_obj.processor, + plugins=config_obj.plugins, + rules=config_obj.rules, + settings=config_obj.settings, ) @classmethod - def from_value(cls, value: Any, value_name: str | None = None) -> "ConfigList": - """Convert given `value` into a `ConfigList` object. + def from_config( + cls, + *configs: ConfigLike, + value_name: str | None = None, + ) -> "Config": + """Convert variable arguments of configuration-like objects + into a new `Config` instance. + + Args: + *configs: Variable number of configuration-like arguments. + For more information see the + [ConfigLike][xrlint.config.ConfigLike] type alias. + value_name: The value's name used for reporting errors. + + Returns: + A new `Config` instance. + """ + value_name = value_name or cls.value_name() + objects: list[ConfigObject] = [] + plugins: dict[str, Plugin] = {} + for i, config_like in enumerate(configs): + new_objects = None + if isinstance(config_like, str): + if CORE_PLUGIN_NAME not in plugins: + plugins.update({CORE_PLUGIN_NAME: get_core_plugin()}) + new_objects = cls._get_named_config(config_like, plugins).objects + elif isinstance(config_like, Config): + new_objects = config_like.objects + elif isinstance(config_like, (list, tuple)): + new_objects = cls.from_config( + *config_like, value_name=f"{value_name}[{i}]" + ).objects + elif config_like: + new_objects = [ + ConfigObject.from_value( + config_like, value_name=f"{value_name}[{i}]" + ) + ] + if new_objects: + for co in new_objects: + objects.append(co) + plugins.update(co.plugins if co.plugins else {}) + return cls(objects=objects) + + @classmethod + def from_value(cls, value: ConfigLike, value_name: str | None = None) -> "Config": + """Convert given `value` into a `Config` object. - If `value` is already a `ConfigList` then it is returned as-is. + If `value` is already a `Config` then it is returned as-is. Args: - value: A `ConfigList` object or `list` of items which can be - converted into `Config` objects including configuration - names of type `str`. The latter are resolved against - the plugin configurations seen so far in the list. - value_name: A value's name. + value: A configuration-like value. For more information + see the [ConfigLike][xrlint.config.ConfigLike] type alias. + value_name: The value's name used for reporting errors. Returns: - A `ConfigList` object. + A `Config` object. """ - if isinstance(value, (Config, dict)): - return ConfigList(configs=[Config.from_value(value)]) + if isinstance(value, (ConfigObject, dict)): + return Config(objects=[ConfigObject.from_value(value)]) return super().from_value(value, value_name=value_name) @classmethod - def _from_sequence(cls, value: Sequence, value_name: str) -> "ConfigList": - configs: list[Config] = [] - plugins: dict[str, Plugin] = {} - for item in value: - if isinstance(item, str): - if CORE_PLUGIN_NAME not in plugins: - plugins.update({CORE_PLUGIN_NAME: get_core_plugin()}) - new_configs = cls._get_named_config_list(item, plugins) - else: - new_configs = [Config.from_value(item)] - for config in new_configs: - configs.append(config) - plugins.update(config.plugins if config.plugins else {}) - return ConfigList(configs=configs) + def _from_sequence(cls, value: Sequence, value_name: str) -> "Config": + return cls.from_config(*value, value_name=value_name) @classmethod def value_name(cls) -> str: - return "config_list" + return "config" @classmethod def value_type_name(cls) -> str: - return "ConfigList | list[Config | dict | str]" + return "Config | ConfigObjectLike | str | Sequence[ConfigObjectLike | str]" @classmethod - def _get_named_config_list( + def _get_named_config( cls, config_spec: str, plugins: dict[str, "Plugin"] - ) -> list[Config]: + ) -> "Config": plugin_name, config_name = ( config_spec.split("/", maxsplit=1) if "/" in config_spec @@ -388,4 +436,4 @@ def _get_named_config_list( plugin: Plugin | None = plugins.get(plugin_name) if plugin is None or not plugin.configs or config_name not in plugin.configs: raise ValueError(f"configuration {config_spec!r} not found") - return ConfigList.from_value(plugin.configs[config_name]).configs + return Config.from_value(plugin.configs[config_name]) diff --git a/xrlint/linter.py b/xrlint/linter.py index fc9f959..c3f6dfc 100644 --- a/xrlint/linter.py +++ b/xrlint/linter.py @@ -4,74 +4,64 @@ import xarray as xr -from xrlint.config import Config, ConfigList, get_core_config +from xrlint.config import Config, ConfigLike, get_core_config_object from xrlint.result import Result from ._linter.verify import new_fatal_message, verify_dataset from .constants import MISSING_DATASET_FILE_PATH -def new_linter( - *configs: Config | dict[str, Any] | str | None, - **config_kwargs: Any, -) -> "Linter": - """Create a new `Linter` with the given configuration. +def new_linter(*configs: ConfigLike, **config_props: Any) -> "Linter": + """Create a new `Linter` with the core plugin included and the + given additional configuration. Args: - configs: Configuration objects or named configurations. - Use `"recommended"` if the recommended configuration - of the builtin rules should be used, or `"all"` if all rules - shall be used. - config_kwargs: Individual [Config][xrlint.config.Config] properties - of an additional configuration object. + *configs: Variable number of configuration-like arguments. + For more information see the + [ConfigLike][xrlint.config.ConfigLike] type alias. + **config_props: Individual configuration object properties. + For more information refer to the properties of a + [ConfigObject][xrlint.config.ConfigObject]. Returns: A new linter instance """ - return Linter(get_core_config(), *configs, **config_kwargs) + return Linter(get_core_config_object(), *configs, **config_props) class Linter: """The linter. Using the constructor directly creates an empty linter - with no configuration - even without default rules loaded. - If you want a linter with core rules loaded - use the `new_linter()` function. + with no configuration - even without the core plugin and + its predefined rule configurations. + If you want a linter with core plugin included use the + `new_linter()` function. Args: - configs: Configuration objects or named configurations. - Use `"recommended"` if the recommended configuration - of the builtin rules should be used, or `"all"` if all rules - shall be used. - config_kwargs: Individual [Config][xrlint.config.Config] properties - of an additional configuration object. + *configs: Variable number of configuration-like arguments. + For more information see the + [ConfigLike][xrlint.config.ConfigLike] type alias. + **config_props: Individual configuration object properties. + For more information refer to the properties of a + [ConfigObject][xrlint.config.ConfigObject]. """ - def __init__( - self, - *configs: Config | dict[str, Any] | None, - **config_kwargs: Any, - ): - _configs = [] - if configs: - _configs.extend(configs) - if config_kwargs: - _configs.append(config_kwargs) - self._config_list = ConfigList.from_value(_configs) + def __init__(self, *configs: ConfigLike, **config_props: Any): + self._config = Config.from_config(*configs, config_props) @property - def config(self) -> ConfigList: + def config(self) -> Config: """Get this linter's configuration.""" - return self._config_list + return self._config def verify_dataset( self, dataset: Any, *, file_path: str | None = None, - config: ConfigList | list | Config | dict[str, Any] | str | None = None, - **config_kwargs: Any, + config: ConfigLike = None, + **config_props: Any, ) -> Result: """Verify a dataset. @@ -81,10 +71,12 @@ def verify_dataset( using `xarray.open_dataset()`. file_path: Optional file path used for formatting messages. Useful if `dataset` is not a file path. - config: Optional configuration object or a list of configuration - objects that will be added to the current linter configuration. - config_kwargs: Individual [Config][xrlint.config.Config] properties - of an additional configuration object. + config: Optional configuration-like value. + For more information see the + [ConfigLike][xrlint.config.ConfigLike] type alias. + **config_props: Individual configuration object properties. + For more information refer to the properties of a + [ConfigObject][xrlint.config.ConfigObject]. Returns: Result of the verification. @@ -95,20 +87,11 @@ def verify_dataset( else: file_path = file_path or _get_file_path_for_source(dataset) - config_list = self._config_list - if isinstance(config, ConfigList): - config_list = ConfigList.from_value([*config_list.configs, *config.configs]) - elif isinstance(config, list): - config_list = ConfigList.from_value([*config_list.configs, *config]) - elif config: - config_list = ConfigList.from_value([*config_list.configs, config]) - if config_kwargs: - config_list = ConfigList.from_value([*config_list.configs, config_kwargs]) - - config = config_list.compute_config(file_path) - if config is None: + config = Config.from_config(self._config, config, config_props) + config_obj = config.compute_config_object(file_path) + if config_obj is None: return Result.new( - config=config, + config_object=None, file_path=file_path, messages=[ new_fatal_message( @@ -117,7 +100,7 @@ def verify_dataset( ], ) - return verify_dataset(config, dataset, file_path) + return verify_dataset(config_obj, dataset, file_path) def _get_file_path_for_dataset(dataset: xr.Dataset) -> str: diff --git a/xrlint/plugin.py b/xrlint/plugin.py index 670ef7c..0204111 100644 --- a/xrlint/plugin.py +++ b/xrlint/plugin.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field from typing import Any, Callable, Literal, Type -from xrlint.config import Config, ConfigList +from xrlint.config import Config, ConfigLike, ConfigObject from xrlint.processor import Processor, ProcessorOp, define_processor from xrlint.rule import Rule, RuleOp, define_rule from xrlint.util.constructible import MappingConstructible @@ -54,7 +54,7 @@ class Plugin(MappingConstructible, JsonSerializable): """A dictionary containing named processors. """ - configs: dict[str, list[Config]] = field(default_factory=dict) + configs: dict[str, list[ConfigObject]] = field(default_factory=dict) """A dictionary containing named configuration lists.""" def define_rule( @@ -103,26 +103,21 @@ def define_processor( registry=self.processors, ) - def define_config( - self, - name: str, - value: list[Config | dict[str, Any]] | Config | dict[str, Any], - ) -> list[Config]: + def define_config(self, name: str, config: ConfigLike) -> Config: """Define a named configuration. Args: name: The name of the configuration. - value: The configuration-like object or list. - A configuration-like object is either a - [Config][xrlint.config.Config] or a `dict` that - represents a configuration. + config: A configuration-like value. + For more information see the + [ConfigLike][xrlint.config.ConfigLike] type alias. Returns: - A list of `Config` objects. + The configuration. """ - configs = ConfigList.from_value(value).configs - self.configs[name] = configs - return configs + config = Config.from_value(config) + self.configs[name] = list(config.objects) + return config @classmethod def _from_str(cls, value: str, value_name: str) -> "Plugin": @@ -152,7 +147,7 @@ def new_plugin( ref: str | None = None, rules: dict[str, Rule] | None = None, processors: dict[str, Processor] | None = None, - configs: dict[str, Config] | None = None, + configs: dict[str, ConfigObject] | None = None, ) -> Plugin: """Create a new plugin object that can contribute rules, processors, and predefined configurations to XRLint. diff --git a/xrlint/result.py b/xrlint/result.py index 0614506..3f321d1 100644 --- a/xrlint/result.py +++ b/xrlint/result.py @@ -16,7 +16,7 @@ from xrlint.util.serializable import JsonSerializable if TYPE_CHECKING: # pragma: no cover - from xrlint.config import Config + from xrlint.config import ConfigObject from xrlint.rule import RuleMeta @@ -85,12 +85,14 @@ class Message(JsonSerializable): """ -@dataclass() +@dataclass(kw_only=True) class Result(JsonSerializable): """The aggregated information of linting a dataset.""" - config: Union["Config", None] = None - """Configuration.""" + config_object: Union["ConfigObject", None] = None + """The configuration object that produced this result + together with `file_path`. + """ file_path: str = MISSING_DATASET_FILE_PATH """The absolute path to the file of this result. @@ -126,12 +128,12 @@ class Result(JsonSerializable): @classmethod def new( cls, - config: Union["Config", None] = None, + config_object: Union["ConfigObject", None] = None, file_path: str | None = None, messages: list[Message] | None = None, ): result = Result( - config=config, + config_object=config_object, file_path=file_path or MISSING_DATASET_FILE_PATH, messages=messages or [], ) @@ -183,7 +185,7 @@ def get_rules_meta_for_results(results: list[Result]) -> dict[str, "RuleMeta"]: for result in results: for message in result.messages: if message.rule_id: - rule = result.config.get_rule(message.rule_id) + rule = result.config_object.get_rule(message.rule_id) rules_meta[message.rule_id] = rule.meta return rules_meta diff --git a/xrlint/testing.py b/xrlint/testing.py index 26825c3..9782ae5 100644 --- a/xrlint/testing.py +++ b/xrlint/testing.py @@ -4,6 +4,7 @@ import xarray as xr +from xrlint.config import ConfigLike from xrlint.constants import SEVERITY_ERROR from xrlint.linter import Linter from xrlint.plugin import new_plugin @@ -14,6 +15,8 @@ _PLUGIN_NAME: Final = "testing" +# TODO: Adapt config argument to Linter(**args, **kwargs) + @dataclass(frozen=True, kw_only=True) class RuleTest: @@ -41,14 +44,20 @@ class RuleTest: class RuleTester: - """Utility that helps to test rules. + """Utility that helps testing rules. Args: - config: optional XRLint configuration. + config: Optional configuration-like value. + For more information see the + [ConfigLike][xrlint.config.ConfigLike] type alias. + **config_props: Individual configuration object properties. + For more information refer to the properties of a + [ConfigObject][xrlint.config.ConfigObject]. """ - def __init__(self, **config: dict[str, Any]): + def __init__(self, *, config: ConfigLike = None, **config_props: Any): self._config = config + self._config_props = config_props def run( self, @@ -86,7 +95,8 @@ def define_test( *, valid: list[RuleTest] | None = None, invalid: list[RuleTest] | None = None, - config: dict[str, Any] | None = None, + config: ConfigLike = None, + **config_props: Any, ) -> Type[unittest.TestCase]: """Create a `unittest.TestCase` class for the given rule and tests. @@ -99,12 +109,17 @@ def define_test( rule_op_class: the class derived from `RuleOp` valid: list of tests that expect no reported problems invalid: list of tests that expect reported problems - config: optional xrlint configuration + config: Optional configuration-like value. + For more information see the + [ConfigLike][xrlint.config.ConfigLike] type alias. + **config_props: Individual configuration object properties. + For more information refer to the properties of a + [ConfigObject][xrlint.config.ConfigObject]. Returns: A new class derived from `unittest.TestCase`. """ - tester = RuleTester(**(config or {})) + tester = RuleTester(config=config, **config_props) tests = tester._create_tests( rule_name, rule_op_class, valid=valid, invalid=invalid ) @@ -181,7 +196,7 @@ def _test_rule( # on the currently configured severity. # There is also no way for a rule to obtain the severity. severity = SEVERITY_ERROR - linter = Linter(**self._config) + linter = Linter(self._config, self._config_props) result = linter.verify_dataset( test.dataset, plugins={ diff --git a/xrlint/version.py b/xrlint/version.py index 45dc5ad..fbf508b 100644 --- a/xrlint/version.py +++ b/xrlint/version.py @@ -1 +1 @@ -version = "0.4.1" +version = "0.5.0.dev0"