diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 06540d2..c9d29cd 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -58,7 +58,7 @@ def xrlint(self, *args: tuple[str, ...]) -> click.testing.Result: runner = CliRunner() result = runner.invoke(main, args) if not isinstance(result.exception, SystemExit): - self.assertIsNone(None, result.exception) + self.assertIsNone(result.exception) return result def test_no_files_no_config(self): diff --git a/tests/test_config.py b/tests/test_config.py index 046c0bd..5d542cf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -64,6 +64,37 @@ def test_from_value_ok(self): ), ) + def test_to_json(self): + config = Config( + name="xXx", + files=["**/*.zarr", "**/*.nc"], + linter_options={"a": 4}, + opener_options={"b": 5}, + settings={"c": 6}, + rules={ + "hello/no-spaces-in-titles": RuleConfig(severity=2), + "hello/time-without-tz": RuleConfig(severity=0), + "hello/no-empty-units": RuleConfig( + severity=1, args=(12,), kwargs={"indent": 4} + ), + }, + ) + self.assertEqual( + { + "name": "xXx", + "files": ["**/*.zarr", "**/*.nc"], + "linter_options": {"a": 4}, + "opener_options": {"b": 5}, + "settings": {"c": 6}, + "rules": { + "hello/no-empty-units": [1, 12, {"indent": 4}], + "hello/no-spaces-in-titles": 2, + "hello/time-without-tz": 0, + }, + }, + config.to_json(), + ) + def test_from_value_fails(self): with pytest.raises( TypeError, diff --git a/tests/test_rule.py b/tests/test_rule.py index d0e31c0..1618d3a 100644 --- a/tests/test_rule.py +++ b/tests/test_rule.py @@ -50,10 +50,63 @@ class MyRule3(RuleOp): with pytest.raises( ValueError, - match="missing rule metadata, apply define_rule\\(\\) to class MyRule3", + match=r"missing rule metadata, apply define_rule\(\) to class MyRule3", ): Rule.from_value(MyRule3) + def test_to_json(self): + class MyRule3(RuleOp): + """This is my 3rd rule.""" + + rule = Rule.from_value("tests.test_rule") + self.assertEqual("tests.test_rule:export_rule", rule.to_json()) + + rule = Rule(meta=RuleMeta(name="r3", ref="mymod.rules:r3"), op_class=MyRule3) + self.assertEqual("mymod.rules:r3", rule.to_json()) + + rule = Rule(meta=RuleMeta(name="r3"), op_class=MyRule3) + self.assertEqual( + { + "meta": {"name": "r3", "version": "0.0.0", "type": "problem"}, + "op_class": ".MyRule3'>", + }, + rule.to_json(), + ) + + +class RuleMetaTest(unittest.TestCase): + def test_from_value(self): + rule_meta = RuleMeta.from_value( + { + "name": "r-4", + "version": "2.0.1", + "type": "suggestion", + "description": "Be nice, always.", + } + ) + self.assertEqual( + RuleMeta( + name="r-4", + version="2.0.1", + type="suggestion", + description="Be nice, always.", + ), + rule_meta, + ) + + def test_to_json(self): + rule_meta = RuleMeta(name="r1", version="0.1.2", description="Nice one.") + self.assertEqual( + { + "name": "r1", + "version": "0.1.2", + "type": "problem", + "description": "Nice one.", + }, + rule_meta.to_json(), + ) + class DefineRuleTest(unittest.TestCase): @@ -79,6 +132,18 @@ def test_with_registry(self): self.assertIs(rule1, registry["my-rule-1"]) self.assertIs(rule2, registry["my-rule-2"]) + # noinspection PyMethodMayBeStatic + def test_fail(self): + with pytest.raises( + TypeError, + match=( + r"component decorated by define_rule\(\)" + r" must be a subclass of RuleOp" + ), + ): + # noinspection PyTypeChecker + define_rule(op_class=DefineRuleTest) + class RuleConfigTest(TestCase): def test_defaults(self): diff --git a/tests/util/test_codec.py b/tests/util/test_codec.py index f7ece14..d86f819 100644 --- a/tests/util/test_codec.py +++ b/tests/util/test_codec.py @@ -9,6 +9,7 @@ get_origin, Mapping, TYPE_CHECKING, + Literal, ) from unittest import TestCase @@ -27,6 +28,20 @@ class UselessContainer(ValueConstructible): pass +@dataclass() +class RequiredPropsContainer(MappingConstructible): + x: float + y: float + z: float + + +class NoTypesContainer(MappingConstructible): + def __init__(self, u, v, w): + self.u = u + self.v = v + self.w = w + + @dataclass() class SimpleTypesContainer(MappingConstructible, JsonSerializable): a: Any = None @@ -35,6 +50,7 @@ class SimpleTypesContainer(MappingConstructible, JsonSerializable): d: float = 0.0 e: str = "abc" f: type = int + g: Literal[0, 1, True, False, "on", "off"] = 0 @dataclass() @@ -52,20 +68,6 @@ class UnionTypesContainer(MappingConstructible, JsonSerializable): m: SimpleTypesContainer | ComplexTypesContainer | None = None -@dataclass() -class RequiredPropsContainer(MappingConstructible, JsonSerializable): - x: float - y: float - z: float - - -@dataclass() -class NoTypesContainer(MappingConstructible, JsonSerializable): - u = 0 - v = 0 - w = 0 - - if TYPE_CHECKING: # make IDEs and flake8 happy from xrlint.rule import RuleConfig @@ -122,13 +124,29 @@ def test_assumptions(self): class JsonSerializableTest(TestCase): def test_simple_ok(self): self.assertEqual( - {"a": None, "b": False, "c": 0, "d": 0.0, "e": "abc", "f": "int"}, + { + "a": None, + "b": False, + "c": 0, + "d": 0.0, + "e": "abc", + "f": "", + "g": 0, + }, SimpleTypesContainer().to_json(), ) self.assertEqual( - {"a": "?", "b": True, "c": 12, "d": 34.56, "e": "uvw", "f": "bool"}, + { + "a": "?", + "b": True, + "c": 12, + "d": 34.56, + "e": "uvw", + "f": "", + "g": "off", + }, SimpleTypesContainer( - a="?", b=True, c=12, d=34.56, e="uvw", f=bool + a="?", b=True, c=12, d=34.56, e="uvw", f=bool, g="off" ).to_json(), ) @@ -144,7 +162,15 @@ def test_complex_ok(self): ) self.assertEqual( { - "p": {"a": None, "b": False, "c": 0, "d": 0.0, "e": "abc", "f": "int"}, + "p": { + "a": None, + "b": False, + "c": 0, + "d": 0.0, + "e": "abc", + "f": "", + "g": 0, + }, "q": {"p": True, "q": False}, "r": { "u": { @@ -153,7 +179,8 @@ def test_complex_ok(self): "c": 0, "d": 0.0, "e": "abc", - "f": "int", + "f": "", + "g": 0, }, "v": { "a": None, @@ -161,19 +188,29 @@ def test_complex_ok(self): "c": 0, "d": 0.0, "e": "abc", - "f": "int", + "f": "", + "g": 0, }, }, "s": [1, 2, 3], "t": [ - {"a": None, "b": False, "c": 5, "d": 6.7, "e": "abc", "f": "int"}, + { + "a": None, + "b": False, + "c": 5, + "d": 6.7, + "e": "abc", + "f": "", + "g": 0, + }, { "a": None, "b": False, "c": 8, "d": 9.1, "e": "abc", - "f": "SimpleTypesContainer", + "f": "", + "g": 0, }, ], "u": None, @@ -193,7 +230,7 @@ class Problematic(JsonSerializable): " None|bool|int|float|str|dict|list|tuple, but got object" ), ): - Problematic(data=object()).to_json(name="problematic") + Problematic(data=object()).to_json(value_name="problematic") class ValueConstructibleTest(TestCase): @@ -263,11 +300,32 @@ def test_useless_fail(self): ): UselessContainer.from_value(UselessContainer, value_name="utc") + def test_required_props_ok(self): + rpc = RequiredPropsContainer.from_value({"x": 12.0, "y": 23.0, "z": 34.0}) + self.assertEqual(RequiredPropsContainer(x=12.0, y=23.0, z=34.0), rpc) + + # noinspection PyMethodMayBeStatic + def test_required_props_fail(self): + with pytest.raises( + TypeError, + match=( + r"missing value for required property rpc.y" + r" of type RequiredPropsContainer \| dict\[str, Any\]" + ), + ): + RequiredPropsContainer.from_value({"x": 12.0, "z": 34.0}, "rpc") + + def test_no_types_ok(self): + ntc = NoTypesContainer.from_value(dict(u=True, v=654, w="abc")) + self.assertEqual(True, ntc.u) + self.assertEqual(654, ntc.v) + self.assertEqual("abc", ntc.w) + class MappingConstructibleTest(TestCase): def test_simple_ok(self): - kwargs = dict(a="?", b=True, c=12, d=34.56, e="uvw", f=bytes) + kwargs = dict(a="?", b=True, c=12, d=34.56, e="uvw", f=bytes, g="on") container = SimpleTypesContainer(**kwargs) self.assertEqual(container, SimpleTypesContainer.from_value(kwargs)) self.assertIs(container, SimpleTypesContainer.from_value(container)) @@ -339,6 +397,12 @@ def test_simple_fail(self): ): SimpleTypesContainer.from_value({"b": None}, value_name="stc") + with pytest.raises( + TypeError, + match=r"stc.g must be one of 0, 1, True, False, 'on', 'off', but got 74", + ): + SimpleTypesContainer.from_value({"g": 74}, value_name="stc") + with pytest.raises( TypeError, match="x is not a member of stc of type SimpleTypesContainer" ): @@ -439,6 +503,7 @@ def test_resolves_types(self): "d", "e", "f", + "g", "p", "q", "r", diff --git a/tests/util/test_todict.py b/tests/util/test_todict.py deleted file mode 100644 index 3fafdb9..0000000 --- a/tests/util/test_todict.py +++ /dev/null @@ -1,36 +0,0 @@ -import dataclasses -import math -import unittest -from functools import cached_property - -from xrlint.util.todict import ToDictMixin - - -@dataclasses.dataclass -class Point(ToDictMixin): - x: float - y: float - - @property - def mh_dist(self) -> float: - return abs(self.x) + abs(self.y) - - @cached_property - def dist(self) -> float: - return math.sqrt(self.x * self.x + self.y * self.y) - - def is_origin(self, eps: float = 1e-10) -> float: - return self.dist < eps - - -class ToDictMixinTest(unittest.TestCase): - def test_to_dict_includes_only_data_fields(self): - self.assertEqual({"x": 12, "y": 32}, Point(12, 32).to_dict()) - - def test_to_dict_excludes_none(self): - # noinspection PyTypeChecker - self.assertEqual({"x": 12}, Point(12, None).to_dict()) - - def test_str_includes_only_data_fields(self): - self.assertEqual("Point(x=13, y=21)", str(Point(13, 21))) - self.assertEqual("Point(x=13, y=21)", repr(Point(13, 21))) diff --git a/xrlint/cli/engine.py b/xrlint/cli/engine.py index 879d349..c7bc7f3 100644 --- a/xrlint/cli/engine.py +++ b/xrlint/cli/engine.py @@ -136,7 +136,7 @@ def print_config_for_file(self, file_path: str) -> None: file_path: A file path. """ config = self.get_config_for_file(file_path) - config_json_obj = config.to_dict() if config is not None else None + 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]: diff --git a/xrlint/config.py b/xrlint/config.py index a695c9a..b8d3750 100644 --- a/xrlint/config.py +++ b/xrlint/config.py @@ -3,9 +3,13 @@ from typing import Any, TYPE_CHECKING, Union, Literal, Sequence from xrlint.constants import CORE_PLUGIN_NAME -from xrlint.util.codec import MappingConstructible, ValueConstructible +from xrlint.util.codec import ( + MappingConstructible, + ValueConstructible, + JsonSerializable, + JsonValue, +) from xrlint.util.filefilter import FileFilter -from xrlint.util.todict import ToDictMixin from xrlint.util.merge import ( merge_arrays, merge_set_lists, @@ -80,7 +84,7 @@ def merge_configs( @dataclass(frozen=True, kw_only=True) -class Config(MappingConstructible, ToDictMixin): +class Config(MappingConstructible, JsonSerializable): """Configuration object. A configuration object contains all the information XRLint needs to execute on a set of dataset files. @@ -301,26 +305,12 @@ def _get_value_name(cls) -> str: def _get_value_type_name(cls) -> str: return "Config | dict | None" - def to_dict(self): - d = super().to_dict() - plugins: dict[str, Plugin] | None = d.get("plugins") - if plugins is not None: - d["plugins"] = {k: v.meta.ref or "?" for k, v in plugins.items()} - rules: dict[str, RuleConfig] | None = d.get("rules") - if rules is not None: - d["rules"] = { - k: ( - v.severity - if not (v.args or v.kwargs) - else [v.severity, v.args, v.kwargs] - ) - for k, v in rules.items() - } - return d + def to_dict(self, value_name: str | None = None) -> dict[str, JsonValue]: + return {k: v for k, v in super().to_dict().items() if v is not None} @dataclass(frozen=True) -class ConfigList(ValueConstructible): +class ConfigList(ValueConstructible, JsonSerializable): """A holder for a list of configuration objects of type [Config][xrlint.config.Config]. diff --git a/xrlint/formatters/json.py b/xrlint/formatters/json.py index ebd5bc9..23aed8a 100644 --- a/xrlint/formatters/json.py +++ b/xrlint/formatters/json.py @@ -35,7 +35,7 @@ def format( omitted_props = {"config"} results_json = { "results": [ - {k: v for k, v in r.to_dict().items() if k not in omitted_props} + {k: v for k, v in r.to_json().items() if k not in omitted_props} for r in results ], } @@ -43,7 +43,7 @@ def format( rules_meta = get_rules_meta_for_results(results) results_json.update( { - "rules_meta": [rm.to_dict() for rm in rules_meta.values()], + "rules_meta": [rm.to_json() for rm in rules_meta.values()], } ) return json.dumps(results_json, indent=self.indent) diff --git a/xrlint/plugin.py b/xrlint/plugin.py index 2b6eb2d..986bd1a 100644 --- a/xrlint/plugin.py +++ b/xrlint/plugin.py @@ -4,12 +4,12 @@ from xrlint.config import Config from xrlint.processor import Processor, ProcessorOp, define_processor from xrlint.rule import Rule, RuleOp, define_rule -from xrlint.util.codec import MappingConstructible +from xrlint.util.codec import MappingConstructible, JsonSerializable, JsonValue from xrlint.util.importutil import import_value @dataclass(kw_only=True) -class PluginMeta(MappingConstructible): +class PluginMeta(MappingConstructible, JsonSerializable): """XRLint plugin metadata.""" name: str @@ -30,7 +30,7 @@ def _get_value_type_name(cls) -> str: @dataclass(frozen=True, kw_only=True) -class Plugin(MappingConstructible): +class Plugin(MappingConstructible, JsonSerializable): """An XRLint plugin.""" meta: PluginMeta @@ -103,3 +103,8 @@ def _from_str(cls, value: str, value_name: str) -> "Plugin": @classmethod def _get_value_type_name(cls) -> str: return "Plugin | dict | str" + + def to_json(self, value_name: str | None = None) -> JsonValue: + if self.meta.ref: + return self.meta.ref + return super().to_json(value_name=value_name) diff --git a/xrlint/result.py b/xrlint/result.py index 5de040f..0203ea2 100644 --- a/xrlint/result.py +++ b/xrlint/result.py @@ -8,9 +8,9 @@ from xrlint.constants import SEVERITY_CODE_TO_NAME, MISSING_DATASET_FILE_PATH from xrlint.constants import SEVERITY_ERROR from xrlint.constants import SEVERITY_WARN +from xrlint.util.codec import JsonSerializable from xrlint.util.formatting import format_problems from xrlint.util.formatting import format_message_type_of -from xrlint.util.todict import ToDictMixin if TYPE_CHECKING: # pragma: no cover from xrlint.config import Config @@ -18,12 +18,12 @@ @dataclass(frozen=True, kw_only=True) -class EditInfo(ToDictMixin): +class EditInfo(JsonSerializable): """Not used yet.""" @dataclass(frozen=True) -class Suggestion(ToDictMixin): +class Suggestion(JsonSerializable): desc: str """Description of the suggestion.""" @@ -54,7 +54,7 @@ def from_value(cls, value: Any, name: str | None = None) -> "Suggestion": @dataclass() -class Message(ToDictMixin): +class Message(JsonSerializable): message: str """The error message.""" @@ -98,7 +98,7 @@ class Message(ToDictMixin): @dataclass() -class Result(ToDictMixin): +class Result(JsonSerializable): """The aggregated information of linting a dataset.""" config: Union["Config", None] = None diff --git a/xrlint/rule.py b/xrlint/rule.py index 92e5297..96a3f83 100644 --- a/xrlint/rule.py +++ b/xrlint/rule.py @@ -12,11 +12,14 @@ ) from xrlint.node import DatasetNode, DataArrayNode, AttrsNode, AttrNode from xrlint.result import Suggestion -from xrlint.util.codec import MappingConstructible, ValueConstructible +from xrlint.util.codec import ( + MappingConstructible, + ValueConstructible, + JsonSerializable, +) from xrlint.util.formatting import format_message_one_of from xrlint.util.importutil import import_value from xrlint.util.naming import to_kebab_case -from xrlint.util.todict import ToDictMixin class RuleContext(ABC): @@ -125,7 +128,7 @@ def attr(self, context: RuleContext, node: AttrNode) -> None: @dataclass(kw_only=True) -class RuleMeta(MappingConstructible, ToDictMixin): +class RuleMeta(MappingConstructible, JsonSerializable): """Rule metadata.""" name: str @@ -180,13 +183,23 @@ class RuleMeta(MappingConstructible, ToDictMixin): by the rule’s implementation and its configured severity. """ + ref: str | None = None + """Reference to the origin.""" + @classmethod def _get_value_type_name(cls) -> str: return "RuleMeta | dict" + def to_dict(self, value_name: str | None = None) -> dict[str, str]: + return { + k: v + for k, v in super().to_dict(value_name=value_name).items() + if v is not None + } + @dataclass(frozen=True) -class Rule(MappingConstructible): +class Rule(MappingConstructible, JsonSerializable): """A rule comprises rule metadata and a reference to the class that implements the rule's logic. @@ -233,9 +246,15 @@ def _from_type(cls, value: Type, value_name: str) -> "Rule": def _get_value_type_name(cls) -> str: return "Rule | dict | str" + # noinspection PyUnusedLocal + def to_json(self, value_name: str | None = None) -> str: + if self.meta.ref: + return self.meta.ref + return super().to_json(value_name=value_name) + @dataclass(frozen=True) -class RuleConfig(ValueConstructible): +class RuleConfig(ValueConstructible, JsonSerializable): """A rule configuration. You should not use the class constructor directly. @@ -314,6 +333,13 @@ def _get_value_name(cls) -> str: def _get_value_type_name(cls) -> str: return "int | str | list" + # noinspection PyUnusedLocal + def to_json(self, value_name: str | None = None) -> int | list: + if not self.args and not self.kwargs: + return self.severity + else: + return [self.severity, *self.args, self.kwargs] + def define_rule( name: str | None = None, @@ -353,7 +379,7 @@ def define_rule( the value of `op_class`. Raises: - TypeError: If either `op_class` or the decorated object is not a + TypeError: If either `op_class` or the decorated object is not a class derived from [RuleOp][xrlint.rule.RuleOp]. """ diff --git a/xrlint/util/codec.py b/xrlint/util/codec.py index a452a74..38116f1 100644 --- a/xrlint/util/codec.py +++ b/xrlint/util/codec.py @@ -14,15 +14,15 @@ get_args, get_type_hints, Optional, + Literal, ) -from xrlint.util.formatting import format_message_type_of - +from xrlint.util.formatting import format_message_type_of, format_message_one_of JSON_VALUE_TYPE_NAME = "None | bool | int | float | str | dict | list" JsonValue: TypeAlias = ( - NoneType | bool | int | float | str | dict[str, "JsonValue"] | list["JsonValue"] + None | bool | int | float | str | dict[str, "JsonValue"] | list["JsonValue"] ) T = TypeVar("T") @@ -31,7 +31,8 @@ class ValueConstructible(Generic[T]): - """Can be used to make data classes constructible from a value. + """A mixin that makes your classes constructible from a single value + of any type. The factory for this purpose is the class method [from_value][xrlint.util.codec.ValueConstructible.from_value]. @@ -41,6 +42,12 @@ class method [from_value][xrlint.util.codec.ValueConstructible.from_value]. def from_value(cls, value: Any, value_name: str | None = None) -> T: """Create an instance of this class from a value. + The default implementation checks if `value` is already an + instance of this class. If so, it is returned unchanged. + + It then delegates to various `_from_()` methods which + all raise `TypeError` by default. + Args: value: The value value_name: An identifier used for error messages. @@ -191,6 +198,12 @@ def _convert_value(cls, value: Any, type_annotation: Any, value_name: str) -> An # therefore return the value as-is return value + if type_origin is Literal: + # Value must be one of literal arguments + if value not in type_args: + raise TypeError(format_message_one_of(value_name, value, type_args)) + return value + if type_origin is Union: # For unions try converting the alternatives. # Return the first successfully converted value. @@ -327,20 +340,26 @@ def _format_type_error(cls, value: Any, value_name: str) -> str: class MappingConstructible(Generic[T], ValueConstructible[T]): - """A `ValueConstructible` that accepts both instances of `T` and - mappings (e.g., `dict`) as input values. + """A mixin that makes your classes constructible from mappings, + such as a `dict`. + + The default implementation checks if `value` is already an + instance of this class. If so, it is returned unchanged. + + It then delegates to various `_from_()` methods which + all raise `TypeError` by default, except for `_from_mapping()`. + The latter is overridden to deserialize the items of the given + mapping into values that will be passed to match constructor + parameters. Type annotations of the parameters will be used + to perform a type-safe conversion of the mapping values. + + The major use case for this is constructing instances of this + class from JSON objects. """ @classmethod def _from_mapping(cls, mapping: Mapping, value_name: str) -> T: - """Create an instance of this class from a mapping value. - - The default implementation treats the mapping as keyword - arguments passed to the class constructor. - - The use case for this is constructing instances of this class - from JSON-objects. - """ + """Create an instance of this class from a mapping value.""" mapping_keys = set(mapping.keys()) properties = cls._get_class_parameters() @@ -428,7 +447,7 @@ def get_class_parameters( resolved_params = {} for i, (name, param) in enumerate(sig.parameters.items()): if i > 0: # Skip `self` - annotation = resolved_hints[name] + annotation = resolved_hints.get(name, Parameter.empty) resolved_params[name] = Parameter( name, param.kind, default=param.default, annotation=annotation ) @@ -437,20 +456,39 @@ def get_class_parameters( class JsonSerializable: + """A mixin that makes your classes serializable to JSON values + and JSON-serializable dictionaries. + + It adds two methods: - def to_json(self, name: str | None = None) -> JsonValue: - return self.to_dict(name=name) + * [to_json][JsonSerializable.to_json] converts to JSON values + * [to_dict][JsonSerializable.to_dict] converts to JSON-serializable + dictionaries - def to_dict(self, name: str | None = None) -> dict[str, JsonValue]: - return self._mapping_to_json(self.__dict__, name or type(self).__name__) + """ + + def to_json(self, value_name: str | None = None) -> JsonValue: + """Convert this object into a JSON value. + + The default implementation calls `self.to_dict()` and returns + its value as-is. + """ + return self.to_dict(value_name=value_name) + + def to_dict(self, value_name: str | None = None) -> dict[str, JsonValue]: + """Convert this object into a JSON-serializable dictionary. + + The default implementation naively serializes the non-protected + attributes of this object's dictionary given by `vars(self)`. + """ + return self._object_to_json(self, value_name or type(self).__name__) @classmethod - def _value_to_json(cls, value: Any, name: str) -> JsonValue: + def _value_to_json(cls, value: Any, value_name: str) -> JsonValue: if value is None: - # noinspection PyTypeChecker return None if isinstance(value, JsonSerializable): - return value.to_json(name=name) + return value.to_json(value_name=value_name) if isinstance(value, bool): return bool(value) if isinstance(value, int): @@ -460,30 +498,41 @@ def _value_to_json(cls, value: Any, name: str) -> JsonValue: if isinstance(value, str): return str(value) if isinstance(value, Mapping): - return cls._mapping_to_json(value, name) + return cls._mapping_to_json(value, value_name) if isinstance(value, Sequence): - return cls._sequence_to_json(value, name) + return cls._sequence_to_json(value, value_name) if isinstance(value, type): - return value.__name__ - raise TypeError(format_message_type_of(name, value, JSON_VALUE_TYPE_NAME)) + return repr(value) + raise TypeError(format_message_type_of(value_name, value, JSON_VALUE_TYPE_NAME)) @classmethod - def _mapping_to_json(cls, mapping: Mapping, name: str) -> dict[str, JsonValue]: + def _object_to_json(cls, value: Any, value_name: str) -> dict[str, JsonValue]: return { - k: cls._value_to_json(v, f"{name}.{k}") - for k, v in mapping.items() - if is_public_property_name(k) + k: cls._value_to_json(v, f"{value_name}.{k}") + for k, v in vars(value).items() + if cls._is_non_protected_property_name(k) } @classmethod - def _sequence_to_json(cls, sequence: Sequence, name: str) -> list[JsonValue]: - return [cls._value_to_json(v, f"{name}[{i}]") for i, v in enumerate(sequence)] + def _mapping_to_json( + cls, mapping: Mapping, value_name: str + ) -> dict[str, JsonValue]: + return { + str(k): cls._value_to_json(v, f"{value_name}[{k!r}]") + for k, v in mapping.items() + } + @classmethod + def _sequence_to_json(cls, sequence: Sequence, value_name: str) -> list[JsonValue]: + return [ + cls._value_to_json(v, f"{value_name}[{i}]") for i, v in enumerate(sequence) + ] -def is_public_property_name(key: Any) -> bool: - return ( - isinstance(key, str) - and key.isidentifier() - and not key[0].isupper() - and not key[0] == "_" - ) + @classmethod + def _is_non_protected_property_name(cls, key: Any) -> bool: + return ( + isinstance(key, str) + and key.isidentifier() + and not key[0].isupper() + and not key[0] == "_" + ) diff --git a/xrlint/util/importutil.py b/xrlint/util/importutil.py index b4dbd27..79d816c 100644 --- a/xrlint/util/importutil.py +++ b/xrlint/util/importutil.py @@ -47,7 +47,7 @@ def import_value( constant: bool = False, factory: Callable[[Any], T] | None = None, expected_type: Type[T] | None = None, -) -> T: +) -> tuple[T, str]: """Import an exported value from given module reference. Args: diff --git a/xrlint/util/todict.py b/xrlint/util/todict.py deleted file mode 100644 index 136b9e7..0000000 --- a/xrlint/util/todict.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import Any - - -class ToDictMixin: - def to_dict(self): - return to_dict(self) - - -def to_dict(obj: Any) -> Any: - return { - k: _convert_value(v) - for k, v in obj.__dict__.items() - if v is not None and isinstance(k, str) and not k.startswith("_") - } - - -def _convert_value(value: Any) -> Any: - if hasattr(value, "to_dict") and callable(value.to_dict): - return value.to_dict() - elif isinstance(value, dict): - return {k: _convert_value(v) for k, v in value.items()} - elif isinstance(value, (tuple, list)): - return [_convert_value(v) for v in value] - return value