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
|
2025-06-30 09:16:18 -07:00
|
|
|
import logging
|
2025-06-23 16:19:34 -07:00
|
|
|
from datetime import datetime
|
|
|
|
|
|
2025-06-30 09:16:18 -07:00
|
|
|
# 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:
|
2025-06-30 09:16:18 -07:00
|
|
|
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):
|
2025-06-30 09:16:18 -07:00
|
|
|
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
|
2025-06-30 09:16:18 -07:00
|
|
|
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")
|
2025-06-30 09:16:18 -07:00
|
|
|
|
|
|
|
|
while attempts > 0:
|
2025-06-30 10:39:33 -07:00
|
|
|
choice = input("\nEnter number: ").strip()
|
2025-06-30 09:16:18 -07:00
|
|
|
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 09:16:18 -07:00
|
|
|
|
2025-06-30 10:39:33 -07:00
|
|
|
print("\nToo many invalid attempts. Exiting.")
|
2025-06-30 09:16:18 -07:00
|
|
|
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
|
2025-06-30 09:16:18 -07:00
|
|
|
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
|
|
|
|
2025-06-30 09:16:18 -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")
|
2025-06-30 09:16:18 -07:00
|
|
|
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
|
2025-06-30 09:16:18 -07:00
|
|
|
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
|
|
|
|
2025-06-30 09:16:18 -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()
|