Coverage for visailu/validate.py: 86.21%
119 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-05 19:49:28 +00:00
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-05 19:49:28 +00:00
1"""Validate data against the model for quiz data."""
3from typing import Any, Union, no_type_check
5from visailu import (
6 INVALID_YAML_RESOURCE,
7 MODEL_META_INVALID_DEFAULTS,
8 MODEL_META_INVALID_RANGE,
9 MODEL_META_INVALID_RANGE_VALUE,
10 MODEL_QUESTION_ANSWER_MISSING,
11 MODEL_QUESTION_ANSWER_MISSING_RATING,
12 MODEL_QUESTION_INCOMPLETE,
13 MODEL_QUESTION_INVALID_RANGE,
14 MODEL_QUESTION_INVALID_RANGE_VALUE,
15 MODEL_STRUCTURE_UNEXPECTED,
16 MODEL_VALUES_MISSING,
17)
18from visailu.verify import verify_path
21@no_type_check
22def parse_scale_range(scale) -> tuple[Union[bool, float, None], list[Any]]:
23 """Parse a scale range declaration.
24 Returns a tuple (ordered pair) of target type and target range
25 target range: Expecting either a list (ordered pair) with low and high or
26 a string value from magic keywords to derive such a pair from.
27 A failed parse returns tuple with None and an empty list.
28 Any target range given as list on input is understood as logical values.
29 """
30 maps_to = scale.get('range', None)
31 if maps_to is None:
32 return None, []
33 if isinstance(maps_to, list):
34 if len(maps_to) != 2:
35 return None, []
36 return bool, maps_to # As documented
37 type_code = maps_to.lower().strip()
38 if type_code in ('binary', 'bool', 'boolean'):
39 return bool, [False, True]
40 if type_code in ('fract', 'fraction', 'relative'):
41 return float, [0, 1]
42 if type_code in ('perc', 'percentage'):
43 return float, [0, 100]
44 return None, []
47@no_type_check
48def effective_meta(data, entry) -> dict[str:Any]:
49 """Walk upwards until meta discovered."""
50 meta = entry.get('meta')
51 if meta is None:
52 meta = data.get('meta')
53 return meta
56@no_type_check
57def parse_defaults(meta) -> tuple[bool, list[Any], Any]:
58 """Parse the effective defaults from the meta data."""
59 defaults = meta.get('defaults')
60 if defaults:
61 default_rating = defaults.get('rating', None)
62 target_type, maps_to = parse_scale_range(meta.get('scale'))
63 return target_type, maps_to, default_rating
64 return None, [], None
67@no_type_check
68def validate_defaults(target_type, maps_to, default_rating) -> tuple[bool, str]:
69 """Validate the default for consistency."""
70 if target_type is None or not maps_to: 70 ↛ 71line 70 didn't jump to line 71, because the condition on line 70 was never true
71 return False, MODEL_META_INVALID_RANGE
72 if target_type is bool:
73 if default_rating not in maps_to: 73 ↛ 74line 73 didn't jump to line 74, because the condition on line 73 was never true
74 return False, MODEL_META_INVALID_RANGE_VALUE
75 if target_type is float:
76 try:
77 val = float(default_rating)
78 if not isinstance(default_rating, (int, float)) or default_rating is False or default_rating is True: 78 ↛ 79line 78 didn't jump to line 79, because the condition on line 78 was never true
79 return False, MODEL_META_INVALID_RANGE_VALUE
80 except (TypeError, ValueError):
81 return False, MODEL_META_INVALID_RANGE_VALUE
82 if not maps_to[0] <= val <= maps_to[1]: 82 ↛ 83line 82 didn't jump to line 83, because the condition on line 82 was never true
83 return False, MODEL_META_INVALID_RANGE_VALUE
84 return True, ''
87@no_type_check
88def _validate(data) -> tuple[int, str, Any]:
89 """Validate the data against the model and return the completed data.
91 Defaults being filled in along the way, so that subsequent publication does not duplicate the logic.
92 """
93 try:
94 identity = data.get('id')
95 title = data.get('title', '')
96 questions = data.get('questions', [])
97 except (AttributeError, RuntimeError):
98 return 1, MODEL_STRUCTURE_UNEXPECTED, data
100 if not all(aspect for aspect in (identity, title, questions)):
101 return 1, MODEL_VALUES_MISSING, data
103 for q_slot, entry in enumerate(questions):
104 question = entry.get('question', '')
105 answers = entry.get('answers', [])
106 meta = effective_meta(data, entry)
107 target_type, maps_to, default_rating = parse_defaults(meta)
108 if target_type is None:
109 return 1, MODEL_META_INVALID_DEFAULTS, data
110 ok, message = validate_defaults(target_type, maps_to, default_rating)
111 if not ok:
112 return 1, message, data
113 if target_type is None or not maps_to: 113 ↛ 114line 113 didn't jump to line 114, because the condition on line 113 was never true
114 return 1, MODEL_META_INVALID_RANGE, data
115 if target_type is bool:
116 if default_rating not in maps_to: 116 ↛ 117line 116 didn't jump to line 117, because the condition on line 116 was never true
117 return 1, MODEL_META_INVALID_RANGE_VALUE, data
118 if target_type is float:
119 try:
120 val = float(default_rating)
121 if not isinstance(default_rating, (int, float)) or default_rating is False or default_rating is True: 121 ↛ 122line 121 didn't jump to line 122, because the condition on line 121 was never true
122 return 1, MODEL_META_INVALID_RANGE_VALUE, data
123 except (TypeError, ValueError):
124 return 1, MODEL_META_INVALID_RANGE_VALUE, data
125 if not maps_to[0] <= val <= maps_to[1]: 125 ↛ 126line 125 didn't jump to line 126, because the condition on line 125 was never true
126 return 1, MODEL_META_INVALID_RANGE_VALUE, data
128 if not all(aspect for aspect in (question, answers)):
129 return 1, MODEL_QUESTION_INCOMPLETE, data
131 for a_slot, option in enumerate(answers):
132 answer = option.get('answer', '')
133 rating = option.get('rating')
134 if rating is None:
135 rating = default_rating
136 if target_type is bool:
137 if rating not in maps_to: 137 ↛ 138line 137 didn't jump to line 138, because the condition on line 137 was never true
138 return 1, MODEL_QUESTION_INVALID_RANGE, data
139 if target_type is float:
140 try:
141 val = float(rating)
142 if not isinstance(rating, (int, float)) or rating is False or rating is True: 142 ↛ 143line 142 didn't jump to line 143, because the condition on line 142 was never true
143 return 1, MODEL_QUESTION_INVALID_RANGE_VALUE, data
144 except (TypeError, ValueError):
145 return 1, MODEL_QUESTION_INVALID_RANGE_VALUE, data
146 if not maps_to[0] <= val <= maps_to[1]:
147 return 1, MODEL_QUESTION_INVALID_RANGE_VALUE, data
148 if not answer: 148 ↛ 149line 148 didn't jump to line 149, because the condition on line 148 was never true
149 return 1, MODEL_QUESTION_ANSWER_MISSING, data
150 if rating is None: 150 ↛ 151line 150 didn't jump to line 151, because the condition on line 150 was never true
151 return 1, MODEL_QUESTION_ANSWER_MISSING_RATING, data
152 # We are good, so we can eagerly patch to fill in defaults without too much logic as idempotent rollup
153 option['rating'] = rating
154 answers[a_slot] = option
155 questions[q_slot]['answers'] = answers
156 data['questions'] = questions
158 return 0, '', data
161@no_type_check
162def validate_path(path: str, options=None) -> tuple[int, str, Any]:
163 """Drive the model validation."""
164 code, message, data = verify_path(path)
165 if code != 0:
166 return code, INVALID_YAML_RESOURCE, data
168 return _validate(data)