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

1"""Validate data against the model for quiz data.""" 

2 

3from typing import Any, Union, no_type_check 

4 

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 

19 

20 

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, [] 

45 

46 

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 

54 

55 

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 

65 

66 

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, '' 

85 

86 

87@no_type_check 

88def _validate(data) -> tuple[int, str, Any]: 

89 """Validate the data against the model and return the completed data. 

90 

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 

99 

100 if not all(aspect for aspect in (identity, title, questions)): 

101 return 1, MODEL_VALUES_MISSING, data 

102 

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 

127 

128 if not all(aspect for aspect in (question, answers)): 

129 return 1, MODEL_QUESTION_INCOMPLETE, data 

130 

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 

157 

158 return 0, '', data 

159 

160 

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 

167 

168 return _validate(data)