Prośba o ocenę programu w Pythonie

0

Siema! Rzuci ktoś okiem na mój programik napisany w Pythonie?
Piszę program do nauki historii. Mam zamiar dokonać w nim kilku ulepszeń jak np. ogarnięcie formatu json zamiast pickle, zrobienie jakiegoś gui w tk lub curses, dodanie ascii artów.
Byłbym wdzięczny za code review.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import date
import pickle
import enum
import os
import random


def read_int(prompt=None, error=None):
    if prompt is None:
        prompt = 'Enter a number'
    if error is None:
        error = 'Not a number.'
    while True:
        try:
            return int(input(prompt))
        except ValueError:
            print(error)


def read_in_range(prompt=None, error=None, lower=None, upper=None):
    if prompt is None:
        prompt = f'Enter a number in range [{lower}; {upper}): '
    if error is None:
        error = 'Not a number.'
    while True:
        try:
            user_input = input(prompt)
            if not user_input.isdigit():
                raise ValueError(error)
            user_input = int(user_input)
            if lower and user_input < lower:
                raise ValueError(f'Too small: number have to be in range [{lower}; {upper}).')
            if upper and user_input > upper:
                raise ValueError(f'Too high: number have to be in range [{lower}; {upper}).')
            return user_input
        except ValueError as e:
            print(e)


@enum.unique
class Era(enum.Enum):
    BCE = enum.auto()
    CE = enum.auto()


def read_historical_year(prompt=None, error=None):
    if prompt is None:
        prompt = f'Enter a year in the format yyyy [{Era.BCE.name}|{Era.CE.name}]: '
    if error is None:
        error = f'Year must be in the format yyyy [{Era.BCE.name}|{Era.CE.name}].'

    while True:
        user_input = input(prompt)
        user_input = user_input.split(' ')
        year = user_input[0]
        era = None
        if len(user_input) == 2:
            era = user_input[-1]
        elif len(user_input) == 1:
            era = Era.CE.name
        if not year.isdigit() or era not in {Era.BCE.name, Era.CE.name}:
            print(error)
        else:
            if era == Era.BCE.name:
                return HistoricalDate(int(year), Era.BCE)
            else:
                return HistoricalDate(int(year), Era.CE)


class Choice:
    def __init__(self, name, value):
        self.name = name
        self.value = value

    def __str__(self):
        return self.name


def read_choice(choices, header=None, prompt=None, error=None):
    if header is None:
        header = 'Available choices:'
    if prompt is None:
        prompt = 'Your choice: '
    if error is None:
        error = 'Invalid choice!'

    while True:
        try:
            print(header)
            for offset, item in enumerate(choices):
                print(f'{offset}) {item}')
            user_choice = input(prompt)
            if user_choice.isdigit():
                user_choice = int(user_choice)
            else:
                raise ValueError('Not a number.')
            if user_choice < 0 or user_choice >= len(choices):
                raise ValueError(error)
            return choices[user_choice].value
        except ValueError as e:
            print(e)


today = date.today()


class HistoricalDate:
    def __init__(self, year=today.year, era=Era.CE):
        self.year = year
        self.era = era

    def __str__(self):
        return f'{self.year} {self.era.name}'

    def __add__(self, other):
        current_year = self.year
        if ((self.era == Era.CE and other.era == Era.CE)
                or (self.era == Era.BCE and other.era == Era.BCE)):
            current_year += other.year
        elif self.era == Era.CE and other.era == Era.BCE:
            current_year -= other.year
            if current_year <= 0:
                current_year -= 1
                return HistoricalDate(-current_year, Era.BCE)
        else:
            current_year -= other.year
            if current_year <= 0:
                current_year -= 1
                return HistoricalDate(-current_year, Era.CE)
        return HistoricalDate(current_year, self.era)

    def __sub__(self, other):
        current_year = self.year
        if self.era == Era.CE and other.era == Era.CE:
            current_year -= other.year
            if current_year <= 0:
                current_year -= 1
                return HistoricalDate(-current_year, Era.BCE)
            return HistoricalDate(current_year, Era.CE)
        elif self.era == Era.CE and other.era == Era.BCE:
            current_year += other.year
            return HistoricalDate(current_year, Era.CE)
        elif self.era == Era.BCE and other.era == Era.CE:
            current_year += other.year
            return HistoricalDate(current_year, Era.BCE)
        else:
            current_year -= other.year
            if current_year <= 0:
                current_year -= 1
                return HistoricalDate(-current_year, Era.CE)
            return HistoricalDate(current_year, Era.BCE)

    def __eq__(self, other):
        return self.year == other.year

    def __ne__(self, other):
        return not self == other

    def __lt__(self, other):
        if self.era == Era.CE and other.era == Era.CE:
            return self.year < other.year
        elif self.era == Era.BCE and other.era == Era.BCE:
            return self.year > other.year
        elif self.era == Era.CE and other.era == Era.BCE:
            return False
        else:
            return True

    def __gt__(self, other):
        if self.era == Era.CE and other.era == Era.CE:
            return self.year > other.year
        elif self.era == Era.BCE and other.era == Era.BCE:
            return self.year < other.year
        elif self.era == Era.CE and other.era == Era.BCE:
            return True
        else:
            return False

    def __le__(self, other):
        return not self > other

    def __ge__(self, other):
        return not self < other

    def century(self):
        return ((self.year - 1) // 100) + 1

    def millennium(self):
        return ((self.year - 1) // 1000) + 1


class HistoricalPeriod:
    def __init__(self, begin, end):
        self.begin = begin
        self.end = end

    def __contains__(self, item):
        return self.begin <= item < self.end

    def duration(self):
        return self.end - self.begin


antiquity = HistoricalPeriod(HistoricalDate(3500, Era.BCE), HistoricalDate(476))
middle_ages = HistoricalPeriod(HistoricalDate(476), HistoricalDate(1492))
dark_ages = middle_ages
early_modern_period = HistoricalPeriod(HistoricalDate(1492), HistoricalDate(1789))
nineteenth_age = HistoricalPeriod(HistoricalDate(1789), HistoricalDate(1914))
modernity = HistoricalPeriod(HistoricalDate(1914), HistoricalDate())

historical_periods = [antiquity, middle_ages, early_modern_period, nineteenth_age, modernity]


def read_historical_period(prompt=None, error=None):
    if prompt is None:
        prompt = 'Your choice: '
    if error is None:
        error = 'Invalid choice!'
    choices = [
        Choice('Antiquity', antiquity),
        Choice('Middle ages', middle_ages),
        Choice('Early modern period', early_modern_period),
        Choice('Nineteenth age', nineteenth_age),
        Choice('Modernity', modernity)
    ]
    return read_choice(choices, prompt=prompt, error=error, header='Choose a historical period:')


def get_period(event_date):
    return [period for period in historical_periods if event_date in period][0]


class HistoricalEvent:
    def __init__(self, event_date, description):
        self.date = event_date
        self.description = description
        self.period = get_period(event_date)

    def __str__(self):
        return f'({self.date}) {self.description}'


class MenuItem:
    def __init__(self, name, action):
        self.name = name
        self.action = action

    def invoke(self):
        self.action()


class Menu:
    def __init__(self, title='', fancy_banner=False):
        self.items = []
        self.title = title
        self.fancy_banner = fancy_banner

    def print_banner(self):
        if self.fancy_banner:
            banner_len = len(self.title)
            longest = len(max((item.name for item in self.items), key=len)) if len(self.items) > 0 else 0
            if longest < banner_len:
                longest = banner_len + 1
            digits = len(str(len(self.items)))
            special = 2
            print('#' * (longest + special + digits))
            print(f'#{self.title:^{longest + digits}s}#')
            print('#' * (longest + special + digits))
        else:
            print(self.title)

    def add_item(self, name, action):
        self.items.append(MenuItem(name, action))

    def loop(self):
        while True:
            self.print_banner()
            offset = 0
            for offset, item in enumerate(self.items):
                print(f'{offset}) {item.name}')
            print(f'{offset+1}) Quit')
            user_choice = read_int('Your choice: ')
            if user_choice == offset+1:
                return
            if user_choice in range(0, len(self.items)):
                self.items[user_choice].invoke()
            else:
                print('Invalid choice!')


@enum.unique
class QuestionType(enum.Enum):
    DATE = enum.auto()
    PERIOD = enum.auto()
    RANDOM = enum.auto()


class Question:
    def __init__(self, name, answer, question_type):
        self.name = name
        self.answer = answer
        self.question_type = question_type


def generate_questions(events, num_questions, question_type):
    names = {QuestionType.DATE: 'In which year was happened following event?\n',
             QuestionType.PERIOD: 'In which period was happened following event?\n'}
    result = []
    events = events[0:num_questions]
    for event in events:
        current_type = question_type
        if question_type == QuestionType.RANDOM:
            choices = random.choice(list(names.items()))
            current_type = choices[0]
            name = choices[1]
        else:
            name = names[question_type]
        name += event.description
        answers = {QuestionType.DATE: event.date,
                   QuestionType.PERIOD: event.period}
        answer = answers[current_type]
        question = Question(name=name, answer=answer, question_type=current_type)
        result.append(question)
    return result


def ask_question(question):
    print(question.name)
    answer = None
    if question.question_type == QuestionType.DATE:
        answer = read_historical_year('Your answer (enter the year in format YYYY [BCE|CE]: ')
    elif question.question_type == QuestionType.PERIOD:
        answer = read_historical_period('Your answer: ')
    if question.answer == answer:
        print('Good answer!')
        return True
    else:
        print('Bad answer!')
        return False


class EventsCalendar:
    def __init__(self):
        self.events = []

    def add(self, event_date, description):
        self.events.append(HistoricalEvent(event_date, description))

    def remove_at(self, index):
        return self.events.pop(index)

    def remove(self, event):
        self.events.remove(event)
        return event

    def search_by_date(self, event_date):
        return list(filter(lambda event: event.date == event_date, self.events))

    def search_by_period(self, event_period):
        return list(filter(lambda event: event.period == event_period, self.events))

    def search_by_desc(self, description):
        return list(filter(lambda event: description in event.description, self.events))

    def all_events(self):
        return self.events

    def is_present(self, event):
        return event in self.events


class EventsCalendarUI:
    def __init__(self):
        if not os.path.exists(self.data_filename):
            self.events_calendar = EventsCalendar()
        with open(self.data_filename, 'rb') as app_data:
            self.events_calendar = pickle.load(app_data)

    def add_event(self):
        year = read_historical_year()
        description = input('Enter an event description: ')
        self.events_calendar.add(year, description)
        print('Done!')

    def remove_event(self):
        events = self.events_calendar.all_events()
        if not events:
            print('Event list is empty!')
            return

        year = read_historical_year()
        print('Found events:')
        events = self.events_calendar.search_by_date(year)
        for offset, desc in enumerate(events):
            print(f'{offset}) {desc}')
        index = read_in_range(prompt='Which one do you want to remove? ',
                              error='No such element at given index',
                              lower=0, upper=len(events))
        self.events_calendar.remove_at(index)

    def list_events(self):
        events = self.events_calendar.all_events()
        if not events:
            print('Events list is empty!')
            return

        for event in events:
            print(event)

    def quiz(self):
        events = self.events_calendar.all_events()
        if not events:
            print('Event list is empty!')
            return

        num_questions = read_in_range('How many questions should I ask? ', lower=1, upper=len(events))
        questions = generate_questions(events, num_questions, QuestionType.DATE)
        for question in questions:
            ask_question(question)

    def search_events(self):
        events = self.events_calendar.all_events()
        if not events:
            print('Event list is empty!')
            return

        def search_events_by_year():
            year = read_historical_year()
            found_events = self.events_calendar.search_by_date(year)
            if not found_events:
                print('No such any events at given year.')
                return
            for event in found_events:
                print(event)

        def search_events_by_period():
            period = read_historical_period()
            found_events = self.events_calendar.search_by_period(period)
            if not found_events:
                print('No such any events at given period.')
                return
            for event in found_events:
                print(event)

        def search_events_by_desc():
            user_input = input('Enter a fragment of event description: ')
            found_events = self.events_calendar.search_by_desc(user_input)
            if not found_events:
                print('No such any events.')
                return
            for event in found_events:
                print(event)

        search_menu = Menu(title='Choose the search criteria')
        search_menu.add_item('Search events by year', search_events_by_year)
        search_menu.add_item('Search events by period', search_events_by_period)
        search_menu.add_item('Search events by description', search_events_by_desc)
        search_menu.loop()

    def save_events(self):
        with open(self.data_filename, 'wb') as app_data:
            pickle.dump(self.events_calendar, app_data)
        print('Done!')

    data_filename = 'events_calendar.pkl'


if __name__ == '__main__':
    ui = EventsCalendarUI()
    menu = Menu(title='Main menu', fancy_banner=True)
    menu.add_item('Quiz', ui.quiz)
    menu.add_item('Add a historical event', ui.add_event)
    menu.add_item('Delete the historical event', ui.remove_event)
    menu.add_item('Search the historical event', ui.search_events)
    menu.add_item('List all events', ui.list_events)
    menu.add_item('Save events list', ui.save_events)
    menu.loop()
    print('Good bye!')

1
  • Zamień ``` na ```python to dodasz kolorowanie składni
  • Lepiej byłoby jakbyś ogarnął repozytorium na gicie (np GitHubie) wtedy mógłbyś podzielić to na pliki per klasa. Ile ten monolit na linii w ogóle?
2

Dziel, dziel i jeszcze raz dziel.

Na początek nie wygląda źle, ale dziel bardziej. Już @KamilAdam napisał, żebyś podzielił na pliki. Do tego, podziel to logicznie i tak, żeby zależności między plikami było jak najmniej. I do każdego pliku plik z jakimiś testami.

No i dziel same funkcje/metody. Jeśli jakaś funkcja ma więcej niż 10 wierszy kodu (generate_questions, HistoricalDate.__add__, HistoricalDate.__sub__, read_choice, ...), to najpewniej jest zbyt długa. Jeśli funkcja robi dwie lub więcej rzeczy (w sensie -- odpowiada merytorycznie za kilka kwestii, a nie buduje jedno działanie z kilku poddziałań na niższym poziomie abstrakcji; jak read_historical_year, read_in_range, read_choice, ...), to najpewniej jest zbyt długa. Jeśli funkcja zawiera dwa ify (read_in_range, ask_question, ...), to najpewniej jest zbyt długa. Jeśli funkcja miesza poziomy abstrakcji, to najpewniej jest zbyt długa.

Ponadto:

  • Co robi today = date.today() wrzucone losowo pomiędzy jakąś funkcję a jakąś klasę? Podobnie podstawienia antiquity = ...? Jeśli to są stałe, to zapisz ich nazwy dużymi literami, a jeśli zmienne globalne (ZŁO), to patrz niżej.
  • Wsadź wszystko co jest poza importami, klasami, funkcjami (i ewentualnie stałymi, ale u Ciebie nic nie jest napisane dużymi literami) do funkcji main(), a poza nią miej tylko:
if __name__ == '__main__':
    main()
0

@Garydos392:

nie lepiej te stałe antiquity=... wsadzić do enuma?

  1. Na temat pisz w postach, nie w komentarzach.
  2. Akurat to nie wygląda mi, że pasuje do enuma...
0
  1. Co myślicie o takiej implementacji HistoricalDate?
@functools.total_ordering
class HistoricalDate:
        def __init__(self, year=None, era=Era.CE):
            assert year != 0
            year = year or date.today().year
            if era == Era.CE:
                self._year_counter = year
            else:
                self._year_counter = 1 - year
    
        @classmethod
        def _from_year_counter(cls, year_counter):
            ret = cls()
            ret._year_counter = year_counter
            return ret
    
        @property
        def year(self):
            if self._year_counter <= 0:
                return 1 - self._year_counter
            return self._year_counter
    
        @property
        def era(self):
            return Era.CE if self._year_counter > 0 else Era.BCE
    
        @property
        def century(self):
            return ((self.year - 1) // 100) + 1
    
        @property
        def millennium(self):
            return ((self.year - 1) // 1000) + 1
    
        def __str__(self):
            return f'{self.year} {self.era.name}'
    
        def add_years(self, years):
            return HistoricalDate._from_year_counter(self._year_counter + years)
    
        def __eq__(self, other):
            return self._year_counter == other._year_counter
    
        def __lt__(self, other):
            return self._year_counter < other._year_counter
  1. Czy nazwę listy historical_periods też zamienić na wielkie litery?

1 użytkowników online, w tym zalogowanych: 0, gości: 1