9
\$\begingroup\$

I decided to build a quiz type of game for improving chemistry linguistics using python. Currently there is not much variety in terms of questions, but before expanding the question base, I would like my code to be reviewed.

I have used various modules including colorama, pyfiglet and inquirer to improve the UX. Any better alternatives and approaches are welcomed.

Any improvements regarding performance, code readability, UX are highly appreciated

Here is the directory structure:

iChemistry -
           - data
                 - elements.json
                 - compounds.json
           - compound.py
           - element.py
           - questions.py
           - main.py

elements.json

{
    "Hydrogen": {
        "symbol": "H",
        "atomic_no": 1,
        "mass_no": 1
    },
    "Helium": {
        "symbol": "He",
        "atomic_no": 2,
        "mass_no": 4
    },
    "Lithium": {
        "symbol": "Li",
        "atomic_no": 3,
        "mass_no": 7
    },
    "Beryllium": {
        "symbol": "Be",
        "atomic_no": 4,
        "mass_no": 9
    },
    "Boron": {
        "symbol": "B",
        "atomic_no": 5,
        "mass_no": 11
    },
    "Carbon": {
        "symbol": "C",
        "atomic_no": 6,
        "mass_no": 12
    },
    "Nitrogen": {
        "symbol": "N",
        "atomic_no": 7,
        "mass_no": 14
    },
    "Oxygen": {
        "symbol": "O",
        "atomic_no": 8,
        "mass_no": 16
    },
    "Fluorine": {
        "symbol": "F",
        "atomic_no": 9,
        "mass_no": 19
    },
    "Neon": {
        "symbol": "Ne",
        "atomic_no": 10,
        "mass_no": 20
    },
    "Sodium": {
        "symbol": "Na",
        "atomic_no": 11,
        "mass_no": 23
    },
    "Magnesium": {
        "symbol": "Mg",
        "atomic_no": 12,
        "mass_no": 24
    },
    "Aluminium": {
        "symbol": "Al",
        "atomic_no": 13,
        "mass_no": 27
    },
    "Silicon": {
        "symbol": "Si",
        "atomic_no": 14,
        "mass_no": 28
    },
    "Phosphorus": {
        "symbol": "P",
        "atomic_no": 15,
        "mass_no": 31
    },
    "Sulphur": {
        "symbol": "S",
        "atomic_no": 16,
        "mass_no": 32
    },
    "Chlorine": {
        "symbol": "Cl",
        "atomic_no": 17,
        "mass_no": 35.5
    },
    "Argon": {
        "symbol": "Ar",
        "atomic_no": 18,
        "mass_no": 40
    }
}

compounds.json

{
    "Hydrogen monoxide": "H2O",
    "Hydrogen peroxide": "H2O2",
    "Hydrogen sulphate": "H2SO4",
    "Hydrogen chloride": "HCl",
    "Hydrogen sulphide": "H2S",
    "Carbon dioxide": "CO2",
    "Carbon tetrachloride": "CCl4",
    "Methane": "CH4",
    "Ethanol": "C2H5OH",
    "Ammonia": "NH3",
    "Sulphur dioxide": "SO2"
}

element.py

import json

class Element:
    def __init__(self, name, symbol, atomic_no, mass_no):
        self.name = name
        self.symbol = symbol
        self.atomic_no = atomic_no
        self.mass_no = mass_no

    def get_no_of_electrons(self):
        return self.atomic_no

    def get_no_of_protons(self):
        return self.atomic_no

    def get_no_of_neutrons(self):
        return self.mass_no - self.atomic_no

    def get_electronic_configuration(self):
        i = 1
        electrons_left = self.atomic_no
        configuration = []
        while electrons_left > 0:
            capacity = 2 * (i ** 2)
            if electrons_left >= capacity:
                configuration.append(capacity)
                electrons_left -= capacity
            else:
                configuration.append(electrons_left)
                electrons_left = 0
            i += 1
        return configuration

    def __repr__(self):
        return self.name

ELEMENTS = []
with open("./data/elements.json", "r") as file:
    for name, data in json.load(file).items():
        ELEMENTS.append(Element(name, *data.values()))

compound.py

import json

class Compound:
    def __init__(self, name, formula):
        self.name = name
        self.formula = formula
        
COMPOUNDS = []
with open("./data/compounds.json", "r") as file:
    for name, formula in json.load(file).items():
        COMPOUNDS.append(Compound(name, formula))

questions.py

from element import Element, ELEMENTS
from compound import Compound, COMPOUNDS
import inquirer
import random
from colorama import Fore, Style

def ask_symbol() -> tuple[str, bool]:
    element = random.choice(ELEMENTS)
    answer = inquirer.prompt(
        [inquirer.Text("name", Fore.BLUE + f"What is the symbol of {element.name}" + Style.RESET_ALL)]
    )["name"].strip()
    return element.symbol, answer == element.symbol

def ask_name() -> tuple[str, bool]:
    element = random.choice(ELEMENTS)
    answer = inquirer.prompt(
        [inquirer.Text("symbol", Fore.BLUE + f"What is the name of {element.symbol}?" + Style.RESET_ALL)]
    )["symbol"].strip()
    return element.name, answer == element.name

def ask_atomic_no() -> tuple[int, bool]:
    element = random.choice(ELEMENTS)
    try:
        answer = inquirer.prompt(
            [inquirer.Text("atomic_no", Fore.BLUE + f"What is the atomic number of {get_name_or_symbol(element)}?" + Style.RESET_ALL)]
        )["atomic_no"]
        answer = int(answer.strip())
    except ValueError:
        answer = None
    return element.atomic_no, answer == element.atomic_no

def ask_mass_no() -> tuple[float, bool]:
    element = random.choice(ELEMENTS)
    try:
        answer = inquirer.prompt(
            [inquirer.Text("mass_no", Fore.BLUE + f"What is the mass number of {get_name_or_symbol(element)}?" + Style.RESET_ALL)]
        )["mass_no"]
        answer = float(answer.strip())
    except ValueError:
        answer = None
    return element.mass_no, answer == element.mass_no

def ask_electronic_configuration() -> tuple[list[int], bool]:
    element = random.choice(ELEMENTS)
    try:
        answer = inquirer.prompt(
            [inquirer.Text("electronic_config", Fore.BLUE + f"What is the electronic configuration of {get_name_or_symbol(element)}?" + Style.RESET_ALL)]
        )["electronic_config"]
        answer = [int(i) for i in answer.strip().replace(" ", "").split(",")]
    except ValueError:
        answer = None
    correct_configuration = element.get_electronic_configuration()
    return correct_configuration, answer == correct_configuration

def ask_chemical_formula():
    compound = random.choice(COMPOUNDS)
    answer = inquirer.prompt(
        [inquirer.Text("formula", Fore.BLUE + f"What is the chemical formula of {compound.name}")]
    )["formula"].strip()
    correct_formula = compound.formula
    return correct_formula, answer == correct_formula

def get_name_or_symbol(element: Element) -> str:
    return random.choice([element.name, element.symbol])

QUESTIONS = [
    ask_symbol, ask_name,
    ask_atomic_no, ask_mass_no,
    ask_electronic_configuration, ask_chemical_formula
]

main.py

import time
import random
import inquirer
from colorama import Fore, Style
from pyfiglet import Figlet

from questions import QUESTIONS

class iChemistry:
    def __init__(self):
        self.name = None

    def intro(self):
        figlet = Figlet()
        text = "iChemistry"
        print(Style.BRIGHT + Fore.BLUE + figlet.renderText(text) + "\t- An interactive way to learn chemistry..." + Style.RESET_ALL)

        self.name = input("What is your name? ").strip()
        print(f"Welcome {self.name}!")

    def start(self):
        self.intro()
        print("Answer the questions, as quickly as possible!")
        input("Ready? ")
        while True:
            question = random.choice(QUESTIONS)
            correct_answer, has_answered = question()
            if has_answered:
                print(Fore.GREEN + "Correct! Keep up the confidence!" + Style.RESET_ALL)
            else:
                print(Fore.RED + f"Oops! The correct answer is {correct_answer}" + Style.RESET_ALL)
            time.sleep(0.5)

if __name__ == "__main__":
    interface = iChemistry()
    interface.start()

Thank you and happy coding!!

\$\endgroup\$

2 Answers 2

5
\$\begingroup\$

compund.py

It looks like your Compund is not doing any business logic, so it would make sense to define it as a dataclass (or a pydantic model, which has benefits if you want to add some sophisticated validation and/or connect it to FastAPI if you decide to make this a web app).

import json
from dataclasses import dataclass

@dataclass
class Compound:
    name: str
    formula: str
        
COMPOUNDS = []
with open("./data/compounds.json", "r") as file:
    for name, formula in json.load(file).items():
        COMPOUNDS.append(Compound(name, formula))

element.py

  • it's good to use a function for an instance attribute if a computation is needed (and it's significant / heavy), otherwise a property makes more sense and is more "pythonic" (the get_<something>, set_<something> methods look very Java / C# like).
  • you probably want to use __str__ for human readable str representation of the class, __repr__ has different uses.
import json

class Element:
    def __init__(self, name, symbol, atomic_no, mass_no):
        self.name = name
        self.symbol = symbol
        self.atomic_no = atomic_no
        self.mass_no = mass_no

    @property
    def electrons(self):
        return self.atomic_no

    @property
    def protons(self):
        return self.atomic_no

    @property
    def neutrons(self):
        return self.mass_no - self.atomic_no

    def get_electronic_configuration(self):
        i = 1
        electrons_left = self.atomic_no
        configuration = []
        while electrons_left > 0:
            capacity = 2 * (i ** 2)
            if electrons_left >= capacity:
                configuration.append(capacity)
                electrons_left -= capacity
            else:
                configuration.append(electrons_left)
                electrons_left = 0
            i += 1
        return configuration

    def __repr__(self):
        return f"Element({self.name},
        {self.symbol},
        {self.atomic_no},
        {self.mass_no})"
    def __str__(self):
         return self.name

ELEMENTS = []
with open("./data/elements.json", "r") as file:
    for name, data in json.load(file).items():
        ELEMENTS.append(Element(name, *data.values()))

main.py

Suggestions:

  • remove the "i" from the name, python uses PascalCase for class names
  • rename has_answered, as it suggests, that any answer has been given, while this variable tells whether the correct answer has been given (maybe has_answered_correctly)
  • you never break from the loop, use some condition, if the question base is not huge you can remove them when randomly choosing and finish the app if you run out of questions (that would require some modifictions in questions.py as well)
\$\endgroup\$
1
  • \$\begingroup\$ Thanks for the review!! I appreciate your efforts! \$\endgroup\$ Mar 26, 2023 at 15:53
2
\$\begingroup\$

Element and simple data classes like it should be modelled by something that is immutable - a properly configured @dataclass, or more simply, NamedTuple.

get_no_of_electrons and functions like it are better-modelled by @property functions.

get_electronic_configuration could be modelled as an iterator function.

Since ELEMENTS and lists like it are initialised in the global namespace, that means that you're forcing anyone that imports your file to immediately run your initialisation. That's not very nice. You already have a class - why not load your data in its constructor?

__repr__ is probably better as __str__. The former is meant to be, literally, a representation - of the object in a serializable or semi-serializable form, and the latter as a human-legible string.

Consider rephrasing your JSON loading code as a comprehension.

Your ask questions can be simplified by having them return a prompt and the correct answer, and letting centralised code handle the rest.

I demonstrate how this can be done, leaving out your ANSI-code formatting.

import json
import random
from typing import NamedTuple, Iterator


class Element(NamedTuple):
    name: str
    symbol: str
    atomic_no: int
    mass_no: int

    @property
    def n_electrons(self) -> int:
        return self.atomic_no

    @property
    def n_protons(self) -> int:
        return self.atomic_no

    @property
    def n_neutrons(self) -> int:
        return self.mass_no - self.atomic_no

    def get_electronic_configuration(self) -> Iterator[int]:
        i = 1
        electrons_left = self.atomic_no
        while electrons_left > 0:
            capacity = 2 * i**2
            if electrons_left >= capacity:
                yield capacity
                electrons_left -= capacity
            else:
                yield electrons_left
                electrons_left = 0
            i += 1

    def __str__(self) -> str:
        return self.name

    def get_name_or_symbol(self) -> str:
        return random.choice((self.name, self.symbol))


class Compound(NamedTuple):
    name: str
    formula: str


class IChemistry:
    def __init__(self) -> None:
        with open("compounds.json") as file:
            self.compounds = [
                Compound(*pair)
                for pair in json.load(file).items()
            ]

        with open("elements.json") as file:
            self.elements = [
                Element(name=name, **data)
                for name, data in json.load(file).items()
            ]

        self.questions = (
            self.ask_symbol, self.ask_name,
            self.ask_atomic_no, self.ask_mass_no,
            self.ask_electronic_configuration, self.ask_chemical_formula
        )

    @staticmethod
    def intro() -> None:
        print("iChemistry - An interactive way to learn chemistry...")
        name = input("What is your name? ").strip()
        print(f"Welcome {name}!")

    def run(self):
        self.intro()
        print("Answer the questions, as quickly as possible!")
        input("Ready? ")
        print()

        while True:
            question = random.choice(self.questions)
            prompt, correct_answer = question()
            answer = input(f'{prompt}? ').strip()
            if answer.lower() == correct_answer.lower():
                print("Correct! Keep up the confidence!")
            else:
                print(f"Oops! The correct answer is {correct_answer}")
            print()

    def ask_symbol(self) -> tuple[str, str]:
        element = random.choice(self.elements)
        return f"What is the symbol of {element.name}", element.symbol

    def ask_name(self) -> tuple[str, str]:
        element = random.choice(self.elements)
        return f"What is the name of {element.symbol}", element.name

    def ask_atomic_no(self) -> tuple[str, str]:
        element = random.choice(self.elements)
        return f"What is the atomic number of {element.get_name_or_symbol()}", str(element.atomic_no)

    def ask_mass_no(self) -> tuple[str, str]:
        element = random.choice(self.elements)
        return f"What is the mass number of {element.get_name_or_symbol()}", str(element.mass_no)

    def ask_electronic_configuration(self) -> tuple[str, str]:
        element = random.choice(self.elements)
        answer = ','.join(str(n) for n in element.get_electronic_configuration())
        return f"What is the electronic configuration of {element.get_name_or_symbol()}", answer

    def ask_chemical_formula(self) -> tuple[str, str]:
        compound = random.choice(self.compounds)
        return f"What is the chemical formula of {compound.name}", compound.formula


def main() -> None:
    interface = IChemistry()
    interface.run()


if __name__ == "__main__":
    main()
iChemistry - An interactive way to learn chemistry...
What is your name? blobbert
Welcome blobbert!
Answer the questions, as quickly as possible!
Ready? no

What is the electronic configuration of O? 2,1
Oops! The correct answer is 2,6

What is the chemical formula of Methane? ch4
Correct! Keep up the confidence!

What is the chemical formula of Carbon dioxide? co2
Correct! Keep up the confidence!

What is the symbol of Oxygen? o
Correct! Keep up the confidence!
\$\endgroup\$
2
  • \$\begingroup\$ Thanks for the review!! I will surely implement your ideas to improve my code... I purposefully did not convert the answers to lowercase... It hurts me that you converted all answers to lowercase... (ch4 is not CH4) \$\endgroup\$ Mar 26, 2023 at 15:52
  • \$\begingroup\$ Then you should include an explanatory prompt to the user that the answers are case-sensitive \$\endgroup\$
    – Reinderien
    Mar 26, 2023 at 16:14

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.