Skip to content

Commit 1be1aec

Browse files
committed
better xrlint.rule coverage, support Literal annotation
1 parent 65ce5de commit 1be1aec

4 files changed

Lines changed: 142 additions & 17 deletions

File tree

tests/test_rule.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,63 @@ class MyRule3(RuleOp):
5050

5151
with pytest.raises(
5252
ValueError,
53-
match="missing rule metadata, apply define_rule\\(\\) to class MyRule3",
53+
match=r"missing rule metadata, apply define_rule\(\) to class MyRule3",
5454
):
5555
Rule.from_value(MyRule3)
5656

57+
def test_to_json(self):
58+
class MyRule3(RuleOp):
59+
"""This is my 3rd rule."""
60+
61+
rule = Rule.from_value("tests.test_rule")
62+
self.assertEqual("tests.test_rule:export_rule", rule.to_json())
63+
64+
rule = Rule(meta=RuleMeta(name="r3", ref="mymod.rules:r3"), op_class=MyRule3)
65+
self.assertEqual("mymod.rules:r3", rule.to_json())
66+
67+
rule = Rule(meta=RuleMeta(name="r3"), op_class=MyRule3)
68+
self.assertEqual(
69+
{
70+
"meta": {"name": "r3", "version": "0.0.0", "type": "problem"},
71+
"op_class": "<class"
72+
" 'tests.test_rule.RuleTest.test_to_json.<locals>.MyRule3'>",
73+
},
74+
rule.to_json(),
75+
)
76+
77+
78+
class RuleMetaTest(unittest.TestCase):
79+
def test_from_value(self):
80+
rule_meta = RuleMeta.from_value(
81+
{
82+
"name": "r-4",
83+
"version": "2.0.1",
84+
"type": "suggestion",
85+
"description": "Be nice, always.",
86+
}
87+
)
88+
self.assertEqual(
89+
RuleMeta(
90+
name="r-4",
91+
version="2.0.1",
92+
type="suggestion",
93+
description="Be nice, always.",
94+
),
95+
rule_meta,
96+
)
97+
98+
def test_to_json(self):
99+
rule_meta = RuleMeta(name="r1", version="0.1.2", description="Nice one.")
100+
self.assertEqual(
101+
{
102+
"name": "r1",
103+
"version": "0.1.2",
104+
"type": "problem",
105+
"description": "Nice one.",
106+
},
107+
rule_meta.to_json(),
108+
)
109+
57110

58111
class DefineRuleTest(unittest.TestCase):
59112

@@ -79,6 +132,18 @@ def test_with_registry(self):
79132
self.assertIs(rule1, registry["my-rule-1"])
80133
self.assertIs(rule2, registry["my-rule-2"])
81134

135+
# noinspection PyMethodMayBeStatic
136+
def test_fail(self):
137+
with pytest.raises(
138+
TypeError,
139+
match=(
140+
r"component decorated by define_rule\(\)"
141+
r" must be a subclass of RuleOp"
142+
),
143+
):
144+
# noinspection PyTypeChecker
145+
define_rule(op_class=DefineRuleTest)
146+
82147

83148
class RuleConfigTest(TestCase):
84149
def test_defaults(self):

tests/util/test_codec.py

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
get_origin,
1010
Mapping,
1111
TYPE_CHECKING,
12+
Literal,
1213
)
1314
from unittest import TestCase
1415

@@ -49,6 +50,7 @@ class SimpleTypesContainer(MappingConstructible, JsonSerializable):
4950
d: float = 0.0
5051
e: str = "abc"
5152
f: type = int
53+
g: Literal[0, 1, True, False, "on", "off"] = 0
5254

5355

5456
@dataclass()
@@ -122,13 +124,29 @@ def test_assumptions(self):
122124
class JsonSerializableTest(TestCase):
123125
def test_simple_ok(self):
124126
self.assertEqual(
125-
{"a": None, "b": False, "c": 0, "d": 0.0, "e": "abc", "f": "int"},
127+
{
128+
"a": None,
129+
"b": False,
130+
"c": 0,
131+
"d": 0.0,
132+
"e": "abc",
133+
"f": "<class 'int'>",
134+
"g": 0,
135+
},
126136
SimpleTypesContainer().to_json(),
127137
)
128138
self.assertEqual(
129-
{"a": "?", "b": True, "c": 12, "d": 34.56, "e": "uvw", "f": "bool"},
139+
{
140+
"a": "?",
141+
"b": True,
142+
"c": 12,
143+
"d": 34.56,
144+
"e": "uvw",
145+
"f": "<class 'bool'>",
146+
"g": "off",
147+
},
130148
SimpleTypesContainer(
131-
a="?", b=True, c=12, d=34.56, e="uvw", f=bool
149+
a="?", b=True, c=12, d=34.56, e="uvw", f=bool, g="off"
132150
).to_json(),
133151
)
134152

@@ -144,7 +162,15 @@ def test_complex_ok(self):
144162
)
145163
self.assertEqual(
146164
{
147-
"p": {"a": None, "b": False, "c": 0, "d": 0.0, "e": "abc", "f": "int"},
165+
"p": {
166+
"a": None,
167+
"b": False,
168+
"c": 0,
169+
"d": 0.0,
170+
"e": "abc",
171+
"f": "<class 'int'>",
172+
"g": 0,
173+
},
148174
"q": {"p": True, "q": False},
149175
"r": {
150176
"u": {
@@ -153,27 +179,38 @@ def test_complex_ok(self):
153179
"c": 0,
154180
"d": 0.0,
155181
"e": "abc",
156-
"f": "int",
182+
"f": "<class 'int'>",
183+
"g": 0,
157184
},
158185
"v": {
159186
"a": None,
160187
"b": False,
161188
"c": 0,
162189
"d": 0.0,
163190
"e": "abc",
164-
"f": "int",
191+
"f": "<class 'int'>",
192+
"g": 0,
165193
},
166194
},
167195
"s": [1, 2, 3],
168196
"t": [
169-
{"a": None, "b": False, "c": 5, "d": 6.7, "e": "abc", "f": "int"},
197+
{
198+
"a": None,
199+
"b": False,
200+
"c": 5,
201+
"d": 6.7,
202+
"e": "abc",
203+
"f": "<class 'int'>",
204+
"g": 0,
205+
},
170206
{
171207
"a": None,
172208
"b": False,
173209
"c": 8,
174210
"d": 9.1,
175211
"e": "abc",
176-
"f": "SimpleTypesContainer",
212+
"f": "<class 'tests.util.test_codec.SimpleTypesContainer'>",
213+
"g": 0,
177214
},
178215
],
179216
"u": None,
@@ -288,7 +325,7 @@ def test_no_types_ok(self):
288325
class MappingConstructibleTest(TestCase):
289326

290327
def test_simple_ok(self):
291-
kwargs = dict(a="?", b=True, c=12, d=34.56, e="uvw", f=bytes)
328+
kwargs = dict(a="?", b=True, c=12, d=34.56, e="uvw", f=bytes, g="on")
292329
container = SimpleTypesContainer(**kwargs)
293330
self.assertEqual(container, SimpleTypesContainer.from_value(kwargs))
294331
self.assertIs(container, SimpleTypesContainer.from_value(container))
@@ -360,6 +397,12 @@ def test_simple_fail(self):
360397
):
361398
SimpleTypesContainer.from_value({"b": None}, value_name="stc")
362399

400+
with pytest.raises(
401+
TypeError,
402+
match=r"stc.g must be one of 0, 1, True, False, 'on', 'off', but got 74",
403+
):
404+
SimpleTypesContainer.from_value({"g": 74}, value_name="stc")
405+
363406
with pytest.raises(
364407
TypeError, match="x is not a member of stc of type SimpleTypesContainer"
365408
):
@@ -460,6 +503,7 @@ def test_resolves_types(self):
460503
"d",
461504
"e",
462505
"f",
506+
"g",
463507
"p",
464508
"q",
465509
"r",

xrlint/rule.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
)
1313
from xrlint.node import DatasetNode, DataArrayNode, AttrsNode, AttrNode
1414
from xrlint.result import Suggestion
15-
from xrlint.util.codec import MappingConstructible, ValueConstructible, JsonSerializable
15+
from xrlint.util.codec import (
16+
MappingConstructible,
17+
ValueConstructible,
18+
JsonSerializable,
19+
)
1620
from xrlint.util.formatting import format_message_one_of
1721
from xrlint.util.importutil import import_value
1822
from xrlint.util.naming import to_kebab_case
@@ -186,6 +190,13 @@ class RuleMeta(MappingConstructible, JsonSerializable):
186190
def _get_value_type_name(cls) -> str:
187191
return "RuleMeta | dict"
188192

193+
def to_dict(self, value_name: str | None = None) -> dict[str, str]:
194+
return {
195+
k: v
196+
for k, v in super().to_dict(value_name=value_name).items()
197+
if v is not None
198+
}
199+
189200

190201
@dataclass(frozen=True)
191202
class Rule(MappingConstructible, JsonSerializable):
@@ -239,7 +250,7 @@ def _get_value_type_name(cls) -> str:
239250
def to_json(self, value_name: str | None = None) -> str:
240251
if self.meta.ref:
241252
return self.meta.ref
242-
return repr(self)
253+
return super().to_json(value_name=value_name)
243254

244255

245256
@dataclass(frozen=True)

xrlint/util/codec.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@
1414
get_args,
1515
get_type_hints,
1616
Optional,
17+
Literal,
1718
)
1819

19-
from xrlint.util.formatting import format_message_type_of
20-
20+
from xrlint.util.formatting import format_message_type_of, format_message_one_of
2121

2222
JSON_VALUE_TYPE_NAME = "None | bool | int | float | str | dict | list"
2323

2424
JsonValue: TypeAlias = (
25-
NoneType | bool | int | float | str | dict[str, "JsonValue"] | list["JsonValue"]
25+
None | bool | int | float | str | dict[str, "JsonValue"] | list["JsonValue"]
2626
)
2727

2828
T = TypeVar("T")
@@ -198,6 +198,12 @@ def _convert_value(cls, value: Any, type_annotation: Any, value_name: str) -> An
198198
# therefore return the value as-is
199199
return value
200200

201+
if type_origin is Literal:
202+
# Value must be one of literal arguments
203+
if value not in type_args:
204+
raise TypeError(format_message_one_of(value_name, value, type_args))
205+
return value
206+
201207
if type_origin is Union:
202208
# For unions try converting the alternatives.
203209
# Return the first successfully converted value.
@@ -480,7 +486,6 @@ def to_dict(self, value_name: str | None = None) -> dict[str, JsonValue]:
480486
@classmethod
481487
def _value_to_json(cls, value: Any, value_name: str) -> JsonValue:
482488
if value is None:
483-
# noinspection PyTypeChecker
484489
return None
485490
if isinstance(value, JsonSerializable):
486491
return value.to_json(value_name=value_name)
@@ -497,7 +502,7 @@ def _value_to_json(cls, value: Any, value_name: str) -> JsonValue:
497502
if isinstance(value, Sequence):
498503
return cls._sequence_to_json(value, value_name)
499504
if isinstance(value, type):
500-
return value.__name__
505+
return repr(value)
501506
raise TypeError(format_message_type_of(value_name, value, JSON_VALUE_TYPE_NAME))
502507

503508
@classmethod

0 commit comments

Comments
 (0)