Skip to content

Commit 03af25e

Browse files
Add yml validation schema generation and schema checker (#67)
* Add yml validation schema generation to rules2yml and schema checker in osi_rules * Add documentation section * Add yamale to requirements * Add unit test * Change int() to num() in schemas to allow float values * Fix not to stop validation on the first schema validation that is found. * Add test output to ci pipeline to check fail * Install osivalidator before executing rules2yml * Fix considering proto.in * Fi proto.* in test --------- Signed-off-by: ClemensLinnhoff <[email protected]>
1 parent a1a9c8b commit 03af25e

File tree

6 files changed

+221
-8
lines changed

6 files changed

+221
-8
lines changed

doc/usage.adoc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ python rules2yml.py # Generates the rule directory
5959
osivalidator --data data/20240221T141700Z_sv_300_2112_10_one_moving_object.osi --rules rules -p
6060
----
6161

62+
The rules2yml.py generates a yml file for each OSI proto file containing the rules specified in OSI.
63+
The yml files are located in the specified rules folder given as an input parameter.
64+
Additionally, the script generates respective yml schema files to validate the rule yml files in <rules_folder>/schema.
65+
The schema files contain the message names of the original OSI proto file and a list of applicable rules.
66+
If a rule has an associated value, e.g. a string or a number, the type of the value is also checked.
67+
When executing osivalidator, all rule files are validated against their respective schema.
68+
69+
If needed, the rules folder can be copied and modified for specific use cases, e.g. by adding or removing certain rules.
70+
This way, osivalidation can be used with different sets of rules.
71+
6272
After successfully running the validation the following output is
6373
generated:
6474

osivalidator/osi_general_validator.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def command_line_arguments():
4646
parser.add_argument(
4747
"--rules",
4848
"-r",
49-
help="Directory with text files containig rules. ",
49+
help="Directory with yml files containing rules. ",
5050
default=os.path.join(dir_path, "rules"),
5151
type=str,
5252
)
@@ -142,7 +142,12 @@ def main():
142142

143143
# Collect Validation Rules
144144
print("Collect validation rules ...")
145-
VALIDATION_RULES.from_yaml_directory(args.rules)
145+
try:
146+
VALIDATION_RULES.from_yaml_directory(args.rules)
147+
except Exception as e:
148+
trace.close()
149+
print("Error collecting validation rules:", e)
150+
exit(1)
146151

147152
# Pass all timesteps or the number specified
148153
if args.timesteps != -1:

osivalidator/osi_rules.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from ruamel.yaml import YAML
1414
from pathlib import Path
15+
import yamale
1516

1617
import osi_rules_implementations
1718

@@ -31,6 +32,31 @@ def __init__(self):
3132
"orientation_acceleration",
3233
}
3334

35+
def validate_rules_yml(self, file=None):
36+
"""Validate rule yml files against schema."""
37+
38+
# Read schema file
39+
directory = os.path.dirname(file)
40+
filename, file_extension = os.path.splitext(os.path.basename(file))
41+
schema_file = directory + os.sep + "schema" + os.sep + filename + "_schema.yml"
42+
if os.path.exists(schema_file):
43+
schema = yamale.make_schema(schema_file)
44+
else:
45+
print(f"WARNING: No schema file found for {file}.\n")
46+
return
47+
48+
# Create a Data object
49+
data = yamale.make_data(file)
50+
51+
# Validate data against the schema. Throws a ValueError if data is invalid.
52+
try:
53+
yamale.validate(schema, data)
54+
except yamale.yamale_error.YamaleError as exc:
55+
print(exc.message)
56+
return False
57+
58+
return True
59+
3460
def from_yaml_directory(self, path=None):
3561
"""Collect validation rules found in the directory."""
3662

@@ -39,13 +65,18 @@ def from_yaml_directory(self, path=None):
3965
path = os.path.join(dir_path, "rules")
4066

4167
exts = (".yml", ".yaml")
42-
try:
43-
for filename in os.listdir(path):
44-
if filename.startswith("osi_") and filename.endswith(exts):
68+
rule_file_errors = dict()
69+
for filename in os.listdir(path):
70+
if filename.startswith("osi_") and filename.endswith(exts):
71+
if self.validate_rules_yml(os.path.join(path, filename)):
4572
self.from_yaml_file(os.path.join(path, filename))
73+
else:
74+
print(f"WARNING: Invalid rule file: {filename}.\n")
75+
rule_file_errors[filename] = rule_file_errors.get(filename, 0) + 1
4676

47-
except FileNotFoundError:
48-
print("Error while reading files OSI-rules. Exiting!")
77+
if rule_file_errors:
78+
print(f"Errors per file: {rule_file_errors}")
79+
raise Exception("Errors were found in the OSI rule files.")
4980

5081
def from_yaml_file(self, path):
5182
"""Import from a file"""

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
tqdm>=4.66.1
2+
yamale>=5.0.0
23
tabulate>=0.9.0
34
ruamel.yaml>=0.18.5
45
defusedxml>=0.7.1

rules2yml.py

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,115 @@ def gen_yml_rules(dir_name="rules", full_osi=False):
4141

4242
if not os.path.exists(dir_name):
4343
os.makedirs(dir_name)
44+
if not os.path.exists(dir_name + "/schema"):
45+
os.makedirs(dir_name + "/schema")
46+
47+
for file in glob("open-simulation-interface/*.proto*"):
48+
filename = file.split("open-simulation-interface/")[1].split(".proto")[0]
49+
50+
if os.path.exists(f"{dir_name}/{filename}.yml"):
51+
continue
52+
53+
with open(f"{dir_name}/schema/{filename}_schema.yml", "a") as schema_file:
54+
with open(file, "rt") as fin:
55+
isEnum = False
56+
numMessage = 0
57+
saveStatement = ""
58+
prevMainField = False # boolean, that the previous field has children
59+
60+
for line in fin:
61+
if file.find(".proto") != -1:
62+
# Search for comment ("//").
63+
matchComment = re.search("//", line)
64+
if matchComment is not None:
65+
statement = line[: matchComment.start()]
66+
else:
67+
statement = line
68+
69+
# Add part of the statement from last line.
70+
statement = saveStatement + " " + statement
71+
72+
# New line is not necessary. Remove for a better output.
73+
statement = statement.replace("\n", "")
74+
75+
# Is statement complete
76+
matchSep = re.search(r"[{};]", statement)
77+
if matchSep is None:
78+
saveStatement = statement
79+
statement = ""
80+
else:
81+
saveStatement = statement[matchSep.end() :]
82+
statement = statement[: matchSep.end()]
83+
84+
# Search for "enum".
85+
matchEnum = re.search(r"\benum\b", statement)
86+
if matchEnum is not None:
87+
isEnum = True
88+
89+
# Search for a closing brace.
90+
matchClosingBrace = re.search("}", statement)
91+
if isEnum is True and matchClosingBrace is not None:
92+
isEnum = False
93+
continue
94+
95+
# Check if not inside an enum.
96+
if isEnum is False:
97+
# Search for "message".
98+
matchMessage = re.search(r"\bmessage\b", statement)
99+
if matchMessage is not None:
100+
# a new message or a new nested message
101+
numMessage += 1
102+
endOfLine = statement[matchMessage.end() :]
103+
matchName = re.search(r"\b\w[\S]*\b", endOfLine)
104+
if matchName is not None and prevMainField is False:
105+
# Check previous main field to exclude empty fields from sensor specific file
106+
matchNameConv = re.search(
107+
r"\b[A-Z][a-zA-Z0-9]*\b",
108+
endOfLine[matchName.start() : matchName.end()],
109+
)
110+
schema_file.write(
111+
2 * (numMessage - 1) * " "
112+
+ f"{matchNameConv.group(0)}:\n"
113+
)
114+
prevMainField = True
115+
116+
elif re.search(r"\bextend\b", statement) is not None:
117+
# treat extend as message
118+
numMessage += 1
119+
120+
# Search for a closing brace.
121+
matchClosingBrace = re.search("}", statement)
122+
if numMessage > 0 and matchClosingBrace is not None:
123+
numMessage -= 1
124+
125+
if matchComment is None and len(saveStatement) == 0:
126+
if numMessage > 0 or isEnum == True:
127+
if statement.find(";") != -1:
128+
field = statement.strip().split()[2]
129+
schema_file.write(
130+
(2 * numMessage) * " "
131+
+ f"{field}: any(list(include('rules', required=False)), null(), required=False)\n"
132+
)
133+
prevMainField = False
134+
schema_file.write(
135+
"---\n"
136+
"rules:\n"
137+
" is_greater_than: num(required=False)\n"
138+
" is_greater_than_or_equal_to: num(required=False)\n"
139+
" is_less_than_or_equal_to: num(required=False)\n"
140+
" is_less_than: num(required=False)\n"
141+
" is_equal_to: any(num(), bool(), required=False)\n"
142+
" is_different_to: num(required=False)\n"
143+
" is_globally_unique: str(required=False)\n"
144+
" refers_to: str(required=False)\n"
145+
" is_iso_country_code: str(required=False)\n"
146+
" is_set: str(required=False)\n"
147+
" check_if: list(include('rules', required=False),required=False)\n"
148+
" do_check: any(required=False)\n"
149+
" target: any(required=False)\n"
150+
" first_element: any(required=False)\n"
151+
" last_element: any(required=False)"
152+
)
44153

45154
for file in glob("open-simulation-interface/*.proto*"):
46155
filename = file.split("open-simulation-interface/")[1].split(".proto")[0]
@@ -54,6 +163,7 @@ def gen_yml_rules(dir_name="rules", full_osi=False):
54163
numMessage = 0
55164
shiftCounter = False
56165
saveStatement = ""
166+
prevMainField = False # boolean, that the previous field has children
57167
rules = []
58168

59169
for line in fin:
@@ -120,7 +230,7 @@ def gen_yml_rules(dir_name="rules", full_osi=False):
120230
numMessage += 1
121231
endOfLine = statement[matchMessage.end() :]
122232
matchName = re.search(r"\b\w[\S]*\b", endOfLine)
123-
if matchName is not None:
233+
if matchName is not None and prevMainField is False:
124234
# Test case 10: Check name - no special char -
125235
# start with a capital letter
126236
matchNameConv = re.search(
@@ -132,6 +242,7 @@ def gen_yml_rules(dir_name="rules", full_osi=False):
132242
2 * (numMessage - 1) * " "
133243
+ f"{matchNameConv.group(0)}:\n"
134244
)
245+
prevMainField = True
135246

136247
elif re.search(r"\bextend\b", statement) is not None:
137248
# treat extend as message
@@ -168,6 +279,8 @@ def gen_yml_rules(dir_name="rules", full_osi=False):
168279
yml_file.write(
169280
(2 * numMessage) * " " + f"{field}:\n"
170281
)
282+
prevMainField = False
283+
171284
# If option --full-osi is enabled:
172285
# Check if is_set is already a rule for the current field, if not, add it.
173286
if full_osi and not any(
@@ -233,6 +346,7 @@ def gen_yml_rules(dir_name="rules", full_osi=False):
233346
(2 * numMessage + 8) * " "
234347
+ f"- {rule_list[2]}: {rule_list[3]}\n"
235348
)
349+
236350
# Standalone rules
237351
elif any(
238352
list_item

tests/test_validation_rules.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
"""Module for test class of OSIValidationRules class"""
22

33
import unittest
4+
from glob import *
5+
import os
6+
import shutil
7+
import yamale
48
from osivalidator.osi_rules import (
59
Rule,
610
TypeRulesContainer,
711
ProtoMessagePath,
812
OSIRules,
913
OSIRuleNode,
1014
)
15+
from rules2yml import gen_yml_rules
1116

1217

1318
class TestValidationRules(unittest.TestCase):
@@ -58,6 +63,53 @@ def test_parse_yaml(self):
5863

5964
self.assertEqual(field["is_set"], rule_check)
6065

66+
def test_yaml_generation(self):
67+
gen_yml_rules("unit_test_rules/")
68+
69+
num_proto_files = len(glob("open-simulation-interface/*.proto*"))
70+
num_rule_files = len(glob("unit_test_rules/*.yml"))
71+
num_rule_schema_files = len(glob("unit_test_rules/schema/*.yml"))
72+
self.assertEqual(num_proto_files, num_rule_files)
73+
self.assertEqual(num_rule_files, num_rule_schema_files)
74+
75+
# clean up
76+
if os.path.isdir("unit_test_rules"):
77+
shutil.rmtree("unit_test_rules")
78+
79+
def test_yaml_schema_fail(self):
80+
gen_yml_rules("unit_test_rules/")
81+
82+
# alter exemplary rule for fail check
83+
raw_sensorspecific = """RadarSpecificObjectData:
84+
rcs:
85+
LidarSpecificObjectData:
86+
maximum_measurement_distance_sensor:
87+
- is_greater_than_or_equal_to: 0
88+
probability:
89+
- is_less_than_or_equal_to: x
90+
- is_greater_than_or_equal_to: 0
91+
trilateration_status:
92+
trend:
93+
signalway:
94+
Signalway:
95+
sender_id:
96+
receiver_id:
97+
"""
98+
99+
os.remove("unit_test_rules/osi_sensorspecific.yml")
100+
with open("unit_test_rules/osi_sensorspecific.yml", "w") as rule_file:
101+
rule_file.write(raw_sensorspecific)
102+
103+
validation_rules = OSIRules()
104+
validation_output = validation_rules.validate_rules_yml(
105+
"unit_test_rules/osi_sensorspecific.yml"
106+
)
107+
self.assertEqual(validation_output, False)
108+
109+
# clean up
110+
if os.path.isdir("unit_test_rules"):
111+
shutil.rmtree("unit_test_rules")
112+
61113

62114
if __name__ == "__main__":
63115
unittest.main()

0 commit comments

Comments
 (0)