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
« 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
7import nbformat
8from nbgrader.preprocessors import NbGraderPreprocessor
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"]
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
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
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
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")
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")
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
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(",")]
88 replacement = expr(*args)
89 processed = processed.replace(match.group("fun"), str(replacement))
90 return processed
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
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
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
125 new_lines.append(line)
126 lines = new_lines
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)
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])
158 for rand in rands:
159 rand_vars.update(self.sample(rand[0], rand[1], rand[2], sets))
161 for rand in rand_vars:
162 for r_name in replaces:
163 replaces[r_name] = replaces[r_name].replace(rand, rand_vars[rand])
165 for rand in rand_vars:
166 for r_name in replaces:
167 replaces[r_name] = replaces[r_name].replace(rand, rand_vars[rand])
169 for rand in rand_vars:
170 for r_name in replaces:
171 replaces[r_name] = replaces[r_name].replace(rand, rand_vars[rand])
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 )
179 return {
180 "seed": self.__seed,
181 "macros": macros,
182 "sets": sets,
183 "rands": rand_vars,
184 "replace": replaces,
185 }
187 def obscure(self, my_dict):
188 byte_str = pickle.dumps(my_dict)
189 obscured = base64.b85encode(byte_str)
190 return obscured
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