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.
- skill_tester-1.1.0/PKG-INFO +57 -0
- skill_tester-1.1.0/README.md +39 -0
- skill_tester-1.1.0/main.py +345 -0
- skill_tester-1.1.0/pyproject.toml +61 -0
- skill_tester-1.1.0/setup.cfg +4 -0
- skill_tester-1.1.0/skill_tester.egg-info/PKG-INFO +57 -0
- skill_tester-1.1.0/skill_tester.egg-info/SOURCES.txt +9 -0
- skill_tester-1.1.0/skill_tester.egg-info/dependency_links.txt +1 -0
- skill_tester-1.1.0/skill_tester.egg-info/entry_points.txt +2 -0
- skill_tester-1.1.0/skill_tester.egg-info/requires.txt +1 -0
- skill_tester-1.1.0/skill_tester.egg-info/top_level.txt +1 -0
|
@@ -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
|
+
© 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
|
+
© 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,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
|
+
© 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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.31.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
main
|