moodlesolver 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """MoodleSolver: async Moodle quiz client over a Camofox browser API."""
2
+ from .client import AsyncMoodle, AsyncTab, CamofoxError
3
+ from .parse import parse_page
4
+
5
+ __version__ = "1.0.0"
6
+ __all__ = ["AsyncMoodle", "AsyncTab", "CamofoxError", "parse_page"]
moodlesolver/client.py ADDED
@@ -0,0 +1,406 @@
1
+ import asyncio
2
+ import base64
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+
8
+ import aiohttp
9
+
10
+
11
+ class CamofoxError(Exception):
12
+ pass
13
+
14
+
15
+ class AsyncTab:
16
+ def __init__(self, session, user, tab_id):
17
+ self.s = session
18
+ self.user = user
19
+ self.id = tab_id
20
+
21
+ async def _post(self, path, body):
22
+ async with self.s.post(path, json=body,
23
+ timeout=aiohttp.ClientTimeout(total=90)) as r:
24
+ d = await r.json()
25
+ if r.status >= 400:
26
+ raise CamofoxError(f"HTTP {r.status}: {str(d)[:200]}")
27
+ return d
28
+
29
+ async def _get(self, path):
30
+ async with self.s.get(path,
31
+ timeout=aiohttp.ClientTimeout(total=90)) as r:
32
+ return await r.json()
33
+
34
+ async def snapshot_raw(self):
35
+ return await self._get(f"/tabs/{self.id}/snapshot?userId={self.user}")
36
+
37
+ async def snapshot(self, tries=5, delay=3):
38
+ for _ in range(tries):
39
+ d = await self.snapshot_raw()
40
+ s = d.get("snapshot") or ""
41
+ if s:
42
+ return s
43
+ await asyncio.sleep(delay)
44
+ raise CamofoxError(f"empty snapshot {self.id}")
45
+
46
+ async def main_content(self):
47
+ s = await self.snapshot()
48
+ a, b = s.find("Содержимое"), s.find("Блоки")
49
+ if a >= 0 < b:
50
+ return s[a + 9:b]
51
+ a, b = s.find("Main content"), s.find("Blocks")
52
+ if a >= 0 < b:
53
+ return s[a + 13:b]
54
+ return s
55
+
56
+ async def click(self, ref):
57
+ return await self._post(f"/tabs/{self.id}/click",
58
+ {"userId": self.user, "ref": ref})
59
+
60
+ async def type(self, ref, text):
61
+ return await self._post(f"/tabs/{self.id}/type",
62
+ {"userId": self.user, "ref": ref, "text": text})
63
+
64
+ async def select(self, ref, option):
65
+ return await self._post(f"/tabs/{self.id}/select",
66
+ {"userId": self.user, "ref": ref, "option": option})
67
+
68
+ async def navigate(self, url, settle=2):
69
+ r = await self._post(f"/tabs/{self.id}/navigate",
70
+ {"userId": self.user, "url": url})
71
+ await asyncio.sleep(settle)
72
+ return r
73
+
74
+ async def evaluate(self, expression):
75
+ return await self._post(f"/tabs/{self.id}/evaluate",
76
+ {"userId": self.user, "expression": expression})
77
+
78
+ async def url(self):
79
+ d = await self.snapshot_raw()
80
+ return d.get("url", "")
81
+
82
+ async def close(self):
83
+ async with self.s.delete(f"/tabs/{self.id}",
84
+ params={"userId": self.user}) as r:
85
+ return await r.json()
86
+
87
+ async def js_click_label(self, labels):
88
+ labs = json.dumps(labels if isinstance(labels, list) else [labels],
89
+ ensure_ascii=False)
90
+ r = await self.evaluate(f"""(() => {{
91
+ const want = {labs};
92
+ let n = 0;
93
+ for (const w of want) {{
94
+ const el = [...document.querySelectorAll('input[type=checkbox],input[type=radio]')]
95
+ .find(x => (x.parentElement.textContent || '').trim().toLowerCase()
96
+ .includes(w.toLowerCase()));
97
+ if (el) {{ el.click(); n++; }}
98
+ }}
99
+ return String(n);
100
+ }})()""")
101
+ return r.get("result")
102
+
103
+ async def js_click_next(self):
104
+ await self.evaluate(
105
+ "document.querySelector('input[name=next]').click(); 'ok'")
106
+
107
+
108
+ class AsyncMoodle:
109
+ def __init__(self, site, username=None, password=None,
110
+ user=None, api_url="http://localhost:9377",
111
+ dumps_dir="moodlesolver_dumps"):
112
+ self.username = username
113
+ self.password = password
114
+ self.site = site.rstrip("/")
115
+ self.api_url = api_url.rstrip("/")
116
+ self.user = user or ("moodle_" + hashlib.sha1(
117
+ (username or "anon").encode()).hexdigest()[:8])
118
+ self.dumps_dir = dumps_dir
119
+ self.tab = None
120
+ self.s = None
121
+ self._connected = False
122
+
123
+ async def __aenter__(self):
124
+ await self.connect()
125
+ return self
126
+
127
+ async def __aexit__(self, *exc):
128
+ await self.disconnect()
129
+
130
+ @property
131
+ def is_connected(self):
132
+ return self._connected
133
+
134
+ async def connect(self):
135
+ if not self._connected:
136
+ self.s = aiohttp.ClientSession(base_url=self.api_url)
137
+ self._connected = True
138
+ return self
139
+
140
+ async def start(self):
141
+ if not self._connected:
142
+ await self.connect()
143
+ if self.username and self.password:
144
+ return await self.login(self.username, self.password)
145
+ return await self.whoami()
146
+
147
+ async def disconnect(self):
148
+ if self.s:
149
+ for t in await self.tabs():
150
+ await AsyncTab(self.s, self.user, t["tabId"]).close()
151
+ await self.s.close()
152
+ self.s = None
153
+ self._connected = False
154
+
155
+ async def login(self, username, password):
156
+ t = await self.pick("login/index.php") or \
157
+ await self.new_tab(f"{self.site}/login/index.php")
158
+ s = await t.main_content()
159
+ if "Вы уже вошли" in s or "Вы зашли" in s or "Logged in" in s:
160
+ return await self.whoami()
161
+ m_u = re.search(r'(?:textbox|field) "([^"]*(?:Username|Логин|Пользователь)[^"]*)"\s*\[e(\d+)\]', s)
162
+ m_p = re.search(r'(?:textbox|field) "([^"]*(?:Password|Пароль)[^"]*)"\s*\[e(\d+)\]', s)
163
+ m_b = re.search(r'button "([^"]*(?:Log in|Вход|Войти)[^"]*)"\s*\[e(\d+)\]', s)
164
+ if not (m_u and m_p and m_b):
165
+ raise CamofoxError(f"login form not parsed: {s[:300]}")
166
+ await t.type(f"e{m_u.group(2)}", username)
167
+ await t.type(f"e{m_p.group(2)}", password)
168
+ await t.click(f"e{m_b.group(2)}")
169
+ await asyncio.sleep(4)
170
+ return await self.whoami()
171
+
172
+ async def whoami(self):
173
+ t = await self.pick("my/") or await self.new_tab(f"{self.site}/my/")
174
+ r = await t.evaluate(
175
+ "JSON.stringify(document.querySelector('.usertext')?.textContent || null)")
176
+ u = r.get("result")
177
+ if u and u != "null":
178
+ return u.strip('"')
179
+ s = await t.main_content()
180
+ m = re.search(r'Вы зашли под именем\s*\n?\s*- link "([^"]+)"', s)
181
+ if m:
182
+ return m.group(1)
183
+ m = re.search(r'You are logged in as\s*\n?\s*- link "([^"]+)"', s)
184
+ return m.group(1) if m else None
185
+
186
+ async def tabs(self):
187
+ async with self.s.get("/tabs", params={"userId": self.user}) as r:
188
+ return (await r.json()).get("tabs", [])
189
+
190
+ async def new_tab(self, url, settle=3):
191
+ async with self.s.post("/tabs", json={
192
+ "userId": self.user, "sessionKey": "s", "url": url}) as r:
193
+ d = await r.json()
194
+ t = AsyncTab(self.s, self.user, d["tabId"])
195
+ await asyncio.sleep(settle)
196
+ self.tab = t
197
+ return t
198
+
199
+ async def pick(self, url_substr, latest=True):
200
+ ts = [t for t in await self.tabs() if url_substr in t.get("url", "")]
201
+ if not ts:
202
+ return None
203
+ t = AsyncTab(self.s, self.user,
204
+ ts[-1]["tabId"] if latest else ts[0]["tabId"])
205
+ self.tab = t
206
+ return t
207
+
208
+ async def close_stale(self, keep=""):
209
+ for t in await self.tabs():
210
+ if keep and keep in t.get("url", ""):
211
+ continue
212
+ await AsyncTab(self.s, self.user, t["tabId"]).close()
213
+
214
+ async def current_page(self, tab=None):
215
+ from .parse import parse_page
216
+ return parse_page(await (tab or self.tab).main_content())
217
+
218
+ async def download_image(self, path="/tmp/moodle_img.jpg", tab=None):
219
+ t = tab or self.tab
220
+ d = (await t.evaluate("""(async () => {
221
+ const img = document.querySelector('.qtext img, .content img');
222
+ if (!img) return 'noimg';
223
+ await new Promise(r => img.complete ? r() : img.onload = r);
224
+ const c = document.createElement('canvas');
225
+ c.width = img.naturalWidth; c.height = img.naturalHeight;
226
+ c.getContext('2d').drawImage(img, 0, 0);
227
+ return c.toDataURL('image/jpeg', 0.9);
228
+ })()""")).get("result")
229
+ if not isinstance(d, str) or "," not in d:
230
+ return None
231
+ open(path, "wb").write(base64.b64decode(d.split(",")[1]))
232
+ return path
233
+
234
+ async def download_all_images(self, prefix="/tmp/moodle_img", tab=None):
235
+ t = tab or self.tab
236
+ d = (await t.evaluate("""(async () => {
237
+ const imgs = [...document.querySelectorAll('.qtext img, .content img')];
238
+ const out = [];
239
+ for (const img of imgs) {
240
+ await new Promise(r => img.complete ? r() : img.onload = r);
241
+ const c = document.createElement('canvas');
242
+ c.width = img.naturalWidth; c.height = img.naturalHeight;
243
+ c.getContext('2d').drawImage(img, 0, 0);
244
+ out.push(c.toDataURL('image/jpeg', 0.9));
245
+ }
246
+ return JSON.stringify(out);
247
+ })()""")).get("result")
248
+ try:
249
+ arr = json.loads(d or "[]")
250
+ except Exception:
251
+ return []
252
+ paths = []
253
+ for i, data in enumerate(arr):
254
+ if isinstance(data, str) and "," in data:
255
+ p = f"{prefix}_{i}.jpg"
256
+ open(p, "wb").write(base64.b64decode(data.split(",")[1]))
257
+ paths.append(p)
258
+ return paths
259
+
260
+ async def answer_text(self, text, tab=None):
261
+ t = tab or self.tab
262
+ r = await t.evaluate(f"""(() => {{
263
+ const el = [...document.querySelectorAll('.qtext ~ * input[type=text], input[type=text]')]
264
+ .filter(x => !x.name.includes('sesskey'))[0];
265
+ if (!el) return 'notext';
266
+ el.focus();
267
+ el.value = {json.dumps(text, ensure_ascii=False)};
268
+ el.dispatchEvent(new Event('input', {{bubbles: true}}));
269
+ el.dispatchEvent(new Event('change', {{bubbles: true}}));
270
+ return el.value;
271
+ }})()""")
272
+ return r.get("result")
273
+
274
+ async def answer_match(self, pairs, tab=None):
275
+ t = tab or self.tab
276
+ r = await t.evaluate(f"""(() => {{
277
+ const pairs = {json.dumps(pairs, ensure_ascii=False)};
278
+ let n = 0;
279
+ for (const p of pairs) {{
280
+ const row = [...document.querySelectorAll('tr, .answer li')]
281
+ .find(x => (x.textContent || '').includes(p[0]));
282
+ if (!row) continue;
283
+ const sel = row.querySelector('select');
284
+ if (!sel) continue;
285
+ const opt = [...sel.options].find(o => o.textContent.trim() === p[1]);
286
+ if (opt) {{ sel.value = opt.value;
287
+ sel.dispatchEvent(new Event('change', {{bubbles: true}})); n++; }}
288
+ }}
289
+ return String(n);
290
+ }})()""")
291
+ return r.get("result")
292
+
293
+ def dump(self, name, content):
294
+ os.makedirs(self.dumps_dir, exist_ok=True)
295
+ p = f"{self.dumps_dir}/{name}"
296
+ open(p, "w").write(content if isinstance(content, str)
297
+ else json.dumps(content, ensure_ascii=False, indent=1))
298
+ return p
299
+
300
+ async def dump_question(self, q, quiz_id):
301
+ rec = {"n": q["number"], "text": q["text"],
302
+ "options": [o["label"] for o in q["options"]],
303
+ "multi": q["multi"], "timer": q["timer"]}
304
+ p = await self.download_image(
305
+ f"{self.dumps_dir}/q{quiz_id}_n{q['number']}.jpg")
306
+ if p:
307
+ rec["image"] = p
308
+ self.dump(f"q{quiz_id}_n{q['number']}.json", rec)
309
+ return rec
310
+
311
+ async def answer_step(self, labels, tab=None):
312
+ t = tab or self.tab
313
+ r = await t.js_click_label(labels)
314
+ await asyncio.sleep(0.7)
315
+ await t.js_click_next()
316
+ await asyncio.sleep(2.4)
317
+ return r, await self.current_page()
318
+
319
+ async def step_quiz(self, quiz_id, bank, noans=None, on_question=None):
320
+ noans = noans if noans is not None else []
321
+ prev = None
322
+ while True:
323
+ q = await self.current_page()
324
+ if q.get("number") == prev:
325
+ await asyncio.sleep(2)
326
+ continue
327
+ prev = q.get("number")
328
+ if not q["text"] and not q["options"] and not q.get("selects"):
329
+ return noans
330
+ rec = await self.dump_question(q, quiz_id)
331
+ ans = None
332
+ for k, v in bank.items():
333
+ if k.lower() in (q["text"] or "").lower():
334
+ ans = v
335
+ break
336
+ if on_question:
337
+ ans = await on_question(q, ans, rec)
338
+ if ans:
339
+ labs = ans if isinstance(ans, list) else [ans]
340
+ r, nxt = await self.answer_step(labs)
341
+ if not nxt["next_ref"] and nxt["text"] is None:
342
+ return noans
343
+ else:
344
+ noans.append(rec)
345
+ if not q["next_ref"]:
346
+ return noans
347
+ await self.tab.js_click_next()
348
+ await asyncio.sleep(2.4)
349
+
350
+ async def resume_attempt(self, quiz_id):
351
+ t = await self.pick(f"view.php?id={quiz_id}") or \
352
+ await self.new_tab(f"{self.site}/mod/quiz/view.php?id={quiz_id}")
353
+ self.tab = t
354
+ s = await t.main_content()
355
+ m = re.search(r'button "(Продолжить текущую попытку|Continue attempt)"[^\[]*\[e(\d+)\]', s)
356
+ if m:
357
+ await t.click(f"e{m.group(1)}")
358
+ await asyncio.sleep(3)
359
+ at = await self.pick("attempt.php")
360
+ if at:
361
+ self.tab = at
362
+ return at.id
363
+ return None
364
+
365
+ async def finish_attempt(self):
366
+ q = await self.current_page()
367
+ if q.get("next_label") and ("Закончить" in q["next_label"]
368
+ or "Finish" in q["next_label"]):
369
+ await self.tab.js_click_next()
370
+ await asyncio.sleep(3)
371
+ t = await self.pick("summary.php")
372
+ if t:
373
+ self.tab = t
374
+ s = await self.tab.main_content()
375
+ m = re.search(r'button "Отправить всё и завершить[^"]*"[^\[]*\[e(\d+)\]', s)
376
+ if m:
377
+ await self.tab.click(f"e{m.group(1)}")
378
+ await asyncio.sleep(3)
379
+ s = await self.tab.main_content()
380
+ m = re.search(r'button "Отправить всё и завершить тест"[^\[]*\[e(\d+)\]', s)
381
+ if m:
382
+ await self.tab.click(f"e{m.group(1)}")
383
+ await asyncio.sleep(6)
384
+
385
+ async def quiz_result(self, quiz_id):
386
+ v = await self.pick(f"view.php?id={quiz_id}") or \
387
+ await self.new_tab(f"{self.site}/mod/quiz/view.php?id={quiz_id}")
388
+ await asyncio.sleep(2)
389
+ s = await v.main_content()
390
+ self.dump(f"result_{quiz_id}.txt", s)
391
+ sc = re.search(r'итоговая оценка за этот тест:\s*([\d,]+)/([\d,]+)', s)
392
+ if sc:
393
+ return sc.group(1) + "/" + sc.group(2)
394
+ return re.findall(r'- row "([^"]+Отправлено[^"]*)"', s)
395
+
396
+ async def review(self, attempt_id, cmid, showall=True):
397
+ u = f"{self.site}/mod/quiz/review.php?attempt={attempt_id}&cmid={cmid}"
398
+ if showall:
399
+ u += "&showall=1"
400
+ t = self.tab or await self.new_tab(u)
401
+ await t.navigate(u)
402
+ await asyncio.sleep(3)
403
+ s = await t.snapshot(tries=2)
404
+ self.dump(f"review_{attempt_id}.txt", s)
405
+ a = s.find("Содержимое")
406
+ return s[a:s.find("Блоки")] if a >= 0 else s
moodlesolver/parse.py ADDED
@@ -0,0 +1,42 @@
1
+ import re
2
+
3
+ ANSWER_TYPES = ("radio", "checkbox", "combobox", "textbox")
4
+
5
+
6
+ def parse_page(snapshot):
7
+ q = {"type": "unknown"}
8
+ m = re.search(r'heading "Вопрос (\d+)"', snapshot)
9
+ if not m:
10
+ m = re.search(r'heading "Question (\d+)"', snapshot)
11
+ q["number"] = int(m.group(1)) if m else None
12
+ m = re.search(r'Балл:\s*([\d,]+)', snapshot)
13
+ if not m:
14
+ m = re.search(r'Mark[s]?\s*([\d,]+)', snapshot)
15
+ q["points"] = m.group(1) if m else None
16
+ m = re.search(r'heading "Текст вопроса".*?\n.*?paragraph: (.+)', snapshot)
17
+ if not m:
18
+ m = re.search(r'heading "Question text".*?\n.*?paragraph: (.+)', snapshot)
19
+ q["text"] = m.group(1).strip() if m else None
20
+ opts = []
21
+ for m in re.finditer(r'(radio|checkbox) "([^"]+)" \[e(\d+)\]([^\n]*)', snapshot):
22
+ opts.append({"ref": "e" + m.group(3), "label": m.group(2),
23
+ "type": m.group(1), "checked": "checked" in m.group(4)})
24
+ q["options"] = opts
25
+ q["selects"] = [{"ref": "e" + r, "current": l}
26
+ for l, r in re.findall(r'combobox[^"\n]*"([^"]+)" \[e(\d+)\]', snapshot)]
27
+ q["textboxes"] = [{"ref": "e" + r, "label": l}
28
+ for l, r in re.findall(r'textbox "([^"]+)" \[e(\d+)\]', snapshot)]
29
+ m = re.search(r'button "(Следующая страница|Завершить попытку[^"]*|'
30
+ r'Next page|Finish attempt[^"]*)"[^\[]*\[e(\d+)\]', snapshot)
31
+ q["next_ref"] = "e" + m.group(2) if m else None
32
+ q["next_label"] = m.group(1) if m else None
33
+ m = re.search(r'Оставшееся время (\d+):(\d+):(\d+)', snapshot)
34
+ if not m:
35
+ m = re.search(r'Time remaining (\d+):(\d+):(\d+)', snapshot)
36
+ q["timer"] = (int(m.group(1)) * 3600 + int(m.group(2)) * 60
37
+ + int(m.group(3))) if m else None
38
+ q["raw"] = snapshot
39
+ q["multi"] = any(o["type"] == "checkbox" for o in opts)
40
+ q["has_image"] = bool(q["text"] and ("picture" in q["text"].lower()
41
+ or "картинк" in q["text"].lower()))
42
+ return q
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: moodlesolver
3
+ Version: 1.0.0
4
+ Summary: Async Moodle quiz automation client with a Telethon-style session API
5
+ License: MIT
6
+ Keywords: moodle,async,automation,quiz
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Framework :: AsyncIO
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: aiohttp>=3.9
13
+
14
+ # MoodleSolver
15
+
16
+ Async Moodle quiz automation client with a Telethon-style session API.
17
+
18
+ ## Features
19
+
20
+ - **Telethon-style session**: `start()`, `connect()`, `disconnect()`, `is_connected`
21
+ - **Fully async** on top of `aiohttp` and a Camofox browser API
22
+ - **Multi-client**: run several accounts in parallel, each with its own browser profile; `disconnect()` only touches that client's tabs
23
+ - **All question types**: single choice, multiple choice, text answers, matching, image questions
24
+ - **Question dumps**: every question saved as JSON (+ optional JPG image) for offline analysis
25
+ - **Review parser**: fetch attempt reviews after submission
26
+ - **Result tracking**: read the final grade table
27
+
28
+ ## Requirements
29
+
30
+ - Python 3.10+
31
+ - A running Camofox browser API (default `http://localhost:9377`)
32
+
33
+ ## Install
34
+
35
+ ```sh
36
+ pip install moodlesolver
37
+ ```
38
+
39
+ ## Quick start
40
+
41
+ ```python
42
+ import asyncio
43
+ from moodlesolver import AsyncMoodle
44
+
45
+ async def main():
46
+ async with AsyncMoodle(
47
+ site="https://moodle.example.edu",
48
+ username="user@example.edu",
49
+ password="secret",
50
+ ) as m:
51
+ print("logged in as", await m.whoami())
52
+
53
+ await m.resume_attempt(quiz_id=123)
54
+ q = await m.current_page()
55
+ print(q["number"], q["text"], [o["label"] for o in q["options"]])
56
+
57
+ r, nxt = await m.answer_step(["correct answer label"])
58
+ print("answered", r, "timer left", nxt["timer"])
59
+
60
+ await m.finish_attempt()
61
+ print(await m.quiz_result(quiz_id=123))
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ ## Session lifecycle
67
+
68
+ ```python
69
+ m = AsyncMoodle(site=..., username=..., password=...)
70
+ await m.connect() # open HTTP session to the browser API
71
+ await m.start() # login (or reuse an existing session)
72
+ ...
73
+ await m.disconnect() # close this client's tabs and session
74
+ ```
75
+
76
+ `AsyncMoodle` also works as an async context manager (`async with`).
77
+
78
+ ## API overview
79
+
80
+ | Method | Description |
81
+ |---|---|
82
+ | `start() / connect() / disconnect()` | Session lifecycle |
83
+ | `login(u, p) / whoami()` | Authentication |
84
+ | `new_tab(url) / pick(substr) / tabs() / close_stale()` | Tab management |
85
+ | `current_page()` | Parse the current question (type, options, timer) |
86
+ | `answer_step(labels)` | Answer by label text, then submit ("next") in one step |
87
+ | `answer_text(text)` | Fill a text-answer input |
88
+ | `answer_match(pairs)` | Fill matching selects |
89
+ | `download_image(path) / download_all_images(prefix)` | Export question images |
90
+ | `dump_question(q, quiz_id) / step_quiz(quiz_id, bank)` | Dump questions and drive a full quiz from an answer bank |
91
+ | `resume_attempt(quiz_id) / finish_attempt() / quiz_result(quiz_id) / review(attempt, cmid)` | Quiz lifecycle |
92
+
93
+ ## Question bank format
94
+
95
+ `step_quiz` accepts a dict mapping question text (case-insensitive substring) to the answer:
96
+
97
+ ```python
98
+ bank = {
99
+ "Some question text": "single answer label",
100
+ "Pick all that apply": ["first", "second"],
101
+ }
102
+ ```
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,7 @@
1
+ moodlesolver/__init__.py,sha256=QWTSFuNZe6JndPQACAT_lOy1y43fakyC9Nt7uYbR398,250
2
+ moodlesolver/client.py,sha256=Dr1i_J2_-Jeb_bQFtFRg9yVEQvvTNMHIxPacNf6YD9s,15771
3
+ moodlesolver/parse.py,sha256=4dE94sB5w_g_N8en9zPMVK5CBufK4uYjWFtc2IgWxck,2091
4
+ moodlesolver-1.0.0.dist-info/METADATA,sha256=HSufwS_V1iplJ4PoMDGnesdhsq4g-lBgy3rbRd4aQmA,3357
5
+ moodlesolver-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ moodlesolver-1.0.0.dist-info/top_level.txt,sha256=-wcKkHBjt3xJhaJqI79sm9wiYKh4c8Z8dKMzRUrRxxM,13
7
+ moodlesolver-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ moodlesolver