Coverage for e2xgrader/preprocessors/scramble.py: 12%

163 statements  

« prev     ^ index     » next       coverage.py v7.4.2, created at 2024-03-14 13:22 +0100

1import base64 

2import copy 

3import pickle 

4import random 

5import re 

6 

7import nbformat 

8from nbgrader.preprocessors import NbGraderPreprocessor 

9 

10 

11class Scramble(NbGraderPreprocessor): 

12 def __init__(self, **kw): 

13 self.__seed = random.randint(0, 10000000) 

14 self.__random = random.Random(self.__seed) 

15 self.__p_define = re.compile( 

16 r"^#define +(?P<fun>\w+)\((?P<args>[^(]*)\) +(?P<body>.*)" 

17 ) 

18 self.__p_set = re.compile(r"^#set +(?P<name>\w+) += +(?P<options>.*)") 

19 self.__p_random = re.compile(r"^#random +(?P<vars>.*) +in +(?P<sets>.*)") 

20 self.__p_replace = re.compile(r"#replace +(?P<name>\w+) +(?P<replace_with>.*)") 

21 self.__p_lambda = re.compile("^#lambda +(?P<name>[^ ]+) +(?P<lambda>.*)") 

22 if kw and "seed" in kw: 

23 self.__random = random.Random(kw["seed"]) 

24 self.__seed = kw["seed"] 

25 

26 def parse_define(self, line): 

27 match = self.__p_define.search(line) 

28 f_name = match.group("fun") 

29 f_args = [arg.strip() for arg in match.group("args").split(",")] 

30 f_body = match.group("body") 

31 return f_name, f_args, f_body 

32 

33 def parse_set(self, line): 

34 match = self.__p_set.search(line) 

35 if match: 

36 return match.group("name"), match.group("options") 

37 return None 

38 

39 def parse_random(self, line, sets): 

40 match = self.__p_random.search(line) 

41 if not match: 

42 return None 

43 opts = [s.strip() for s in match.group("sets").split(",")] 

44 var_str = match.group("vars") 

45 if "!=" in var_str: 

46 rand_vars = [s.strip() for s in var_str.split("!=")] 

47 var_groups = [[v.strip() for v in s.split(",")] for s in rand_vars] 

48 return var_groups, "!=", opts 

49 else: 

50 rand_vars = [s.strip() for s in var_str.split("==")] 

51 var_groups = [[v.strip() for v in s.split(",")] for s in rand_vars] 

52 return var_groups, "==", opts 

53 

54 def parse_replace(self, line): 

55 match = self.__p_replace.search(line) 

56 if not match: 

57 return None 

58 return match.group("name"), match.group("replace_with") 

59 

60 def parse_lambda(self, line): 

61 match = self.__p_lambda.search(line) 

62 if not match: 

63 return None 

64 return match.group("name"), "lambda " + match.group("lambda") 

65 

66 def replace(self, text, macro): 

67 p_macro = re.compile(r"(?P<fun>{}\((?P<args>[^)]*)\))".format(macro[0])) 

68 processed = text 

69 for match in p_macro.finditer(text): 

70 args = [arg.strip() for arg in match.group("args").split(",")] 

71 assert len(macro[1]) == len( 

72 args 

73 ), "Wrong number of arguments for macro {} with args {}".format( 

74 macro[0], args 

75 ) 

76 replacement = macro[2] 

77 for i in range(len(args)): 

78 replacement = replacement.replace(macro[1][i], args[i]) 

79 processed = processed.replace(match.group("fun"), replacement) 

80 return processed 

81 

82 def replace_lambdas(self, text, name, expr): 

83 p_macro = re.compile(r"(?P<fun>{}\((?P<args>[^)]*)\))".format(name)) 

84 processed = text 

85 for match in p_macro.finditer(text): 

86 args = [arg.strip() for arg in match.group("args").split(",")] 

87 

88 replacement = expr(*args) 

89 processed = processed.replace(match.group("fun"), str(replacement)) 

90 return processed 

91 

92 def sample(self, groups, constraint, sets_names, set_dict): 

93 sets = [set_dict[sets_name] for sets_name in sets_names] 

94 rand_dict = {} 

95 if constraint == "==": 

96 k = 1 

97 sampled_idx = self.__random.sample(range(len(sets[0])), k=k) 

98 for g_idx in range(len(groups)): 

99 group = groups[g_idx] 

100 for set_idx in range(len(group)): 

101 rand_dict[group[set_idx]] = sets[set_idx][sampled_idx[0]] 

102 return rand_dict 

103 

104 if constraint == "!=": 

105 k = len(groups) 

106 sampled_idx = self.__random.sample(range(len(sets[0])), k=k) 

107 for g_idx in range(len(groups)): 

108 group = groups[g_idx] 

109 for set_idx in range(len(group)): 

110 rand_dict[group[set_idx]] = sets[set_idx][sampled_idx[g_idx]] 

111 return rand_dict 

112 

113 def sample_config(self, config): 

114 lines = config.split("\n") 

115 new_lines = [] 

116 i = 0 

117 while i < len(lines): 

118 line = lines[i].strip() 

119 while line.endswith("\\"): 

120 line = line[:-1] 

121 line += lines[i + 1].strip() 

122 i += 1 

123 i += 1 

124 

125 new_lines.append(line) 

126 lines = new_lines 

127 

128 macros = [] 

129 sets = {} 

130 rands = [] 

131 rand_vars = {} 

132 replaces = {} 

133 lambdas = {} 

134 for line in lines: 

135 line = line.strip() 

136 if line.startswith("#define"): 

137 macros.append(list(self.parse_define(line))) 

138 elif line.startswith("#set"): 

139 name, opts = self.parse_set(line) 

140 sets[name] = [opt.strip() for opt in opts.split("||")] 

141 elif line.startswith("#random"): 

142 rands.append(self.parse_random(line, sets)) 

143 elif line.startswith("#replace"): 

144 name, replace_with = self.parse_replace(line) 

145 replaces[name] = replace_with 

146 elif line.startswith("#lambda"): 

147 name, lambda_expr = self.parse_lambda(line) 

148 lambdas[name] = eval(lambda_expr) 

149 

150 for i in range(len(macros)): 

151 for j in range(i + 1, len(macros)): 

152 macros[j][2] = self.replace(macros[j][2], macros[i]) 

153 for set_name in sets: 

154 sets[set_name] = [self.replace(s, macros[i]) for s in sets[set_name]] 

155 for r_name in replaces: 

156 replaces[r_name] = self.replace(replaces[r_name], macros[i]) 

157 

158 for rand in rands: 

159 rand_vars.update(self.sample(rand[0], rand[1], rand[2], sets)) 

160 

161 for rand in rand_vars: 

162 for r_name in replaces: 

163 replaces[r_name] = replaces[r_name].replace(rand, rand_vars[rand]) 

164 

165 for rand in rand_vars: 

166 for r_name in replaces: 

167 replaces[r_name] = replaces[r_name].replace(rand, rand_vars[rand]) 

168 

169 for rand in rand_vars: 

170 for r_name in replaces: 

171 replaces[r_name] = replaces[r_name].replace(rand, rand_vars[rand]) 

172 

173 for lambda_expr in lambdas: 

174 for r_name in replaces: 

175 replaces[r_name] = self.replace_lambdas( 

176 replaces[r_name], lambda_expr, lambdas[lambda_expr] 

177 ) 

178 

179 return { 

180 "seed": self.__seed, 

181 "macros": macros, 

182 "sets": sets, 

183 "rands": rand_vars, 

184 "replace": replaces, 

185 } 

186 

187 def obscure(self, my_dict): 

188 byte_str = pickle.dumps(my_dict) 

189 obscured = base64.b85encode(byte_str) 

190 return obscured 

191 

192 def preprocess(self, nb, resources): 

193 if len(nb.cells) < 1 or not nb.cells[0].source.startswith("%% scramble"): 

194 return nb, resources 

195 config = self.sample_config(nb.cells[0].source) 

196 replacement_variables = config["replace"] 

197 scrambled_nb = nbformat.v4.new_notebook() 

198 scrambled_nb.cells = copy.deepcopy(nb.cells[1:]) 

199 for cell in scrambled_nb.cells: 

200 for replacement_variable in replacement_variables: 

201 cell.source = cell.source.replace( 

202 "{{" + replacement_variable + "}}", 

203 replacement_variables[replacement_variable], 

204 ) 

205 scrambled_nb.metadata["scramble_config"] = { 

206 "seed": config["seed"], 

207 "config": self.obscure(replacement_variables), 

208 } 

209 return scrambled_nb, resources