Files
Python/ham-test-cli.py
T

178 lines
6.4 KiB
Python
Raw Normal View History

2025-06-23 16:19:34 -07:00
#!/usr/bin/python3
# Description: A command-line interface for the HAM Operator Test.
2025-06-30 10:39:33 -07:00
# Usage: python3 ham-test-cli.py
2025-06-23 16:19:34 -07:00
# Author: Justin Oros
# Source: https://github.com/JustinOros
import json
import os
import random
import requests
import logging
2025-06-23 16:19:34 -07:00
from datetime import datetime
# Set up logging
logging.basicConfig(level=logging.WARNING, format='[%(levelname)s] %(message)s')
2025-06-23 16:19:34 -07:00
# URLs to download the latest official HAM exam question pools from GitHub
URLS = {
"Technician": "https://raw.githubusercontent.com/russolsen/ham_radio_question_pool/master/technician-2022-2026/technician.json",
"General": "https://raw.githubusercontent.com/russolsen/ham_radio_question_pool/master/general-2023-2027/general.json",
"Extra": "https://raw.githubusercontent.com/russolsen/ham_radio_question_pool/master/extra-2024-2028/extra.json"
}
# Corresponding local filenames for each question pool
LOCAL_FILES = {
"Technician": "technician.json",
"General": "general.json",
"Extra": "extra.json"
}
# Get the Last-Modified timestamp of a remote file, used to detect updates
def get_remote_last_modified(url):
try:
r = requests.head(url, timeout=10)
if r.status_code == 200 and 'Last-Modified' in r.headers:
lm = r.headers['Last-Modified']
dt = datetime.strptime(lm, '%a, %d %b %Y %H:%M:%S %Z')
return dt.timestamp()
except Exception as e:
logging.warning(f"Could not get Last-Modified for {url}: {e}")
2025-06-23 16:19:34 -07:00
return None
# Download the file from GitHub and save it locally
def download_file(url, local_path):
print(f"Downloading latest questions from {url} ...")
r = requests.get(url, timeout=20)
r.raise_for_status()
with open(local_path, "w", encoding='utf-8') as f:
f.write(r.text)
print(f"Saved to {local_path}")
# Determine whether the local file is newer than the remote version
def is_local_file_up_to_date(local_path, remote_timestamp):
if not os.path.exists(local_path):
return False
local_mtime = os.path.getmtime(local_path)
return local_mtime >= remote_timestamp
# Sync all 3 test pools with the GitHub repo (if newer versions exist)
def sync_files():
for test_name, url in URLS.items():
local_path = LOCAL_FILES[test_name]
remote_ts = get_remote_last_modified(url)
if remote_ts is None:
if not os.path.exists(local_path):
print(f"No local file for {test_name} and unable to get remote timestamp. Downloading anyway.")
download_file(url, local_path)
else:
print(f"Skipping update for {test_name} (no remote timestamp).")
else:
if not is_local_file_up_to_date(local_path, remote_ts):
print(f"{local_path} is outdated, updating it.")
2025-06-23 16:19:34 -07:00
download_file(url, local_path)
else:
print(f"{local_path} is up to date.")
# Load questions from the locally stored JSON file
def load_questions(test_name):
local_path = LOCAL_FILES[test_name]
if not os.path.exists(local_path):
print(f"Error: Local file {local_path} not found.")
return []
with open(local_path, "r", encoding='utf-8') as f:
return json.load(f)
# Prompt the user to select one of the 3 test pools
def select_test(attempts=3):
# Prompt user to select an exam type
2025-06-30 10:39:33 -07:00
print("\nSelect the test you want to take:\n")
2025-06-23 16:19:34 -07:00
for idx, name in enumerate(URLS.keys(), start=1):
print(f"{idx}. {name} Class")
while attempts > 0:
2025-06-30 10:39:33 -07:00
choice = input("\nEnter number: ").strip()
try:
return list(URLS.keys())[int(choice) - 1]
except (ValueError, IndexError):
attempts -= 1
2025-06-30 10:39:33 -07:00
print(f"\nInvalid choice. You have {attempts} attempts left.\n")
2025-06-30 10:39:33 -07:00
print("\nToo many invalid attempts. Exiting.")
exit(1)
2025-06-23 16:19:34 -07:00
# Core quiz loop: presents questions, checks answers, tracks score
def run_quiz(questions):
score = 0
total = 0
random.shuffle(questions) # Randomize question order each session
print("\nPress 'Q' at any time to quit.\n")
for q in questions:
question_text = q.get("question")
answers = q.get("answers", [])
correct_index = q.get("correct")
# Skip if the structure is invalid
if not question_text or not answers or correct_index is None:
continue
if correct_index < 0 or correct_index >= len(answers):
logging.warning(f"Skipping question with invalid correct index: {question_text}")
continue
2025-06-23 16:19:34 -07:00
# Pair each answer with whether it is correct
answer_options = [(i, ans, i == correct_index) for i, ans in enumerate(answers)]
random.shuffle(answer_options) # Shuffle answer order
# Create a letter map from shuffled options
letter_map = {}
correct_letter = None
for idx, (orig_index, ans_text, is_correct) in enumerate(answer_options):
letter = chr(65 + idx)
letter_map[letter] = (orig_index, ans_text)
if is_correct:
correct_letter = letter
correct_answer = ans_text
2025-06-23 16:19:34 -07:00
2025-06-30 10:39:33 -07:00
print(f"\n{question_text}\n")
for letter in sorted(letter_map.keys()):
print(f" {letter}. {letter_map[letter][1]}")
2025-06-23 16:19:34 -07:00
# Get the user's answer
while True:
2025-06-30 10:39:33 -07:00
user_input = input("\nYour answer (A/B/C/D or Q to quit): ").strip().upper()
2025-06-23 16:19:34 -07:00
if user_input == 'Q':
2025-06-30 10:39:33 -07:00
print("\nExiting test.")
print(f"\nFinal score: {score}/{total}")
2025-06-23 16:19:34 -07:00
return
if user_input in letter_map:
break
else:
2025-06-30 10:39:33 -07:00
print("\nInvalid input. Please enter A, B, C, D or Q.")
2025-06-23 16:19:34 -07:00
# Score the answer
total += 1
selected_index = letter_map[user_input][0]
2025-06-23 16:19:34 -07:00
if selected_index == correct_index:
2025-06-30 10:39:33 -07:00
print("\n✅ Correct!\n")
2025-06-23 16:19:34 -07:00
score += 1
else:
2025-06-30 10:39:33 -07:00
print(f"\n❌ Incorrect. The correct answer is {correct_letter}. {correct_answer}\n")
2025-06-23 16:19:34 -07:00
percentage = (score / total) * 100 if total > 0 else 0
print(f"Test completed. Score: {score}/{total} ({percentage:.2f}%)")
2025-06-23 16:19:34 -07:00
# Main program flow
def main():
print("Syncing question files...")
sync_files()
test_name = select_test()
questions = load_questions(test_name)
if not questions:
print("No questions found.")
return
run_quiz(questions)
if __name__ == "__main__":
main()