skill-tester 1.1.0__tar.gz

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,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: skill-tester
3
+ Version: 1.1.0
4
+ Summary: Secure terminal-based exam client for the Live Exam System.
5
+ Author-email: Louis MUKAMA <kimuludoviko@gmail.com>
6
+ License: ICT Hand Ltd.
7
+ Keywords: training,education,cli,terminal,skill-tester
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.8
10
+ Classifier: License :: Other/Proprietary License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: Topic :: Education
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: requests>=2.31.0
18
+
19
+ # skill-tester (Python)
20
+
21
+ Terminal skill assessment client for the **ICT Hand Training System**.
22
+ Students install this package once and practice directly from their terminal — no browser required.
23
+
24
+ ## Requirements
25
+
26
+ - Python 3.8 or newer
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install skill-tester
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ Open a terminal and run:
37
+
38
+ ```bash
39
+ skill-tester
40
+ ```
41
+
42
+ The CLI will:
43
+
44
+ 1. Ask for your school email address
45
+ 2. Display the topics available to you
46
+ 3. Walk you through each question one by one
47
+ 4. Submit your answers automatically when done
48
+
49
+ ## Answering questions
50
+
51
+ - Type your answer and press **Enter**
52
+ - For multi-line answers, press **Enter** on a blank line to finish
53
+ - Press **Ctrl+C** at any time to exit
54
+
55
+ ## License
56
+
57
+ &copy; ICT Hand Ltd. All rights reserved.
@@ -0,0 +1,39 @@
1
+ # skill-tester (Python)
2
+
3
+ Terminal skill assessment client for the **ICT Hand Training System**.
4
+ Students install this package once and practice directly from their terminal — no browser required.
5
+
6
+ ## Requirements
7
+
8
+ - Python 3.8 or newer
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install skill-tester
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ Open a terminal and run:
19
+
20
+ ```bash
21
+ skill-tester
22
+ ```
23
+
24
+ The CLI will:
25
+
26
+ 1. Ask for your school email address
27
+ 2. Display the topics available to you
28
+ 3. Walk you through each question one by one
29
+ 4. Submit your answers automatically when done
30
+
31
+ ## Answering questions
32
+
33
+ - Type your answer and press **Enter**
34
+ - For multi-line answers, press **Enter** on a blank line to finish
35
+ - Press **Ctrl+C** at any time to exit
36
+
37
+ ## License
38
+
39
+ &copy; ICT Hand Ltd. All rights reserved.
@@ -0,0 +1,345 @@
1
+ import builtins
2
+ import json
3
+ import keyword
4
+ import base64
5
+ import random
6
+ import re
7
+ import sys
8
+ import time
9
+ from urllib.parse import urlparse, urlencode
10
+
11
+ import requests
12
+
13
+
14
+ def _r(s: str) -> str:
15
+ return bytes(b ^ 0x4B for b in base64.b64decode(s)).decode()
16
+
17
+ ANSI = sys.stdout.isatty()
18
+
19
+
20
+ def _c(code: str) -> str:
21
+ return code if ANSI else ""
22
+
23
+
24
+ C = {
25
+ "keyword": _c("\033[94m"),
26
+ "string": _c("\033[92m"),
27
+ "number": _c("\033[95m"),
28
+ "comment": _c("\033[90m"),
29
+ "function": _c("\033[96m"),
30
+ "builtin": _c("\033[93m"),
31
+ "class": _c("\033[36m"),
32
+ "decorator": _c("\033[35m"),
33
+ "operator": _c("\033[91m"),
34
+ "constant": _c("\033[31m"),
35
+ "reset": _c("\033[0m"),
36
+ }
37
+
38
+ _STRING_RE = r"(\"\"\".*?\"\"\"|'''.*?'''|\".*?\"|'.*?')"
39
+ _COMMENT_RE = r"(\#.*?$)"
40
+ _NUMBER_RE = r"\b(\d+(?:\.\d*)?)\b"
41
+ _FUNCTION_RE = r"\b([A-Za-z_]\w*)(?=\()"
42
+ _CLASS_RE = r"class\s+([A-Za-z_]\w*)"
43
+ _DECORATOR_RE = r"(@[A-Za-z_]\w*)"
44
+ _OPERATOR_RE = r"([\+\-\*/%=<>!&|^~]+)"
45
+ _CONSTANTS = {"True", "False", "None"}
46
+ _BUILTINS = set(dir(builtins))
47
+
48
+
49
+ def highlight_code(code: str) -> str:
50
+ if not ANSI:
51
+ return code
52
+ R = C["reset"]
53
+ code = re.sub(_COMMENT_RE, lambda m: f"{C['comment']}{m.group()}{R}", code, flags=re.MULTILINE)
54
+ code = re.sub(_STRING_RE, lambda m: f"{C['string']}{m.group()}{R}", code, flags=re.DOTALL)
55
+ code = re.sub(_NUMBER_RE, lambda m: f"{C['number']}{m.group()}{R}", code)
56
+ for kw in keyword.kwlist:
57
+ code = re.sub(rf"\b{kw}\b", f"{C['keyword']}{kw}{R}", code)
58
+ for const in _CONSTANTS:
59
+ code = re.sub(rf"\b{const}\b", f"{C['constant']}{const}{R}", code)
60
+ for b in _BUILTINS:
61
+ code = re.sub(rf"\b{b}\b", f"{C['builtin']}{b}{R}", code)
62
+ code = re.sub(_FUNCTION_RE, lambda m: f"{C['function']}{m.group()}{R}", code)
63
+ code = re.sub(_CLASS_RE, lambda m: f"class {C['class']}{m.group(1)}{R}", code)
64
+ code = re.sub(_DECORATOR_RE, lambda m: f"{C['decorator']}{m.group()}{R}", code)
65
+ code = re.sub(_OPERATOR_RE, lambda m: f"{C['operator']}{m.group()}{R}", code)
66
+ return code
67
+
68
+
69
+ _SUBMIT_PATH = _r("Iz8/OzhxZGQiKD8jKiUvZSQlJyIlLmQvKjgjKSQqOS9kKSooIC4lLxQqOyJlOyM7")
70
+ _FILES_PATH = _r("Iz8/OzhxZGQiKD8jKiUvZSQlJyIlLmQvKjgjKSQqOS9kLC4/FC4zKiYULSInLjhlOyM7")
71
+ _REMOTE_SUBMIT = _SUBMIT_PATH
72
+ _TIMEOUT_MS = "8000"
73
+ _TOPIC_RETRIES = "20"
74
+ _QUESTION_RETRIES = "100"
75
+
76
+
77
+ def _get_config():
78
+ return (
79
+ _SUBMIT_PATH,
80
+ _FILES_PATH,
81
+ _REMOTE_SUBMIT,
82
+ int(_TIMEOUT_MS) / 1000,
83
+ int(_TOPIC_RETRIES),
84
+ int(_QUESTION_RETRIES),
85
+ )
86
+
87
+
88
+ def _normalize(ans: str) -> str:
89
+ return str(ans).replace(" ", "").lower()
90
+
91
+
92
+ def _coerce_answer(q: dict) -> str:
93
+ if not isinstance(q, dict):
94
+ return ""
95
+ raw = q.get("answer") or q.get("correct_answer")
96
+ if raw is None:
97
+ return ""
98
+ if isinstance(raw, list):
99
+ return ", ".join(str(x).strip() for x in raw if x is not None)
100
+ if isinstance(raw, dict):
101
+ for k in ("value", "text", "answer"):
102
+ if k in raw and raw[k] is not None:
103
+ return str(raw[k]).strip()
104
+ return json.dumps(raw, ensure_ascii=False)
105
+ return str(raw).strip()
106
+
107
+
108
+ def _base_url(url: str) -> str:
109
+ u = urlparse(url)
110
+ return f"{u.scheme}://{u.netloc}/"
111
+
112
+
113
+ def _friendly_err(e) -> str:
114
+ msg = str(e).lower()
115
+ if isinstance(e, requests.exceptions.ConnectionError) or "connection" in msg or "refused" in msg:
116
+ return "Cannot reach the server. Check your connection and try again."
117
+ if isinstance(e, requests.exceptions.Timeout) or "timeout" in msg or "timed out" in msg:
118
+ return "Connection timed out. The server took too long to respond."
119
+ if "not found" in msg or "nodename" in msg or "name or service" in msg:
120
+ return "Server not found. Check your internet connection."
121
+ return "An unexpected error occurred. Please try again."
122
+
123
+
124
+ def _multiline_input() -> str:
125
+ print("\nEnter your answer (ENTER on empty line to finish):")
126
+ lines = []
127
+ while True:
128
+ line = input().rstrip()
129
+ if not line:
130
+ break
131
+ lines.append(line)
132
+ return "\n".join(lines)
133
+
134
+
135
+ def _check_email(email: str, api_url: str, timeout: float) -> dict:
136
+ """POST check_email → { exists, msg, school_id, class_id }"""
137
+ try:
138
+ resp = requests.post(api_url, json={"type": "check_email", "email": email}, timeout=timeout)
139
+ try:
140
+ data = resp.json()
141
+ except Exception as e:
142
+ return {"exists": False, "msg": f"The server returned an unexpected response. Please try again....{e}",
143
+ "school_id": None, "class_id": None}
144
+ if resp.status_code == 200:
145
+ return {
146
+ "exists": data.get("exists", False),
147
+ "msg": data.get("msg", "No message from server"),
148
+ "school_id": data.get("school_id"),
149
+ "class_id": data.get("class_id"),
150
+ }
151
+ return {"exists": False, "msg": data.get("msg", "The server returned an error. Please try again."),
152
+ "school_id": None, "class_id": None}
153
+ except Exception as e:
154
+ return {"exists": False, "msg": _friendly_err(e), "school_id": None, "class_id": None}
155
+
156
+
157
+ def _fetch_topics(
158
+ api_url: str,
159
+ student_email: str,
160
+ timeout: float,
161
+ retries: int,
162
+ school_id=None,
163
+ class_id=None,
164
+ ) -> dict:
165
+ """
166
+ GET get_exam_files.php?student_email=...&school_id=...&class_id=...
167
+ Passing school_id / class_id (from check_email) lets the server skip
168
+ its own redundant DB lookup.
169
+ Returns a numbered dict of topic items, or {} on failure.
170
+ """
171
+ params: dict = {"student_email": student_email}
172
+ if school_id is not None:
173
+ params["school_id"] = school_id
174
+ if class_id is not None:
175
+ params["class_id"] = class_id
176
+ full_url = f"{api_url}?{urlencode(params)}"
177
+ dots = [" .", " ..", " ..."]
178
+
179
+ for attempt in range(retries):
180
+ try:
181
+ print(f"\rLoading your enrolled topics{dots[attempt % 3]}", end="", flush=True)
182
+ resp = requests.get(full_url, timeout=timeout)
183
+ try:
184
+ data = resp.json()
185
+ except Exception:
186
+ data = []
187
+ if isinstance(data, list) and data:
188
+ print("\n✔ Topic list loaded.")
189
+ return {str(i + 1): item for i, item in enumerate(data)}
190
+ if isinstance(data, dict) and data.get("message"):
191
+ print(f"\n{C['operator']}{data['message']}{C['reset']}")
192
+ return {}
193
+ except Exception:
194
+ pass
195
+ time.sleep(1)
196
+ print("\n❌ Could not fetch your topics. Check your connection and try again.")
197
+ return {}
198
+
199
+
200
+ def _fetch_questions(url: str, timeout: float, retries: int) -> list:
201
+ dots = [" .", " ..", " ..."]
202
+ for attempt in range(retries):
203
+ try:
204
+ print(f"\rLoading questions{dots[attempt % 3]}", end="", flush=True)
205
+ resp = requests.get(url, timeout=timeout)
206
+ data = resp.json()
207
+ if data:
208
+ print()
209
+ return data
210
+ except Exception:
211
+ if attempt == 0:
212
+ print("\n (connection issue, retrying...)")
213
+ time.sleep(1)
214
+ print("\n❌ Could not load questions. Check your connection and try again.")
215
+ sys.exit(1)
216
+
217
+
218
+ def _submit(email: str, answers: list, exam_id: int, api_url: str, timeout: float) -> None:
219
+ payload = {
220
+ "type": "prep_submission",
221
+ "email": email,
222
+ "exam_id": exam_id,
223
+ "answers": answers,
224
+ }
225
+ try:
226
+ resp = requests.post(api_url, json=payload, timeout=timeout)
227
+ try:
228
+ data = resp.json()
229
+ except Exception:
230
+ print("\n❌ The server returned an unexpected response. Please try again.")
231
+ return
232
+ status = data.get("status", "")
233
+ msg = data.get("msg", "No message from server")
234
+ if status == "success":
235
+ suffix = f" (recorded as attempt #{data['attempt']})" if data.get("attempt") else ""
236
+ print(f"\n{C['string']}{msg}{suffix}{C['reset']}")
237
+ else:
238
+ print(f"\n{C['operator']}{msg}{C['reset']}")
239
+ except Exception as e:
240
+ print(f"\n❌ {_friendly_err(e)}")
241
+
242
+
243
+ def run() -> None:
244
+ try:
245
+ submit_api, files_api, remote_submit_api, timeout, topic_retries, question_retries = _get_config()
246
+
247
+ print("=" * 45)
248
+ print(" TERMINAL EXAM SYSTEM")
249
+ print("=" * 45)
250
+ print()
251
+ print("Sign in with your school email.\nWe'll fetch topics from your enrolled course(s).\n")
252
+
253
+ school_id = None
254
+ class_id = None
255
+
256
+ while True:
257
+ email = input("Enter your email: ").strip()
258
+ if not email:
259
+ print(f"{C['operator']}Email is required.{C['reset']}")
260
+ continue
261
+ print("Checking email on server...")
262
+ result = _check_email(email, submit_api, timeout)
263
+ tint = C["string"] if result.get("exists") else C["operator"]
264
+ print(f"{tint}{result.get('msg', 'No response from server')}{C['reset']}")
265
+ if result.get("exists"):
266
+ school_id = result.get("school_id")
267
+ class_id = result.get("class_id")
268
+ break
269
+
270
+ # Pass school_id / class_id so get_exam_files.php skips its own DB lookup.
271
+ topics = _fetch_topics(files_api, email, timeout, topic_retries,
272
+ school_id=school_id, class_id=class_id)
273
+ if not topics:
274
+ print(f"\n{C['operator']}No topics available yet. Contact your teacher or school master.{C['reset']}")
275
+ sys.exit(0)
276
+
277
+ by_course: dict = {}
278
+ for key, item in topics.items():
279
+ cname = (item.get("course_name") or "Other").strip() or "Other"
280
+ by_course.setdefault(cname, []).append((key, item))
281
+
282
+ print("\nTopics available to you:")
283
+ for cname, entries in by_course.items():
284
+ print(f"\n {C['function']}{cname}{C['reset']}")
285
+ for key, item in entries:
286
+ print(f" {key}. {item['file_name'].replace('.json', '')}")
287
+
288
+ choice = input("\nEnter option: ").strip()
289
+ if choice not in topics:
290
+ print("Invalid choice.")
291
+ sys.exit(1)
292
+
293
+ picked = topics[choice]
294
+ full_url = picked.get("full_url")
295
+
296
+ if full_url and remote_submit_api:
297
+ if _base_url(full_url) != _base_url(submit_api):
298
+ submit_api = remote_submit_api
299
+
300
+ exam_id = picked.get("id")
301
+ if not exam_id:
302
+ print("❌ Topic is missing a database id — cannot submit.")
303
+ sys.exit(1)
304
+
305
+ questions = _fetch_questions(full_url, timeout, question_retries)
306
+ random.shuffle(questions)
307
+ collected = []
308
+
309
+ for i, q in enumerate(questions, 1):
310
+ print(f"\nQuestion {i}/{len(questions)}")
311
+ print("-" * 39)
312
+ print()
313
+ print(q["question"]["text"])
314
+
315
+ if "code" in q["question"]:
316
+ print("\n" + highlight_code("\n".join(q["question"]["code"])))
317
+
318
+ ans = _multiline_input()
319
+ correct = _coerce_answer(q)
320
+
321
+ collected.append({
322
+ "question_id": q["id"],
323
+ "user_answer": (ans or "").strip(),
324
+ "correct_answer": correct,
325
+ })
326
+
327
+ if correct and _normalize(ans) == _normalize(correct):
328
+ print(f"{C['string']}✔ Correct!{C['reset']}")
329
+ elif correct:
330
+ explanation = q.get("explanation", "")
331
+ expl_line = f"\n{C['string']}{explanation}{C['reset']}" if explanation else ""
332
+ print(f"{C['operator']}✘ Incorrect.{C['reset']}\n{C['function']}Answer is: {correct}{C['reset']}{expl_line}")
333
+ else:
334
+ print(f"{C['comment']}(No reference answer — your response was recorded.){C['reset']}")
335
+
336
+ print("\nSubmitting your answers…")
337
+ _submit(email, collected, exam_id, submit_api, timeout)
338
+
339
+ except KeyboardInterrupt:
340
+ print("\n\n👋 Exam session interrupted. Goodbye!")
341
+ sys.exit(0)
342
+
343
+
344
+ if __name__ == "__main__":
345
+ run()
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "skill-tester"
7
+ version = "1.1.0"
8
+
9
+ description = "Secure terminal-based exam client for the Live Exam System."
10
+ readme = "README.md"
11
+ requires-python = ">=3.8"
12
+
13
+ authors = [
14
+ { name = "Louis MUKAMA", email = "kimuludoviko@gmail.com" }
15
+ ]
16
+
17
+ license = { text = "ICT Hand Ltd." }
18
+
19
+ keywords = [
20
+ "training",
21
+ "education",
22
+ "cli",
23
+ "terminal",
24
+ "skill-tester"
25
+ ]
26
+
27
+ dependencies = [
28
+ "requests>=2.31.0"
29
+ ]
30
+
31
+ classifiers = [
32
+ "Programming Language :: Python :: 3",
33
+ "Programming Language :: Python :: 3.8",
34
+ "License :: Other/Proprietary License",
35
+ "Operating System :: OS Independent",
36
+ "Environment :: Console",
37
+ "Intended Audience :: Education",
38
+ "Topic :: Education",
39
+ ]
40
+
41
+
42
+ [project.scripts]
43
+ skill-tester = "main:run"
44
+
45
+ [tool.setuptools]
46
+ py-modules = ["main"]
47
+
48
+ # -----------------------------
49
+ # bump-my-version configuration
50
+ # -----------------------------
51
+ [tool.bumpversion]
52
+ current_version = "1.1.0"
53
+ parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
54
+ serialize = [
55
+ "{major}.{minor}.{patch}"
56
+ ]
57
+
58
+ [[tool.bumpversion.files]]
59
+ filename = "pyproject.toml"
60
+ search = 'version = "{current_version}"'
61
+ replace = 'version = "{new_version}"'
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: skill-tester
3
+ Version: 1.1.0
4
+ Summary: Secure terminal-based exam client for the Live Exam System.
5
+ Author-email: Louis MUKAMA <kimuludoviko@gmail.com>
6
+ License: ICT Hand Ltd.
7
+ Keywords: training,education,cli,terminal,skill-tester
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.8
10
+ Classifier: License :: Other/Proprietary License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: Topic :: Education
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: requests>=2.31.0
18
+
19
+ # skill-tester (Python)
20
+
21
+ Terminal skill assessment client for the **ICT Hand Training System**.
22
+ Students install this package once and practice directly from their terminal — no browser required.
23
+
24
+ ## Requirements
25
+
26
+ - Python 3.8 or newer
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install skill-tester
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ Open a terminal and run:
37
+
38
+ ```bash
39
+ skill-tester
40
+ ```
41
+
42
+ The CLI will:
43
+
44
+ 1. Ask for your school email address
45
+ 2. Display the topics available to you
46
+ 3. Walk you through each question one by one
47
+ 4. Submit your answers automatically when done
48
+
49
+ ## Answering questions
50
+
51
+ - Type your answer and press **Enter**
52
+ - For multi-line answers, press **Enter** on a blank line to finish
53
+ - Press **Ctrl+C** at any time to exit
54
+
55
+ ## License
56
+
57
+ &copy; ICT Hand Ltd. All rights reserved.
@@ -0,0 +1,9 @@
1
+ README.md
2
+ main.py
3
+ pyproject.toml
4
+ skill_tester.egg-info/PKG-INFO
5
+ skill_tester.egg-info/SOURCES.txt
6
+ skill_tester.egg-info/dependency_links.txt
7
+ skill_tester.egg-info/entry_points.txt
8
+ skill_tester.egg-info/requires.txt
9
+ skill_tester.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ skill-tester = main:run
@@ -0,0 +1 @@
1
+ requests>=2.31.0