Programma quiz sulla storia di Python

Oct 17 2020

Questo programma consente agli utenti di gestire domande e utenti di quiz da anni di eventi storici. Sto cercando suggerimenti su come posso organizzare meglio il mio codice, magari applicare l'OOP per le funzioni ed evitare di usare la variabile globale data_filename.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from enum import Enum
import os
import pickle
import random


data_filename = 'program.obj'


def read_int(prompt='> ', errmsg='Invalid number!'):
    number = None
    while number is None:
        try:
            number = int(input(prompt))
        except ValueError:
            print(errmsg)
    return number


def display_menu():
    print('What do you want to do?')
    print('[1] List all historical events')
    print('[2] Add event')
    print('[3] Remove event')
    print('[4] Quiz')
    print('[5] Statistics')
    print('[6] Clear statistics') # TODO: move to options submenu
    print('[7] Exit')


def clear_screen():
    print(chr(27) + "[2J")


def pause():
    input('Press any key to continue...')


def yes_or_no(prompt='Proceed? [y|n]\n> ', errmsg='Valid answers are y and n.'):
    answer = input(prompt).strip().lower()
    while answer != 'y' and answer != 'n':
        print(errmsg)
        answer = input(prompt).strip().lower()
    return answer


def read_data_file():
    file = open(data_filename, 'a+b')
    file.seek(0)
    data = {'events': [], 'statistics': {'total_successes': 0, 'total_failures': 0}}
    if os.path.getsize(data_filename) > 0:
        data = pickle.load(file)
    file.close()
    return data


def list_events(data):
    events = data['events']
    if len(events) == 0:
        print('Event list is empty!')
    else:
        print('Historical events:')
        for event in events:
            print(event)


def add_event(data):
    events = data['events']
    year = read_int(prompt='Enter year: ')
    description = input('Enter description: ')
    events.append({'year': year, 'description': description})
    print('Successfully added a new historical event!')


def remove_event(data):
    events = data['events']
    if len(events) == 0:
        print('Event list is empty!')
    else:
        for event in events:
            print(events.index(event), event)
        index = read_int('Which event do you want to delete? ')
        try:
            events.pop(index)
            print('Successfully deleted event!')
        except IndexError:
            print('Number out of range!')


def quiz(data):
    events = data['events']
    stats = data['statistics']
    if len(events) == 0:
        print('Event list is empty!')
    else:
        num = read_int(prompt='How many questions should I ask? ')
        if 0 < num <= len(events):
            for event in random.sample(events, num):
                print(event['description'])
                year = read_int('In which year was following event occurred? ')
                if year == event['year']:
                    stats['total_successes'] += 1
                    print('Good answer!')
                else:
                    stats['total_failures'] += 1
                    print('Bad answer!')
        elif num < 0:
            print('Number of questions can\'t be negative!')
        else:
            print('Too much questions!')


def display_stats(data):
    stats = data['statistics']
    tries = stats['total_successes'] + stats['total_failures']
    if tries == 0:
        total_successes = 0
        total_failures = 0
    else:
        total_successes = stats['total_successes']/tries * 100
        total_failures = stats['total_failures']/tries * 100
    print('Statistics')
    print('Total: {0:10.2f}% successes, {1:10.2f}% failures'.format(total_successes, total_failures))


def clear_stats(data):
    answer = yes_or_no('Are you sure you want to clear statistics? [y|n]\n> ')
    if answer == 'y':
        data['statistics'] = {'total_successes': 0, 'total_failures': 0}
        print('Successfully cleared statistics!')
    else:
        print('Statistics left unchanged.')


# TODO: settings submenu
# def settings(data):
#     print('-' * 10)
#     print('Program settings')
#     print('-' * 10)
#     print('[1] Clear statistics')
#     print('[2] Back')
#     user_choice = read_int()
#     while user_choice != 2:
#         if user_choice == 1:
#             clear_stats(data)


def update_data_file(data):
    file = open(data_filename, 'wb')
    pickle.dump(data, file)
    file.close()


class Choices(Enum):
    list_events = 1
    add_event = 2
    remove_event = 3
    quiz = 4
    statistics = 5
    clear_stats = 6
    exit = 7


program_data = read_data_file()
choice = None
while choice != Choices.exit.value:
    clear_screen()
    display_menu()
    choice = read_int()
    if choice == Choices.list_events.value:
        list_events(program_data)
    elif choice == Choices.add_event.value:
        add_event(program_data)
    elif choice == Choices.remove_event.value:
        remove_event(program_data)
    elif choice == Choices.quiz.value:
        quiz(program_data)
    elif choice == Choices.statistics.value:
        display_stats(program_data)
    elif choice == Choices.clear_stats.value:
        clear_stats(program_data)
    elif choice == Choices.exit.value:
        print('Good bye!')
    else:
        print('Invalid choice!')
    update_data_file(program_data)
    pause()

Risposte

3 Reinderien Oct 17 2020 at 20:21

Costanti

data_filename dovrebbe essere in maiuscolo, poiché è una costante globale.

Ritorno anticipato

Non è necessario utilizzare numbercome condizione sul tuo while; anziché:

number = None
while number is None:
    try:
        number = int(input(prompt))
    except ValueError:
        print(errmsg)
return number

può essere

while True:
    try:
        return int(input(prompt))
    except ValueError:
        print(errmsg)

Gestione dei menu

display_menupotrebbe usare una sequenza di tuple, meglio ancora una sequenza di tuple con nome o @dataclasses, ciascuna con un attributo stringa del titolo e un attributo richiamabile. Allora il tuo display_menupotrebbe essere

print('What do you want to do?')
print('\n'.join(f'[{i}] {item.title}' for i, item in enumerate(menu, 1)))

Vedo che hai anche un Choicesenum. Non è male, e potresti potenzialmente usare sia un enum che il suggerimento sopra, purché tu abbia un dizionario di enumerazioni-scelte-a-riferimenti-funzione.

Test di appartenenza

answer != 'y' and answer != 'n'

può essere

answer not in {'y', 'n'}

Struttura ad anello

Evita di dover chiamare inputdue volte; Questo:

answer = input(prompt).strip().lower()
while answer != 'y' and answer != 'n':
    print(errmsg)
    answer = input(prompt).strip().lower()
return answer

può essere

while True:
    answer = input(prompt).strip().lower()
    if answer in {'y', 'n'}:
        return answer
    print(errmsg)

Operazioni sui file

file = open(data_filename, 'a+b')
file.seek(0)

ha alcuni problemi:

  • Gli handle di file appena aperti non necessitano di una ricerca iniziale iniziale; è ridondante se apri in modalità binaria di lettura
  • Dovresti usare withed evitare un file esplicitoclose()
  • Dovresti preoccuparti di inizializzare l'impostazione predefinita solo dataquando necessario
  • Vuoi verificare se il file esiste, non la sua dimensione

Così:

if os.path.exists(data_filename):
    with open(data_filename, 'rb') as file:
        return pickle.load(file)
return {'events': [], 'statistics': {'total_successes': 0, 'total_failures': 0}}

Predicati ridondanti

if 0 < num <= len(events):
    ...
elif num < 0:
    print('Number of questions can\'t be negative!')
else:
    print('Too much questions!')

deve essere ripensato. In primo luogo, too much questionsdovrebbe essere too many questions. Inoltre, cosa succede se l'utente inserisce 0? Sicuramente non sono "troppe domande", ma è quello che verrà stampato. Suggerito:

if num < 1:
    print('Not enough questions.')
elif num > len(events):
    print('Too many questions.')
else:
    ...

Dati più tipizzati, più strutturati

stats['total_successes'] + stats['total_failures']

è un esempio di quello che ho visto chiamato "data pasta". I dizionari vengono abusati, qui, quando qualcosa come un @dataclasssuggerimento di tipo contenente è più appropriato.

Interpolazione di stringhe

'Total: {0:10.2f}% successes, {1:10.2f}% failures'.format(total_successes, total_failures)

è più facilmente espresso come

(
    f'Total: {total_successes:10.2f}% successes, '
    f'{total_failures:10.2f}% failures'
)

La divisione della riga è facoltativa ma migliore per la leggibilità.

String sfugge

'Number of questions can\'t be negative!'

è più facilmente scritto come

"Number of questions can't be negative!"